Send new signups the event that starts the welcome emails (SKY-17435) (#8740)

This commit is contained in:
Shuchang Zheng 2026-09-30 15:03:36 -04:00 • committed by GitHub
parent 92e7636337
commit dca0f98fe7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 431 additions and 3937 deletions

View file

@ -1,215 +0,0 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
const { mockPost, mockNavigate, studioState, prewarmFlagState } = vi.hoisted(
() => ({
mockPost: vi.fn(),
mockNavigate: vi.fn(),
studioState: { enabled: true },
prewarmFlagState: { enabled: false },
}),
);
vi.mock("@/hooks/useWorkflowStudioEnabled", () => ({
useWorkflowStudioEnabled: () => studioState.enabled,
}));
vi.mock("@/hooks/useFeatureFlag", () => ({
useFeatureFlag: () => prewarmFlagState.enabled,
}));
vi.mock("react-router-dom", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-router-dom")>();
return {
...actual,
useNavigate: () => mockNavigate,
};
});
vi.mock("@/hooks/useCredentialGetter", () => ({
useCredentialGetter: () => () => Promise.resolve("test-token"),
}));
vi.mock("@/hooks/useUser", () => ({
useUser: () => ({ get: () => ({ id: "user-a", email: "", name: "" }) }),
}));
vi.mock("@/hooks/useCurrentOrgId", () => ({
useCurrentOrgId: () => "org-a",
}));
vi.mock("@/api/AxiosClient", () => ({
getClient: () => Promise.resolve({ post: mockPost }),
}));
vi.mock("@/routes/workflows/hooks/useGlobalWorkflowsQuery", () => ({
useGlobalWorkflowsQuery: () => ({ data: [], isLoading: false }),
}));
vi.mock("@/util/onboarding/OnboardingTelemetry", () => ({
OnboardingTelemetry: {
flowCompleted: vi.fn(),
modalCopilotClicked: vi.fn(),
modalTemplateSelected: vi.fn(),
},
}));
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { CopilotCTAStep } from "./CopilotCTAStep";
function setup(onBusyChange?: (busy: boolean) => void) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const renderStep = () => (
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<Dialog defaultOpen>
<DialogContent>
<CopilotCTAStep
selectedIntent="fill_forms"
onBack={vi.fn()}
onSkip={vi.fn()}
onDismiss={vi.fn()}
onBusyChange={onBusyChange}
/>
</DialogContent>
</Dialog>
</MemoryRouter>
</QueryClientProvider>
);
const rendered = render(renderStep());
return {
invalidateSpy,
rerender: () => rendered.rerender(renderStep()),
};
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
studioState.enabled = true;
prewarmFlagState.enabled = false;
});
describe("CopilotCTAStep", () => {
it("prewarms once on first input without prewarming on autofocus", async () => {
prewarmFlagState.enabled = true;
mockPost.mockResolvedValue({ data: {} });
setup();
const textarea = screen.getByRole("textbox");
expect(document.activeElement).toBe(textarea);
expect(mockPost).not.toHaveBeenCalled();
fireEvent.change(textarea, { target: { value: "Fill" } });
await waitFor(() =>
expect(mockPost).toHaveBeenCalledWith("/debug-session/prewarm", {
proxy_location: null,
}),
);
fireEvent.change(textarea, { target: { value: "Fill out my form" } });
expect(
mockPost.mock.calls.filter(([path]) => path === "/debug-session/prewarm"),
).toHaveLength(1);
});
it("prewarms an existing prompt when the feature flag resolves late", async () => {
mockPost.mockResolvedValue({ data: {} });
const { rerender } = setup();
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "Fill out my form" },
});
expect(mockPost).not.toHaveBeenCalled();
prewarmFlagState.enabled = true;
rerender();
await waitFor(() =>
expect(mockPost).toHaveBeenCalledWith("/debug-session/prewarm", {
proxy_location: null,
}),
);
});
it("invalidates userOnboarding after the copilot handoff creates a workflow", async () => {
mockPost.mockResolvedValue({
data: { workflow_permanent_id: "wpid_x" },
});
const { invalidateSpy } = setup();
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "fill out my form" },
});
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
await waitFor(() =>
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["userOnboarding"],
}),
);
});
it("hands the prompt off to /edit with it in location state", async () => {
mockPost.mockResolvedValue({
data: { workflow_permanent_id: "wpid_x" },
});
setup();
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "fill out my form" },
});
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
await waitFor(() =>
expect(mockNavigate).toHaveBeenCalledWith("/agents/wpid_x/studio", {
state: { copilotMessage: "fill out my form" },
}),
);
});
it("reports busy to the parent while the handoff is in flight", async () => {
mockPost.mockReturnValue(new Promise(() => {}));
const onBusyChange = vi.fn();
setup(onBusyChange);
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "fill out my form" },
});
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
await waitFor(() => expect(onBusyChange).toHaveBeenCalledWith(true));
});
it("disables Skip while the copilot handoff is in flight", async () => {
// Never resolves: keep the handoff mutation pending so we can assert the
// Skip control is disabled (otherwise a skip mid-create still navigates).
mockPost.mockReturnValue(new Promise(() => {}));
setup();
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "fill out my form" },
});
fireEvent.click(screen.getByRole("button", { name: "Create with AI" }));
await waitFor(() => {
const skip = screen.getByRole("button", { name: "Skip" });
expect((skip as HTMLButtonElement).disabled).toBe(true);
});
});
});

View file

@ -1,312 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { stringify as convertToYAML } from "yaml";
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/util/utils";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { OnboardingTelemetry } from "@/util/onboarding/OnboardingTelemetry";
import { useGlobalWorkflowsQuery } from "@/routes/workflows/hooks/useGlobalWorkflowsQuery";
import { useCreateWorkflowMutation } from "@/routes/workflows/hooks/useCreateWorkflowMutation";
import { convert } from "@/routes/workflows/editor/workflowEditorUtils";
import { useWorkflowStudioEnabled } from "@/hooks/useWorkflowStudioEnabled";
import { workflowEditorPath } from "@/routes/workflows/studioNavigation";
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import type { WorkflowCreateYAMLRequest } from "@/routes/workflows/types/workflowYamlTypes";
import { useBrowserSessionPrewarm } from "@/routes/tasks/create/useBrowserSessionPrewarm";
import {
getTemplatesForIntent,
getTemplateIcon,
getSetupTime,
} from "./templateUtils";
const SURFACE = "discover" as const;
const INTENT_PLACEHOLDERS: Record<string, string> = {
fill_forms: "Describe the form you want to fill out...",
extract_data: "What data do you want to extract and from where?",
monitor_website: "What website changes do you want to monitor?",
something_else: "Describe what you want to automate...",
};
const HANDOFF_TITLE_MAX_LEN = 80;
function deriveTitle(prompt: string): string {
const collapsed = prompt.replace(/\s+/g, " ").trim();
if (!collapsed) return "New Workflow";
if (collapsed.length <= HANDOFF_TITLE_MAX_LEN) return collapsed;
return `${collapsed.slice(0, HANDOFF_TITLE_MAX_LEN - 1).trimEnd()}...`;
}
type CopilotCTAStepProps = {
selectedIntent: string;
onBack: () => void;
onSkip: () => void;
onDismiss: () => void;
/** Reports in-flight creations so the parent can ignore its dialog-level
* close path (Escape / X), which cannot see this step's mutations. */
onBusyChange?: (busy: boolean) => void;
};
function CopilotCTAStep({
selectedIntent,
onBack,
onSkip,
onDismiss,
onBusyChange,
}: Readonly<CopilotCTAStepProps>) {
const navigate = useNavigate();
const studioEnabled = useWorkflowStudioEnabled();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const [promptText, setPromptText] = useState("");
const prewarmBrowserSession = useBrowserSessionPrewarm(null);
useEffect(() => {
prewarmBrowserSession(promptText);
}, [prewarmBrowserSession, promptText]);
const [showFallback, setShowFallback] = useState(false);
const submitRef = useRef(false);
const { data: globalTemplates = [], isLoading: templatesLoading } =
useGlobalWorkflowsQuery();
const createWorkflowMutation = useCreateWorkflowMutation();
const handoffMutation = useMutation({
mutationFn: async (prompt: string) => {
const client = await getClient(credentialGetter);
const request: WorkflowCreateYAMLRequest = {
title: deriveTitle(prompt),
description: "",
ai_fallback: true,
code_version: 2,
run_with: "agent",
workflow_definition: {
version: 2,
blocks: [],
parameters: [],
},
};
const yaml = convertToYAML(request);
const result = await client.post<WorkflowApiResponse>(
"/workflows",
yaml,
{ headers: { "Content-Type": "text/plain" } },
);
return { workflow: result.data, prompt };
},
onSuccess: ({ workflow, prompt }) => {
OnboardingTelemetry.flowCompleted(SURFACE);
queryClient.invalidateQueries({ queryKey: ["workflows"] });
queryClient.invalidateQueries({ queryKey: ["folders"] });
// Let the provider observe the backend first_save_at milestone so the
// first_workflow_created funnel event fires for copilot-first users.
queryClient.invalidateQueries({ queryKey: ["userOnboarding"] });
onDismiss();
navigate(
workflowEditorPath(workflow.workflow_permanent_id, studioEnabled),
{
state: { copilotMessage: prompt },
},
);
},
onError: () => {
setShowFallback(true);
},
onSettled: () => {
submitRef.current = false;
},
});
const busy = handoffMutation.isPending || createWorkflowMutation.isPending;
useEffect(() => {
onBusyChange?.(busy);
}, [busy, onBusyChange]);
useEffect(() => {
return () => {
onBusyChange?.(false);
};
}, [onBusyChange]);
function handleSubmit() {
const trimmed = promptText.trim();
if (!trimmed || submitRef.current || handoffMutation.isPending) return;
submitRef.current = true;
OnboardingTelemetry.modalCopilotClicked(SURFACE, selectedIntent, trimmed);
handoffMutation.mutate(trimmed);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
function handleTemplateSelect(template: WorkflowApiResponse) {
if (createWorkflowMutation.isPending) return;
OnboardingTelemetry.modalTemplateSelected(
SURFACE,
template.workflow_permanent_id,
selectedIntent,
);
const cloned = convert(
{ ...template, title: `${template.title} (copy)` },
{ asNewWorkflow: true },
);
// flow_completed fires from useCreateWorkflowMutation (it owns the navigation
// that unmounts this modal); the navigation also dismisses the modal.
createWorkflowMutation.mutate({ ...cloned, _via: "onboarding_template" });
}
const filteredTemplates =
globalTemplates.length > 0
? getTemplatesForIntent(globalTemplates, selectedIntent)
: [];
if (showFallback) {
return (
<>
<DialogHeader>
<DialogTitle className="text-xl">Or pick a template</DialogTitle>
<DialogDescription>
Choose a pre-built workflow and customize it in the editor.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-2">
{templatesLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-lg" />
))
) : filteredTemplates.length > 0 ? (
filteredTemplates.map((template) => {
const Icon = getTemplateIcon(template);
return (
<button
key={template.workflow_permanent_id}
type="button"
disabled={createWorkflowMutation.isPending}
onClick={() => handleTemplateSelect(template)}
className={cn(
"flex flex-col items-start gap-2 rounded-lg border border-border p-4 text-left transition-colors",
"hover:border-primary hover:bg-primary/5",
createWorkflowMutation.isPending &&
"pointer-events-none opacity-50",
)}
>
<Icon className="h-6 w-6 text-primary" />
<div className="min-w-0 self-stretch">
<p className="truncate text-sm font-medium">
{template.title}
</p>
{template.description && (
<p className="line-clamp-2 text-xs text-muted-foreground">
{template.description}
</p>
)}
</div>
<span className="text-xs text-muted-foreground">
~{getSetupTime(template)} setup
</span>
</button>
);
})
) : (
<p className="col-span-2 py-8 text-center text-sm text-muted-foreground">
No templates available yet.
</p>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
size="sm"
onClick={() => setShowFallback(false)}
disabled={createWorkflowMutation.isPending}
>
Back
</Button>
<Button
variant="ghost"
size="sm"
onClick={onSkip}
disabled={createWorkflowMutation.isPending}
>
Skip
</Button>
</DialogFooter>
</>
);
}
return (
<>
<DialogHeader>
<DialogTitle className="text-xl">
Describe what you want to automate
</DialogTitle>
<DialogDescription>
Tell us in plain language and our AI copilot will build a workflow for
you.
</DialogDescription>
</DialogHeader>
<div className="py-2">
<Textarea
value={promptText}
onChange={(e) => setPromptText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
INTENT_PLACEHOLDERS[selectedIntent] ??
INTENT_PLACEHOLDERS.something_else
}
className="min-h-[100px] resize-none"
disabled={handoffMutation.isPending}
autoFocus
/>
{handoffMutation.isPending && (
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Setting up your workflow...
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
size="sm"
onClick={onBack}
disabled={handoffMutation.isPending}
>
Back
</Button>
<Button
variant="ghost"
size="sm"
onClick={onSkip}
disabled={handoffMutation.isPending}
>
Skip
</Button>
<Button
size="sm"
disabled={!promptText.trim() || handoffMutation.isPending}
onClick={handleSubmit}
>
{handoffMutation.isPending ? "Creating..." : "Create with AI"}
</Button>
</DialogFooter>
</>
);
}
export { CopilotCTAStep };
export type { CopilotCTAStepProps };

View file

@ -1,743 +0,0 @@
// @vitest-environment jsdom
import { StrictMode, useState, type ComponentProps } from "react";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
ConfirmedPatch,
ConfirmedWriteResult,
LegacyOnboardingStatePatch,
OnboardingState,
OnboardingStateResponse,
QuestionnaireStateV1,
} from "@/store/onboarding/types";
import { OnboardingContext } from "@/store/onboarding/useOnboardingState";
import { GetStartedModal } from "./GetStartedModal";
const mocks = vi.hoisted(() => ({
userId: "user-a",
orgId: null as string | null,
realDetails: false,
confirmed: vi.fn<(patch: ConfirmedPatch) => Promise<ConfirmedWriteResult>>(),
createWorkflow: vi.fn(),
legacyUpdate: vi.fn<(patch: LegacyOnboardingStatePatch) => void>(),
telemetry: {
registerVariant: vi.fn(),
flowStarted: vi.fn(),
modalOpened: vi.fn(),
modalSkipped: vi.fn(),
questionnaireShown: vi.fn<(input: unknown) => boolean>(() => true),
questionnaireCompleted: vi.fn(),
questionnaireSkipped: vi.fn(),
questionnaireUpdated: vi.fn(),
},
}));
vi.mock("posthog-js/react", () => ({
useFeatureFlagVariantKey: () => "template-first",
}));
vi.mock("@/hooks/useUser", () => ({
useUser: () => ({
get: () => ({
id: mocks.userId,
email: "",
name: "",
createdAt: new Date("2026-08-28T00:00:00Z"),
}),
}),
}));
vi.mock("@/store/ActiveOrgContext", () => ({
useActiveOrgId: () => mocks.orgId ?? undefined,
}));
vi.mock("@/routes/workflows/hooks/useGlobalWorkflowsQuery", () => ({
useGlobalWorkflowsQuery: () => ({ data: [], isLoading: false }),
}));
vi.mock("@/routes/workflows/hooks/useCreateWorkflowMutation", () => ({
useCreateWorkflowMutation: () => ({
mutate: mocks.createWorkflow,
isPending: false,
}),
}));
vi.mock("@/util/onboarding/OnboardingTelemetry", () => ({
OnboardingTelemetry: mocks.telemetry,
}));
vi.mock("./CopilotCTAStep", () => ({
CopilotCTAStep: () => null,
}));
vi.mock("./QuestionnaireDetailsStep", async () => {
const actual = await vi.importActual<
typeof import("./QuestionnaireDetailsStep")
>("./QuestionnaireDetailsStep");
return {
QuestionnaireDetailsStep: (
props: ComponentProps<typeof actual.QuestionnaireDetailsStep>,
) => {
if (mocks.realDetails)
return <actual.QuestionnaireDetailsStep {...props} />;
const {
completionAction,
expectedRevision,
externalError,
isPending,
onAction,
onBack,
} = props;
const answerPatch = {
version: 1 as const,
mutation_id: `mutation-${completionAction}-${expectedRevision}`,
expected_revision: expectedRevision,
action: completionAction,
role: "developer" as const,
company_context: "startup" as const,
scale_intent: "exploring" as const,
referral_source: "search" as const,
};
return (
<div>
<span>{`details-${completionAction}-${expectedRevision}`}</span>
{externalError ? <div role="alert">{externalError}</div> : null}
<button
type="button"
disabled={isPending}
onClick={() => void onAction(answerPatch)}
>
details-submit
</button>
<button type="button" onClick={onBack}>
details-back
</button>
</div>
);
},
};
});
const baseState: OnboardingState = {
tour_completed_at: null,
modal_dismissed_at: null,
first_save_at: null,
first_run_at: null,
ab_variant: "template-first",
user_intent: null,
questionnaire: null,
questionnaire_prompted_at: null,
seen_canvas: null,
seen_node_adder: null,
seen_sidebar: null,
seen_save_run: null,
};
function questionnaire(
overrides: Partial<QuestionnaireStateV1> = {},
): QuestionnaireStateV1 {
return {
version: 1,
response_id: "response",
revision: 1,
last_mutation_id: "mutation-complete-0",
status: "completed",
role: "developer",
company_context: "startup",
scale_intent: "exploring",
referral_source: "search",
completed_at: "2026-01-01T00:00:00Z",
skipped_at: null,
deferred_at: null,
updated_at: "2026-01-01T00:00:00Z",
defer_prompt_count: 0,
...overrides,
};
}
function response(
overrides: Partial<OnboardingState> = {},
): OnboardingStateResponse {
return {
onboarding_state: { ...baseState, ...overrides },
project_owner_supported: true,
launch_date_at_signup: "2026-01-01T00:00:00Z",
recovery_guidance_assignment: null,
};
}
const promptedAt = "2026-08-27T00:00:00Z";
const reservedResponse = (overrides: Partial<OnboardingState> = {}) => ({
...response({ ...overrides, questionnaire_prompted_at: promptedAt }),
questionnaire_prompt_result: {
status: "reserved" as const,
prompted_at: promptedAt,
},
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
function TestModal({
initialState = baseState,
editorState,
}: {
initialState?: OnboardingState;
editorState?: Partial<
Pick<
OnboardingState,
| "modal_dismissed_at"
| "first_save_at"
| "questionnaire_prompted_at"
| "questionnaire"
>
>;
}) {
const [state, setState] = useState(initialState);
function updateState(patch: LegacyOnboardingStatePatch) {
mocks.legacyUpdate(patch);
setState((current) => ({
...current,
...patch,
questionnaire: current.questionnaire,
}));
}
async function updateStateConfirmed(patch: ConfirmedPatch) {
const next = await mocks.confirmed(patch);
if ("onboarding_state" in next) {
setState(next.onboarding_state);
}
return next;
}
return (
<MemoryRouter>
<OnboardingContext.Provider
value={{
state: editorState ? { ...state, ...editorState } : state,
isLoading: false,
updateState,
updateStateConfirmed,
isNewUser: true,
abVariant: "template-first",
recoveryGuidanceAssignment: null,
}}
>
<GetStartedModal />
</OnboardingContext.Provider>
</MemoryRouter>
);
}
beforeEach(() => {
mocks.confirmed.mockResolvedValue(response({ user_intent: "fill_forms" }));
mocks.telemetry.questionnaireShown.mockReturnValue(true);
mocks.userId = "user-a";
mocks.orgId = null;
mocks.realDetails = false;
});
afterEach(() => {
cleanup();
mocks.confirmed.mockReset();
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe("GetStartedModal", () => {
it.each([undefined, false])(
"keeps owner capture hidden without backend support: %s",
async (supported) => {
mocks.realDetails = true;
mocks.confirmed.mockImplementation(async (patch) =>
patch.questionnaire_prompt
? reservedResponse()
: {
...response({ user_intent: "fill_forms" }),
organization_id: "o_test",
project_owner_supported: supported,
},
);
render(<TestModal />);
fireEvent.click(
await screen.findByRole("button", { name: /Fill out forms/ }),
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await screen.findByRole("button", { name: "Complete and continue" });
expect(
screen.queryByRole("button", {
name: /Who else owns this automation project/,
}),
).toBeNull();
expect(screen.getAllByRole("combobox")).toHaveLength(4);
},
);
it.each(["Complete and continue", "Skip", "Close"])(
"only saves the draft on explicit completion: %s",
async (action) => {
mocks.realDetails = true;
mocks.confirmed.mockImplementation(async (patch) => {
if (patch.questionnaire_prompt)
return { ...reservedResponse(), organization_id: "o_test" };
if (!patch.questionnaire)
return {
...response({ user_intent: "fill_forms" }),
organization_id: "o_test",
};
const q = patch.questionnaire;
return {
...response({
user_intent: "fill_forms",
questionnaire: questionnaire({
last_mutation_id: q.mutation_id,
status: q.action === "skip" ? "skipped" : "completed",
}),
}),
organization_id: "o_test",
};
});
render(<TestModal />);
fireEvent.click(
await screen.findByRole("button", { name: /Fill out forms/ }),
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
fireEvent.click(
await screen.findByRole("button", {
name: /Who else owns this automation project/,
}),
);
fireEvent.change(screen.getByLabelText("Work email"), {
target: { value: "owner@example.com" },
});
screen.getAllByRole("combobox").forEach((control) => {
fireEvent.click(control);
fireEvent.click(screen.getAllByRole("option")[0]!);
});
fireEvent.click(screen.getByRole("button", { name: action }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
const write = mocks.confirmed.mock.calls.find(
([patch]) => patch.questionnaire,
)?.[0].questionnaire;
if (action === "Complete and continue") {
expect(write).toHaveProperty("project_owner", {
action: "set",
expected_organization_id: "o_test",
professional_email: "owner@example.com",
});
} else {
expect(write).toEqual({
version: 1,
action: "skip",
mutation_id: expect.any(String),
expected_revision: 0,
});
}
},
);
it("drops the real optional draft on an organization switch", async () => {
mocks.realDetails = true;
mocks.orgId = "org_a";
mocks.confirmed
.mockResolvedValueOnce({ ...reservedResponse(), organization_id: "o_a" })
.mockResolvedValueOnce({
...response({ user_intent: "fill_forms" }),
organization_id: "o_a",
});
const view = render(<TestModal />);
fireEvent.click(
await screen.findByRole("button", { name: /Fill out forms/ }),
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
fireEvent.click(
await screen.findByRole("button", {
name: /Who else owns this automation project/,
}),
);
fireEvent.change(screen.getByLabelText("Name"), {
target: { value: "Synthetic Owner" },
});
mocks.confirmed
.mockResolvedValueOnce({ ...reservedResponse(), organization_id: "o_b" })
.mockResolvedValueOnce({
...response({ user_intent: "fill_forms" }),
organization_id: "o_b",
});
mocks.orgId = "org_b";
view.rerender(<TestModal />);
fireEvent.click(
await screen.findByRole("button", { name: /Fill out forms/ }),
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
fireEvent.click(
await screen.findByRole("button", {
name: /Who else owns this automation project/,
}),
);
expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
expect(
mocks.confirmed.mock.calls.some(([patch]) => patch.questionnaire),
).toBe(false);
});
it("reserves once, completes, stamps dismissal, and closes", async () => {
const reservation = deferred<ConfirmedWriteResult>();
const completion = deferred<OnboardingStateResponse>();
mocks.confirmed
.mockReturnValueOnce(reservation.promise)
.mockResolvedValueOnce(
response({
user_intent: "fill_forms",
questionnaire_prompted_at: promptedAt,
}),
)
.mockReturnValueOnce(completion.promise);
const view = render(
<StrictMode>
<TestModal />
</StrictMode>,
);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
view.rerender(
<StrictMode>
<TestModal />
</StrictMode>,
);
expect(mocks.confirmed).toHaveBeenCalledWith({
questionnaire_prompt: { version: 1, action: "reserve" },
});
await act(async () => reservation.resolve(reservedResponse()));
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledWith(
expect.objectContaining({ primaryIntent: null, promptReason: "initial" }),
);
expect(screen.getByText("STEP 1 OF 2")).toBeTruthy();
fireEvent.click(
await screen.findByRole("button", { name: /Fill out forms/ }),
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
fireEvent.click(await screen.findByText("details-submit"));
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledTimes(3));
expect(mocks.telemetry.questionnaireCompleted).not.toHaveBeenCalled();
fireEvent.keyDown(document, { key: "Escape" });
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(mocks.confirmed).toHaveBeenCalledTimes(3);
expect(screen.getByRole("dialog")).toBeTruthy();
await act(async () =>
completion.resolve(
response({
user_intent: "fill_forms",
questionnaire_prompted_at: promptedAt,
questionnaire: questionnaire(),
}),
),
);
expect(mocks.telemetry.questionnaireCompleted).toHaveBeenCalledOnce();
expect(mocks.confirmed).toHaveBeenCalledTimes(3);
expect(mocks.legacyUpdate).toHaveBeenCalledWith({
modal_dismissed_at: expect.any(String),
});
expect(mocks.telemetry.modalSkipped).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("closes when Intent Continue first sees a foreign response", async () => {
mocks.confirmed
.mockResolvedValueOnce(reservedResponse())
.mockResolvedValueOnce(
response({
user_intent: "fill_forms",
questionnaire_prompted_at: promptedAt,
questionnaire: questionnaire({ response_id: "foreign-response" }),
}),
);
render(<TestModal />);
fireEvent.click(
await screen.findByRole("button", { name: /Fill out forms/ }),
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(mocks.confirmed).toHaveBeenCalledTimes(2);
});
it("reserves and opens the questionnaire for an initially dismissed editor", async () => {
const dismissedAt = "2026-01-01T00:00:00Z";
mocks.confirmed.mockResolvedValueOnce(
reservedResponse({ modal_dismissed_at: dismissedAt }),
);
render(
<TestModal
initialState={{ ...baseState, modal_dismissed_at: dismissedAt }}
/>,
);
expect(await screen.findByRole("dialog")).toBeTruthy();
expect(mocks.confirmed).toHaveBeenCalledOnce();
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
});
it("opens the reserved questionnaire when sessionStorage.getItem throws", async () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("storage unavailable");
});
mocks.confirmed.mockResolvedValueOnce(reservedResponse());
render(<TestModal />);
expect(await screen.findByRole("dialog")).toBeTruthy();
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
});
it("opens the reserved questionnaire when sessionStorage.setItem throws", async () => {
vi.spyOn(Storage.prototype, "getItem").mockReturnValue(null);
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("storage unavailable");
});
mocks.confirmed.mockResolvedValueOnce(reservedResponse());
render(<TestModal />);
expect(await screen.findByRole("dialog")).toBeTruthy();
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
});
it("never reopens a legacy deferred response", async () => {
const deferredState = questionnaire({
status: "deferred",
completed_at: null,
deferred_at: "2026-01-01T00:00:00Z",
defer_prompt_count: 1,
});
const props = {
initialState: {
...baseState,
user_intent: "fill_forms",
questionnaire: deferredState,
},
};
render(<TestModal {...props} />);
await act(async () => undefined);
expect(screen.queryByRole("dialog")).toBeNull();
expect(mocks.confirmed).not.toHaveBeenCalled();
});
it("keeps initial details open and announces a failed dialog close", async () => {
const closeWrite = deferred<ConfirmedWriteResult>();
mocks.confirmed
.mockResolvedValueOnce(reservedResponse())
.mockResolvedValueOnce(
response({
user_intent: "fill_forms",
questionnaire_prompted_at: promptedAt,
}),
)
.mockReturnValueOnce(closeWrite.promise);
render(<TestModal />);
fireEvent.click(
await screen.findByRole("button", { name: /Fill out forms/ }),
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
expect(await screen.findByText("details-complete-0")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledTimes(3));
expect(screen.getByText("details-complete-0")).toBeTruthy();
await act(async () => closeWrite.reject(new Error("offline")));
expect((await screen.findByRole("alert")).textContent).toBe(
"We couldn't save your choice. Try again.",
);
expect(screen.getByText("details-complete-0")).toBeTruthy();
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("persists initial Skip before telemetry and closing", async () => {
const write = deferred<OnboardingStateResponse>();
mocks.confirmed
.mockResolvedValueOnce(reservedResponse())
.mockReturnValueOnce(write.promise);
render(<TestModal />);
fireEvent.click(await screen.findByRole("button", { name: "Skip" }));
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledTimes(2));
const skipPatch =
mocks.confirmed.mock.calls[mocks.confirmed.mock.calls.length - 1]?.[0]
.questionnaire;
expect(skipPatch).toEqual(
expect.objectContaining({ expected_revision: 0, action: "skip" }),
);
if (!skipPatch) throw new Error("missing Skip patch");
expect(screen.getByRole("dialog")).toBeTruthy();
expect(mocks.telemetry.questionnaireSkipped).not.toHaveBeenCalled();
await act(async () =>
write.resolve(
response({
questionnaire_prompted_at: promptedAt,
questionnaire: questionnaire({
status: "skipped",
completed_at: null,
skipped_at: promptedAt,
last_mutation_id: skipPatch.mutation_id,
}),
}),
),
);
expect(mocks.telemetry.questionnaireSkipped).toHaveBeenCalledWith(
expect.objectContaining({ responseId: "response", revision: 1 }),
);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("ignores a reservation result after unmount", async () => {
const write = deferred<ConfirmedWriteResult>();
mocks.confirmed.mockReturnValueOnce(write.promise);
const view = render(<TestModal />);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
expect(screen.getByRole("dialog")).toBeTruthy();
view.unmount();
await act(async () => write.resolve(reservedResponse()));
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
});
it("resets reservation ownership when the Clerk user changes", async () => {
const firstUserReservation = deferred<ConfirmedWriteResult>();
mocks.confirmed
.mockReturnValueOnce(firstUserReservation.promise)
.mockResolvedValueOnce(reservedResponse());
const view = render(<TestModal />);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
mocks.userId = "user-b";
view.rerender(<TestModal />);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledTimes(2));
expect(
await screen.findByText("What do you want to automate?"),
).toBeTruthy();
await act(async () => firstUserReservation.resolve(reservedResponse()));
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
});
it("closes an ineligible fallback after another tab reserves", async () => {
const write = deferred<ConfirmedWriteResult>();
mocks.confirmed.mockReturnValueOnce(write.promise);
const view = render(<TestModal />);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
view.rerender(
<TestModal editorState={{ questionnaire_prompted_at: promptedAt }} />,
);
await act(async () =>
write.resolve({
...response(),
questionnaire_prompt_result: { status: "ineligible" },
}),
);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByText("Pick a template to start")).toBeNull();
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
});
it.each([
["after a refresh", { questionnaire_prompted_at: promptedAt }],
["when another tab reserved first", {}],
[
"after a legacy modal dismissal",
{
questionnaire_prompted_at: promptedAt,
modal_dismissed_at: "2026-08-26T00:00:00Z",
},
],
])("resumes an unanswered questionnaire %s", async (_, initial) => {
mocks.confirmed.mockResolvedValueOnce({
...response({ ...initial, questionnaire_prompted_at: promptedAt }),
questionnaire_prompt_result: { status: "already_prompted" },
});
render(<TestModal initialState={{ ...baseState, ...initial }} />);
expect(
await screen.findByText("What do you want to automate?"),
).toBeTruthy();
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledWith(
expect.objectContaining({ promptReason: "resume" }),
);
});
it("stays closed when another tab answered before the resume reserve", async () => {
mocks.confirmed.mockResolvedValueOnce({
...response({
questionnaire_prompted_at: promptedAt,
questionnaire: questionnaire(),
}),
questionnaire_prompt_result: { status: "already_prompted" },
});
render(
<TestModal
initialState={{ ...baseState, questionnaire_prompted_at: promptedAt }}
/>,
);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
});
it("closes a resumed questionnaire when Skip finds it answered in another tab", async () => {
mocks.confirmed
.mockResolvedValueOnce({
...response({ questionnaire_prompted_at: promptedAt }),
questionnaire_prompt_result: { status: "already_prompted" },
})
.mockResolvedValueOnce({ code: "questionnaire_revision_conflict" });
render(
<TestModal
initialState={{ ...baseState, questionnaire_prompted_at: promptedAt }}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: "Skip" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(
screen.queryByText("We couldn't save your choice. Try again."),
).toBeNull();
});
it("keeps a skipped questionnaire closed", async () => {
render(
<TestModal
initialState={{
...baseState,
questionnaire_prompted_at: promptedAt,
questionnaire: questionnaire({
status: "skipped",
completed_at: null,
skipped_at: promptedAt,
}),
}}
/>,
);
await act(async () => undefined);
expect(screen.queryByRole("dialog")).toBeNull();
expect(mocks.confirmed).not.toHaveBeenCalled();
});
it.each(["modal_dismissed_at", "first_save_at"] as const)(
"ignores a stale reservation when %s changes while pending",
async (field) => {
const write = deferred<ConfirmedWriteResult>();
mocks.confirmed.mockReturnValueOnce(write.promise);
const view = render(<TestModal />);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
view.rerender(
<TestModal editorState={{ [field]: "2026-01-01T00:00:00Z" }} />,
);
await act(async () => write.resolve(reservedResponse()));
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
expect(screen.queryByRole("dialog")).toBeNull();
},
);
});

View file

@ -1,366 +0,0 @@
// @vitest-environment jsdom
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
ConfirmedWriteResult,
OnboardingState,
QuestionnaireStateV1,
} from "@/store/onboarding/types";
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
const {
mockUpdateState,
mockUpdateStateConfirmed,
mockMutate,
mockTelemetry,
mutationState,
flagState,
stateOverrides,
clerkUser,
} = vi.hoisted(() => ({
mockUpdateState: vi.fn(),
mockUpdateStateConfirmed: vi.fn(),
mockMutate: vi.fn(),
mockTelemetry: {
registerVariant: vi.fn(),
flowStarted: vi.fn(),
modalOpened: vi.fn(),
abVariantAssigned: vi.fn(),
modalSkipped: vi.fn(),
modalTemplateSelected: vi.fn(),
questionnaireShown: vi.fn(() => true),
},
mutationState: { isPending: false },
flagState: {
variant: "template-first" as string | boolean | undefined,
},
stateOverrides: { current: {} as Partial<OnboardingState> },
clerkUser: {
current: { createdAt: new Date("2026-08-28T00:00:00Z") } as {
createdAt: Date | null;
} | null,
},
}));
const baseState: OnboardingState = {
tour_completed_at: null,
modal_dismissed_at: null,
first_save_at: null,
first_run_at: null,
ab_variant: "template-first",
user_intent: null,
questionnaire_prompted_at: null,
seen_canvas: null,
seen_node_adder: null,
seen_sidebar: null,
seen_save_run: null,
};
const completedQuestionnaire = {
version: 1,
response_id: "response",
revision: 1,
last_mutation_id: "mutation",
status: "completed",
role: "developer",
company_context: "startup",
scale_intent: "exploring",
referral_source: "search",
completed_at: "2026-01-01T00:00:00Z",
skipped_at: null,
deferred_at: null,
updated_at: "2026-01-01T00:00:00Z",
defer_prompt_count: 0,
} satisfies QuestionnaireStateV1;
vi.mock("posthog-js/react", () => ({
useFeatureFlagVariantKey: () => flagState.variant,
}));
vi.mock("@/hooks/useUser", () => ({
useUser: () => ({
get: () =>
clerkUser.current && {
id: "user-a",
email: "",
name: "",
createdAt: clerkUser.current.createdAt ?? undefined,
},
}),
}));
vi.mock("@/hooks/useFeatureFlag", () => ({
useFeatureFlag: vi.fn(),
}));
vi.mock("@/store/onboarding/useOnboardingState", () => ({
useOnboardingState: () => ({
state: {
...baseState,
...stateOverrides.current,
ab_variant: flagState.variant,
},
isNewUser: true,
updateState: mockUpdateState,
updateStateConfirmed: mockUpdateStateConfirmed,
}),
}));
vi.mock("./CopilotCTAStep", async () => {
const { useEffect } = await import("react");
return {
CopilotCTAStep: (props: {
onSkip: () => void;
onBusyChange?: (busy: boolean) => void;
}) => {
useEffect(() => {
props.onBusyChange?.(true);
}, [props]);
return (
<>
<div>copilot-header</div>
<div>copilot-body</div>
<div>
<button type="button" onClick={props.onSkip}>
child-skip
</button>
</div>
</>
);
},
};
});
vi.mock("@/routes/workflows/hooks/useGlobalWorkflowsQuery", () => ({
useGlobalWorkflowsQuery: () => ({ data: [], isLoading: false }),
}));
vi.mock("@/routes/workflows/hooks/useCreateWorkflowMutation", () => ({
useCreateWorkflowMutation: () => ({
mutate: mockMutate,
isPending: mutationState.isPending,
}),
}));
vi.mock("@/util/onboarding/OnboardingTelemetry", () => ({
OnboardingTelemetry: mockTelemetry,
}));
import {
DECIDING_PLACEHOLDER_DELAY_MS,
GetStartedModal,
} from "./GetStartedModal";
beforeEach(() => {
mockUpdateStateConfirmed.mockResolvedValue({
onboarding_state: { ...baseState, ...stateOverrides.current },
launch_date_at_signup: "2026-08-27T00:00:00Z",
recovery_guidance_assignment: null,
questionnaire_prompt_result: { status: "flag_disabled" },
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mutationState.isPending = false;
flagState.variant = "template-first";
stateOverrides.current = {};
clerkUser.current = { createdAt: new Date("2026-08-28T00:00:00Z") };
});
async function renderTemplatesStep() {
render(<GetStartedModal />, { wrapper: MemoryRouter });
fireEvent.click(
await screen.findByRole("button", { name: /Fill out forms/ }),
);
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
}
describe("GetStartedModal progress", () => {
it("uses server-owned reservation state instead of a frontend questionnaire flag", async () => {
render(<GetStartedModal />, { wrapper: MemoryRouter });
expect(await screen.findByText("STEP 1 OF 2")).toBeTruthy();
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
expect(screen.queryByText("STEP 1 OF 3")).toBeNull();
expect(vi.mocked(useFeatureFlag)).not.toHaveBeenCalled();
});
it.each([
[undefined, "ineligible"],
[false, "flag_disabled"],
] as const)(
"renders no editor content when the editor flag is %s",
async (variant, status) => {
flagState.variant = variant;
mockUpdateStateConfirmed.mockResolvedValueOnce({
onboarding_state: baseState,
launch_date_at_signup: "2026-08-27T00:00:00Z",
recovery_guidance_assignment: null,
questionnaire_prompt_result: { status },
});
render(<GetStartedModal />, { wrapper: MemoryRouter });
await waitFor(() =>
expect(mockUpdateStateConfirmed).toHaveBeenCalledOnce(),
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.queryByText("What do you want to automate?")).toBeNull();
expect(screen.queryByText("Pick a template to start")).toBeNull();
expect(screen.queryByText("copilot-header")).toBeNull();
},
);
it("keeps editor-only final steps and existing responses closed", async () => {
stateOverrides.current = {
user_intent: "fill_forms",
questionnaire: null,
};
const { unmount } = render(<GetStartedModal />, {
wrapper: MemoryRouter,
});
expect(await screen.findByText("STEP 2 OF 2")).toBeTruthy();
expect(screen.getByText("FINAL STEP")).toBeTruthy();
expect(screen.queryByText("OPTIONAL")).toBeNull();
unmount();
mockUpdateStateConfirmed.mockClear();
stateOverrides.current = {
user_intent: "fill_forms",
questionnaire: completedQuestionnaire,
};
render(<GetStartedModal />, { wrapper: MemoryRouter });
expect(screen.queryByRole("dialog")).toBeNull();
expect(mockUpdateStateConfirmed).not.toHaveBeenCalled();
});
it("marks the editor second step as final", async () => {
await renderTemplatesStep();
expect(screen.getByText("STEP 2 OF 2")).toBeTruthy();
expect(screen.getByText("FINAL STEP")).toBeTruthy();
});
});
describe("GetStartedModal pre-cutoff users", () => {
it("shows nothing and never calls the reserve endpoint for a pre-cutoff user with a prior save", async () => {
clerkUser.current = { createdAt: new Date("2026-08-01T00:00:00Z") };
stateOverrides.current = { first_save_at: "2026-08-20T00:00:00Z" };
render(<GetStartedModal />, { wrapper: MemoryRouter });
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(mockUpdateStateConfirmed).not.toHaveBeenCalled();
});
it("routes a pre-cutoff new user straight to the editor without a reserve call", async () => {
clerkUser.current = { createdAt: new Date("2026-08-01T00:00:00Z") };
render(<GetStartedModal />, { wrapper: MemoryRouter });
expect(
await screen.findByText("What do you want to automate?"),
).toBeTruthy();
expect(mockUpdateStateConfirmed).not.toHaveBeenCalled();
});
});
describe("GetStartedModal deciding placeholder", () => {
it("closes without ever showing the placeholder when there is nothing to show", async () => {
flagState.variant = undefined;
let resolveReservation!: (value: ConfirmedWriteResult) => void;
mockUpdateStateConfirmed.mockReturnValueOnce(
new Promise<ConfirmedWriteResult>((resolve) => {
resolveReservation = resolve;
}),
);
render(<GetStartedModal />, { wrapper: MemoryRouter });
const dialog = await screen.findByRole("dialog");
expect(screen.getByText("Checking your onboarding setup.")).toBeTruthy();
expect(dialog.className).toContain("opacity-0");
expect((dialog.previousElementSibling as HTMLElement).className).toContain(
"opacity-0",
);
await act(async () =>
resolveReservation({
onboarding_state: baseState,
launch_date_at_signup: "2026-08-27T00:00:00Z",
recovery_guidance_assignment: null,
questionnaire_prompt_result: { status: "ineligible" },
}),
);
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("reveals the placeholder only after the grace delay while still deciding", async () => {
vi.useFakeTimers();
try {
mockUpdateStateConfirmed.mockReturnValueOnce(
new Promise<ConfirmedWriteResult>(() => {}),
);
render(<GetStartedModal />, { wrapper: MemoryRouter });
const dialog = screen.getByRole("dialog");
expect(dialog.className).toContain("opacity-0");
act(() => {
vi.advanceTimersByTime(DECIDING_PLACEHOLDER_DELAY_MS);
});
expect(dialog.className).not.toContain("opacity-0");
expect(
(dialog.previousElementSibling as HTMLElement).className,
).not.toContain("opacity-0");
expect(screen.getByText("Checking your onboarding setup.")).toBeTruthy();
} finally {
vi.useRealTimers();
}
});
});
describe("GetStartedModal skip-while-creating guard", () => {
it("blocks Skip while a template workflow creation is in flight", async () => {
mutationState.isPending = true;
await renderTemplatesStep();
const skip = screen.getByRole("button", { name: "Skip" });
expect((skip as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(skip);
expect(mockTelemetry.modalSkipped).not.toHaveBeenCalled();
expect(mockUpdateState).not.toHaveBeenCalledWith(
expect.objectContaining({ modal_dismissed_at: expect.any(String) }),
);
});
it("dismisses on Skip when no creation is pending", async () => {
mutationState.isPending = false;
await renderTemplatesStep();
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
expect(mockTelemetry.modalSkipped).toHaveBeenCalledTimes(1);
expect(mockUpdateState).toHaveBeenCalledWith(
expect.objectContaining({ modal_dismissed_at: expect.any(String) }),
);
});
it("ignores skip and Escape-close while the copilot step reports busy", async () => {
flagState.variant = "copilot-first";
await renderTemplatesStep();
const chrome = screen.getByTestId("copilot-chrome");
expect(chrome.children).toHaveLength(3);
expect(screen.getByText("STEP 2 OF 2")).toBeTruthy();
expect(screen.getByText("FINAL STEP")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "child-skip" }));
fireEvent.keyDown(document, { key: "Escape" });
expect(mockTelemetry.modalSkipped).not.toHaveBeenCalled();
expect(mockUpdateState).not.toHaveBeenCalledWith(
expect.objectContaining({ modal_dismissed_at: expect.any(String) }),
);
});
});

View file

@ -1,815 +0,0 @@
import { ReloadIcon } from "@radix-ui/react-icons";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useFeatureFlagVariantKey } from "posthog-js/react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useOnboardingState } from "@/store/onboarding/useOnboardingState";
import { useUser } from "@/hooks/useUser";
import { useActiveOrgId } from "@/store/ActiveOrgContext";
import {
isQuestionnaireUserIntentV1,
type QuestionnaireAnswersV1,
type QuestionnairePatchV1,
type QuestionnaireStateV1,
} from "@/store/onboarding/types";
import { OnboardingTelemetry } from "@/util/onboarding/OnboardingTelemetry";
import { ClickIcon } from "@/components/icons/ClickIcon";
import { ExtractIcon } from "@/components/icons/ExtractIcon";
import { SearchIcon } from "@/components/icons/SearchIcon";
import { CompassIcon } from "@/components/icons/CompassIcon";
import { cn } from "@/util/utils";
import { useGlobalWorkflowsQuery } from "@/routes/workflows/hooks/useGlobalWorkflowsQuery";
import { useCreateWorkflowMutation } from "@/routes/workflows/hooks/useCreateWorkflowMutation";
import { convert } from "@/routes/workflows/editor/workflowEditorUtils";
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import {
getTemplatesForIntent,
getTemplateIcon,
getSetupTime,
} from "./templateUtils";
import { CopilotCTAStep } from "./CopilotCTAStep";
import { QuestionnaireDetailsStep } from "./QuestionnaireDetailsStep";
import { OnboardingOptionRow } from "./OnboardingOptionRow";
import { OnboardingStepProgress } from "./OnboardingStepProgress";
import { OnboardingStepShell } from "./OnboardingStepShell";
import {
type ABVariant,
DEFAULT_VARIANT,
VARIANTS,
isABVariant,
EXPERIMENT,
} from "@/util/onboarding/experimentConfig";
const SURFACE = "discover" as const;
export const DECIDING_PLACEHOLDER_DELAY_MS = 500;
// Mirrors the backend questionnaire signup cutoff: reservation can only answer
// ineligible for accounts created before it, so those users skip the reserve
// call entirely. The server stays authoritative for everyone else.
export const QUESTIONNAIRE_SIGNUP_CUTOFF_MS = Date.UTC(2026, 7, 27);
type EditorStep = "intent" | "templates";
type DiscoverOnboardingOwner =
| { kind: "deciding" }
| { kind: "questionnaire"; step: "intent" }
| { kind: "questionnaire"; step: "details" }
| { kind: "editor" }
| { kind: "closed" };
type IntentOption = {
id: string;
label: string;
description: string;
icon: React.FC<{ className?: string }>;
};
const intentOptions: IntentOption[] = [
{
id: "fill_forms",
label: "Fill out forms",
description: "Automate form submissions across websites",
icon: ClickIcon,
},
{
id: "extract_data",
label: "Extract data from websites",
description: "Scrape and collect data at scale",
icon: ExtractIcon,
},
{
id: "monitor_website",
label: "Monitor a website for changes",
description: "Track updates and get notified",
icon: SearchIcon,
},
{
id: "something_else",
label: "Something else",
description: "Other browser automation tasks",
icon: CompassIcon,
},
];
function resolveVariant(
stateVariant: string | null | undefined,
flagVariant: string | boolean | undefined,
): ABVariant {
if (isABVariant(stateVariant)) {
return stateVariant;
}
return flagVariant === VARIANTS.COPILOT_FIRST
? VARIANTS.COPILOT_FIRST
: DEFAULT_VARIANT;
}
function answersFromQuestionnaire(
questionnaire: QuestionnaireStateV1 | null,
): QuestionnaireAnswersV1 | null {
if (!questionnaire) return null;
const {
role,
company_context,
scale_intent,
referral_source,
referral_source_detail,
} = questionnaire;
return role && company_context && scale_intent && referral_source
? {
role,
company_context,
scale_intent,
referral_source,
referral_source_detail,
}
: null;
}
function GetStartedModalForUser() {
const { state, isLoading, isNewUser, updateState, updateStateConfirmed } =
useOnboardingState();
const user = useUser().get();
const flagVariant = useFeatureFlagVariantKey(EXPERIMENT.flagKey);
const [owner, setOwner] = useState<DiscoverOnboardingOwner>({
kind: "deciding",
});
const questionnaireOwner = owner.kind === "questionnaire";
const [step, setStep] = useState<EditorStep>("intent");
const [selectedIntent, setSelectedIntent] = useState<string | null>(null);
const [questionnaire, setQuestionnaire] =
useState<QuestionnaireStateV1 | null>(null);
const [questionnaireOrganizationId, setQuestionnaireOrganizationId] =
useState<string | null>(null);
const [intentPending, setIntentPending] = useState(false);
const [decidingPlaceholderVisible, setDecidingPlaceholderVisible] =
useState(false);
const [questionnairePending, setQuestionnairePending] = useState(false);
const [intentError, setIntentError] = useState<string | null>(null);
const [copilotStepBusy, setCopilotStepBusy] = useState(false);
const openedRef = useRef(false);
const variantAssignedRef = useRef(false);
const confirmedEventsRef = useRef(new Set<string>());
const openVisitRef = useRef(0);
const isOpenRef = useRef(false);
const reservationStartedRef = useRef(false);
const reservationEditorStateRef = useRef<
readonly [string | null, string | null, string | null] | null
>(null);
const ownerGenerationRef = useRef(0);
const mountedRef = useRef(true);
const latestStateRef = useRef(state);
const questionnaireObservedDuringReservationRef = useRef(false);
latestStateRef.current = state;
const initialSkipMutationIdRef = useRef(crypto.randomUUID());
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const variant = resolveVariant(state?.ab_variant, flagVariant);
const { data: globalTemplates = [], isLoading: templatesLoading } =
useGlobalWorkflowsQuery();
const createWorkflowMutation = useCreateWorkflowMutation();
const onboardingEnabled = isABVariant(flagVariant);
const editorOpen =
owner.kind === "editor" &&
onboardingEnabled &&
state !== null &&
state.modal_dismissed_at === null &&
state.first_save_at === null &&
state.questionnaire_prompted_at == null &&
state.questionnaire == null &&
(isOpenRef.current || isNewUser);
const reservationCandidate =
owner.kind === "deciding" &&
!isLoading &&
state !== null &&
state.questionnaire == null;
const questionnaireLocallyIneligible =
user?.createdAt == null ||
user.createdAt.getTime() < QUESTIONNAIRE_SIGNUP_CUTOFF_MS;
const decidingOpen = reservationCandidate;
const ownedOpen = questionnaireOwner || editorOpen;
const isOpen = decidingOpen || ownedOpen;
// The deciding dialog must block interaction but stay invisible at first, so
// a reservation that resolves to "nothing to show" never flashes the
// placeholder; it is revealed only if the check outlasts the grace delay.
const decidingHidden =
owner.kind === "deciding" && !decidingPlaceholderVisible;
useEffect(() => {
if (!decidingOpen) return;
const timer = window.setTimeout(
() => setDecidingPlaceholderVisible(true),
DECIDING_PLACEHOLDER_DELAY_MS,
);
return () => window.clearTimeout(timer);
}, [decidingOpen]);
useLayoutEffect(() => {
if (ownedOpen && !isOpenRef.current) openVisitRef.current += 1;
isOpenRef.current = ownedOpen;
}, [ownedOpen]);
useLayoutEffect(() => {
if (!state) return;
const editorState = [
state.modal_dismissed_at,
state.first_save_at,
state.user_intent,
] as const;
if (
owner.kind === "deciding" &&
reservationStartedRef.current &&
(state.questionnaire_prompted_at || state.questionnaire)
) {
questionnaireObservedDuringReservationRef.current = true;
}
const reservationEditorState = reservationEditorStateRef.current;
if (
owner.kind === "deciding" &&
reservationStartedRef.current &&
reservationEditorState &&
(editorState[0] !== reservationEditorState[0] ||
editorState[1] !== reservationEditorState[1])
) {
ownerGenerationRef.current += 1;
setOwner({ kind: "closed" });
return;
}
if (owner.kind !== "deciding" || isLoading) {
return;
}
if (reservationStartedRef.current) {
return;
}
if (!reservationCandidate) {
setOwner({ kind: "closed" });
return;
}
if (questionnaireLocallyIneligible) {
if (
state.modal_dismissed_at !== null ||
state.first_save_at !== null ||
state.questionnaire_prompted_at != null
) {
setOwner({ kind: "closed" });
return;
}
if (state.user_intent) {
setSelectedIntent(state.user_intent);
setStep("templates");
}
setOwner({ kind: "editor" });
return;
}
reservationStartedRef.current = true;
reservationEditorStateRef.current = editorState;
const generation = ++ownerGenerationRef.current;
void updateStateConfirmed({
questionnaire_prompt: { version: 1, action: "reserve" },
})
.then((response) => {
if (!mountedRef.current || generation !== ownerGenerationRef.current)
return;
if ("code" in response) {
setOwner({ kind: "closed" });
return;
}
const result = response.questionnaire_prompt_result;
if (
result?.status === "reserved" &&
typeof result.prompted_at === "string" &&
result.prompted_at.length > 0
) {
OnboardingTelemetry.questionnaireShown({
primaryIntent: null,
promptReason: "initial",
organizationId: response.organization_id ?? null,
});
setOwner({ kind: "questionnaire", step: "intent" });
} else if (
result?.status === "flag_disabled" ||
result?.status === "ineligible"
) {
const latestState = latestStateRef.current;
if (
questionnaireObservedDuringReservationRef.current ||
!latestState ||
latestState.questionnaire_prompted_at ||
latestState.questionnaire ||
latestState.modal_dismissed_at !== null ||
latestState.first_save_at !== null
) {
setOwner({ kind: "closed" });
return;
}
const fallbackIntent =
latestState.user_intent ??
reservationEditorStateRef.current?.[2] ??
null;
if (fallbackIntent) {
setSelectedIntent(fallbackIntent);
setStep("templates");
}
setOwner({ kind: "editor" });
} else if (
result?.status === "already_prompted" &&
response.onboarding_state.questionnaire == null
) {
// A reservation that was never answered (refresh, closed tab, or another tab reserving first).
OnboardingTelemetry.questionnaireShown({
primaryIntent: null,
promptReason: "resume",
organizationId: response.organization_id ?? null,
});
setOwner({ kind: "questionnaire", step: "intent" });
} else {
setOwner({ kind: "closed" });
}
})
.catch(() => {
if (mountedRef.current && generation === ownerGenerationRef.current) {
setOwner({ kind: "closed" });
}
});
}, [
isLoading,
owner.kind,
questionnaireLocallyIneligible,
reservationCandidate,
state,
updateStateConfirmed,
]);
useEffect(() => {
if (isOpen && owner.kind !== "deciding" && !openedRef.current) {
openedRef.current = true;
if (owner.kind === "editor") {
OnboardingTelemetry.registerVariant(variant);
}
OnboardingTelemetry.flowStarted(SURFACE);
OnboardingTelemetry.modalOpened(SURFACE);
}
}, [isOpen, owner.kind, variant]);
useEffect(() => {
if (
owner.kind !== "editor" ||
!state ||
state.ab_variant !== null ||
variantAssignedRef.current
)
return;
// only persist a variant once the flag resolves to a real arm; an unloaded/disabled flag (pre-load, 0% rollout, rollback) must not bias the split
if (!isABVariant(flagVariant)) return;
variantAssignedRef.current = true;
updateState({ ab_variant: variant });
OnboardingTelemetry.abVariantAssigned(SURFACE, variant);
}, [owner.kind, state, variant, flagVariant, updateState]);
function handleSelectIntent(intentId: string) {
if (intentPending) return;
setSelectedIntent(intentId);
}
async function handleContinue() {
if (!selectedIntent || intentPending) {
return;
}
setIntentError(null);
if (owner.kind === "editor") {
updateState({ user_intent: selectedIntent });
setStep("templates");
return;
}
if (
owner.kind !== "questionnaire" ||
!isQuestionnaireUserIntentV1(selectedIntent)
) {
setIntentError("We couldn't save your choice. Try again.");
return;
}
const visitId = openVisitRef.current;
setIntentPending(true);
try {
const response = await updateStateConfirmed({
user_intent: selectedIntent,
});
if ("code" in response) {
throw new Error(`Intent confirmation failed: ${response.code}`);
}
if (
!mountedRef.current ||
!isOpenRef.current ||
visitId !== openVisitRef.current
) {
return;
}
const nextState = response.onboarding_state;
setQuestionnaireOrganizationId(
response.project_owner_supported === true
? (response.organization_id ?? null)
: null,
);
if (!isQuestionnaireUserIntentV1(nextState.user_intent)) {
setOwner({ kind: "closed" });
return;
}
const confirmedQuestionnaire = nextState.questionnaire ?? null;
if (confirmedQuestionnaire !== null) {
if (questionnaire?.response_id !== confirmedQuestionnaire.response_id) {
setOwner({ kind: "closed" });
return;
}
setQuestionnaire(confirmedQuestionnaire);
setOwner({ kind: "closed" });
return;
}
setOwner({ kind: "questionnaire", step: "details" });
} catch {
if (isOpenRef.current && visitId === openVisitRef.current) {
setIntentError("We couldn't save your choice. Try again.");
}
} finally {
setIntentPending(false);
}
}
function handleBack() {
if (owner.kind === "questionnaire") {
setOwner({ kind: "questionnaire", step: "intent" });
return;
}
setStep("intent");
}
function handleSkip() {
if (owner.kind === "deciding" || intentPending || questionnairePending)
return;
if (owner.kind === "questionnaire") {
if (questionnaire !== null) {
OnboardingTelemetry.modalSkipped(SURFACE);
updateState({ modal_dismissed_at: new Date().toISOString() });
setOwner({ kind: "closed" });
return;
}
setIntentError(null);
void handleQuestionnaireAction({
version: 1,
mutation_id: initialSkipMutationIdRef.current,
expected_revision: 0,
action: "skip",
}).catch(() =>
setIntentError("We couldn't save your choice. Try again."),
);
return;
}
if (createWorkflowMutation.isPending || copilotStepBusy) {
return;
}
OnboardingTelemetry.modalSkipped(SURFACE);
updateState({ modal_dismissed_at: new Date().toISOString() });
}
function emitQuestionnaireEvent(
patch: QuestionnairePatchV1,
previousQuestionnaire: QuestionnaireStateV1 | null,
nextQuestionnaire: QuestionnaireStateV1,
primaryIntent: string | null,
organizationId: string | null,
) {
if (nextQuestionnaire.last_mutation_id !== patch.mutation_id) return;
const eventName = `onboarding_questionnaire_${
patch.action === "skip"
? "skipped"
: patch.action === "complete"
? "completed"
: "updated"
}`;
const eventKey = `${organizationId}:${nextQuestionnaire.response_id}:${nextQuestionnaire.revision}:${eventName}`;
if (confirmedEventsRef.current.has(eventKey)) return;
if (patch.action === "skip") {
if (nextQuestionnaire.status !== "skipped") return;
confirmedEventsRef.current.add(eventKey);
OnboardingTelemetry.questionnaireSkipped({
responseId: nextQuestionnaire.response_id,
revision: nextQuestionnaire.revision,
disposition: "skip",
statusAfter: "skipped",
organizationId,
});
return;
}
if (!isQuestionnaireUserIntentV1(primaryIntent)) return;
const answers = answersFromQuestionnaire(nextQuestionnaire);
if (nextQuestionnaire.status !== "completed" || !answers) return;
if (patch.action === "complete") {
confirmedEventsRef.current.add(eventKey);
OnboardingTelemetry.questionnaireCompleted({
responseId: nextQuestionnaire.response_id,
revision: nextQuestionnaire.revision,
primaryIntent,
answers,
organizationId,
projectOwner: nextQuestionnaire.project_owner,
});
return;
}
if (!previousQuestionnaire) return;
confirmedEventsRef.current.add(eventKey);
OnboardingTelemetry.questionnaireUpdated({
responseId: nextQuestionnaire.response_id,
revision: nextQuestionnaire.revision,
primaryIntent,
previousStatus: previousQuestionnaire.status,
answers,
organizationId,
projectOwner: nextQuestionnaire.project_owner,
});
}
async function handleQuestionnaireAction(patch: QuestionnairePatchV1) {
if (!questionnaireOwner) {
throw new Error("Questionnaire visit is closed");
}
const visitId = openVisitRef.current;
const previousQuestionnaire = questionnaire;
setIntentError(null);
setQuestionnairePending(true);
try {
const response = await updateStateConfirmed({ questionnaire: patch });
if ("code" in response) {
// A skip at revision 0 conflicts only when another tab already answered or skipped.
if (
patch.action === "skip" &&
patch.expected_revision === 0 &&
response.code === "questionnaire_revision_conflict"
) {
setOwner({ kind: "closed" });
return;
}
if (response.code === "project_owner_invalid")
throw new Error(response.code);
throw new Error(`Questionnaire confirmation failed: ${response.code}`);
}
if (
!mountedRef.current ||
!isOpenRef.current ||
visitId !== openVisitRef.current
) {
return;
}
const nextQuestionnaire = response.onboarding_state.questionnaire;
if (!nextQuestionnaire) {
throw new Error("Questionnaire confirmation was missing");
}
emitQuestionnaireEvent(
patch,
previousQuestionnaire,
nextQuestionnaire,
response.onboarding_state.user_intent,
response.organization_id ?? null,
);
setQuestionnaire(nextQuestionnaire);
if (patch.action === "complete") {
updateState({ modal_dismissed_at: new Date().toISOString() });
}
setOwner({ kind: "closed" });
} finally {
setQuestionnairePending(false);
}
}
function handleTemplateSelect(template: WorkflowApiResponse) {
if (createWorkflowMutation.isPending) return;
OnboardingTelemetry.modalTemplateSelected(
SURFACE,
template.workflow_permanent_id,
selectedIntent!,
);
const cloned = convert(
{ ...template, title: `${template.title} (copy)` },
{ asNewWorkflow: true },
);
// Completion telemetry fires from useCreateWorkflowMutation (it owns the
// navigation that unmounts this modal); first_save_at prevents re-display,
// so no dismiss write is needed here.
createWorkflowMutation.mutate({ ...cloned, _via: "onboarding_template" });
}
const filteredTemplates =
selectedIntent && globalTemplates.length > 0
? getTemplatesForIntent(globalTemplates, selectedIntent)
: [];
const stepCount = 2;
const view =
owner.kind === "questionnaire"
? owner.step
: owner.kind === "editor" && editorOpen
? step
: null;
return (
<Dialog open={isOpen} onOpenChange={() => handleSkip()}>
<DialogContent
className={cn(
"z-[2147480003] grid max-h-[calc(100dvh-2rem)] max-w-xl grid-rows-[minmax(0,1fr)] overflow-hidden overscroll-contain [&>button]:right-2 [&>button]:top-2 [&>button]:inline-flex [&>button]:size-11 [&>button]:touch-manipulation [&>button]:items-center [&>button]:justify-center [&>button]:motion-reduce:transition-none",
decidingHidden && "opacity-0",
)}
overlayClassName={decidingHidden ? "opacity-0" : undefined}
onPointerDownOutside={(e) => e.preventDefault()}
>
{view === null ? (
owner.kind === "deciding" && decidingOpen ? (
<DialogHeader className="pr-12">
<DialogTitle className="text-xl font-semibold">
Getting started
</DialogTitle>
<DialogDescription>
Checking your onboarding setup.
</DialogDescription>
</DialogHeader>
) : null
) : view === "intent" ? (
<OnboardingStepShell stepIndex={1} stepCount={stepCount}>
<DialogHeader className="pr-12">
<DialogTitle className="text-xl font-semibold">
What do you want to automate?
</DialogTitle>
<DialogDescription>
Pick the option that best describes your goal. You can always
change this later.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto overscroll-contain pr-1">
{intentOptions.map((option) => (
<OnboardingOptionRow
key={option.id}
icon={option.icon}
label={option.label}
description={option.description}
selected={selectedIntent === option.id}
disabled={intentPending}
onClick={() => handleSelectIntent(option.id)}
/>
))}
</div>
{intentError ? (
<Alert role="status" aria-live="polite" variant="destructive">
<AlertDescription>{intentError}</AlertDescription>
</Alert>
) : null}
<DialogFooter className="sticky bottom-0 z-10 -mx-6 -mb-6 flex-col gap-2 border-t border-border bg-background px-6 pb-[max(1rem,env(safe-area-inset-bottom))] pt-4 sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
<Button
variant="ghost"
size="sm"
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
onClick={handleSkip}
disabled={intentPending}
>
Skip
</Button>
<Button
size="sm"
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
disabled={!selectedIntent || intentPending}
onClick={() => void handleContinue()}
>
{intentPending ? (
<ReloadIcon
aria-hidden
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : null}
Continue
</Button>
</DialogFooter>
</OnboardingStepShell>
) : owner.kind === "questionnaire" && owner.step === "details" ? (
<QuestionnaireDetailsStep
completionAction={questionnaire ? "update" : "complete"}
expectedRevision={questionnaire?.revision ?? 0}
organizationId={questionnaireOrganizationId}
initialAnswers={answersFromQuestionnaire(questionnaire)}
externalError={intentError}
isPending={questionnairePending}
onAction={handleQuestionnaireAction}
onBack={handleBack}
/>
) : variant === VARIANTS.COPILOT_FIRST ? (
<div className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-4">
<OnboardingStepProgress
stepIndex={stepCount}
stepCount={stepCount}
chip="final"
/>
<div
className={cn(
"grid min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-4 [&>div:first-child]:pr-12 [&>div:nth-child(2)]:min-h-0 [&>div:nth-child(2)]:overflow-y-auto [&>div:nth-child(2)]:overscroll-contain [&>div:nth-child(2)_.animate-spin]:motion-reduce:animate-none",
"[&>div:nth-child(2)>button]:min-h-[3.5rem] [&>div:nth-child(2)>button]:touch-manipulation [&>div:nth-child(2)>button]:flex-row [&>div:nth-child(2)>button]:flex-wrap [&>div:nth-child(2)>button]:items-center [&>div:nth-child(2)>button]:py-3 [&>div:nth-child(2)>button]:motion-reduce:transition-none [&>div:nth-child(2)>div]:h-[3.5rem] [&>div:nth-child(2)>p]:col-span-1 [&>div:nth-child(2)]:grid-cols-1",
"[&>div:nth-child(2)>button>div]:flex-1 [&>div:nth-child(2)>button>div]:basis-[calc(100%-2rem)] [&>div:nth-child(2)>button>div]:sm:basis-0 [&>div:nth-child(2)>button>svg]:h-5 [&>div:nth-child(2)>button>svg]:w-5 [&>div:nth-child(2)>button>svg]:shrink-0",
"[&>div:nth-child(2)>button>span]:basis-full [&>div:nth-child(2)>button>span]:pl-8 [&>div:nth-child(2)>button>span]:tabular-nums [&>div:nth-child(2)>button>span]:sm:ml-auto [&>div:nth-child(2)>button>span]:sm:basis-auto [&>div:nth-child(2)>button>span]:sm:pl-0 [&>div:nth-child(2)>button>span]:sm:text-right",
"[&>div:last-child]:sticky [&>div:last-child]:bottom-0 [&>div:last-child]:z-10 [&>div:last-child]:-mx-6 [&>div:last-child]:-mb-6 [&>div:last-child]:border-t [&>div:last-child]:border-border [&>div:last-child]:bg-background [&>div:last-child]:px-6 [&>div:last-child]:pb-[max(1rem,env(safe-area-inset-bottom))] [&>div:last-child]:pt-4",
"[&>div:last-child_button]:min-h-11 [&>div:last-child_button]:w-full [&>div:last-child_button]:min-w-11 [&>div:last-child_button]:touch-manipulation [&>div:last-child_button]:motion-reduce:transition-none [&>div:last-child_button]:sm:w-auto",
)}
data-testid="copilot-chrome"
>
<CopilotCTAStep
selectedIntent={selectedIntent!}
onBack={handleBack}
onSkip={handleSkip}
onDismiss={() =>
updateState({ modal_dismissed_at: new Date().toISOString() })
}
onBusyChange={setCopilotStepBusy}
/>
</div>
</div>
) : (
<OnboardingStepShell
stepIndex={stepCount}
stepCount={stepCount}
chip="final"
>
<DialogHeader className="pr-12">
<DialogTitle className="text-xl font-semibold">
Pick a template to start
</DialogTitle>
<DialogDescription>
Choose a pre-built workflow and customize it in the editor.
</DialogDescription>
</DialogHeader>
<div className="flex min-h-0 flex-col gap-2 overflow-y-auto overscroll-contain pr-1">
{templatesLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[3.5rem] rounded-lg" />
))
) : filteredTemplates.length > 0 ? (
filteredTemplates.map((template) => (
<OnboardingOptionRow
key={template.workflow_permanent_id}
icon={getTemplateIcon(template)}
label={template.title}
description={template.description ?? ""}
meta={`~${getSetupTime(template)} setup`}
disabled={createWorkflowMutation.isPending}
onClick={() => handleTemplateSelect(template)}
/>
))
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
No templates available yet.
</p>
)}
</div>
<DialogFooter className="sticky bottom-0 z-10 -mx-6 -mb-6 flex-col gap-2 border-t border-border bg-background px-6 pb-[max(1rem,env(safe-area-inset-bottom))] pt-4 sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
<Button
variant="ghost"
size="sm"
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
onClick={handleBack}
disabled={createWorkflowMutation.isPending}
>
Back
</Button>
<Button
variant="ghost"
size="sm"
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
onClick={handleSkip}
disabled={createWorkflowMutation.isPending}
>
Skip
</Button>
</DialogFooter>
</OnboardingStepShell>
)}
</DialogContent>
</Dialog>
);
}
function GetStartedModal() {
const userId = useUser().get()?.id;
const orgId = useActiveOrgId();
return (
<GetStartedModalForUser
key={JSON.stringify([userId ?? null, orgId ?? null])}
/>
);
}
export { GetStartedModal };

View file

@ -99,6 +99,26 @@ const rowDefinitions: Record<TrackKey, RowDefinition> = {
links: [{ label: "Get an API key", to: "/settings#api-keys" }],
howItWorks: DOCS_API_QUICKSTART_URL,
},
agent_edited: {
title: "Edit an agent in the editor",
why: "Change a step and save it to make the agent yours",
links: [{ label: "Open your agents", to: "/agents" }],
},
questionnaire_completed: {
title: "Answer the welcome questions",
why: "Tell us what you want to automate",
links: [{ label: "Answer them", to: "/welcome" }],
},
first_successful_run: {
title: "Run your first agent",
why: "Describe a task and let an agent do it",
links: [{ label: "Start on Home", to: "/discover?focus=prompt" }],
},
run_feedback_given: {
title: "Rate a run",
why: "A thumbs up or down helps agents get better",
links: [{ label: "Open runs", to: "/runs" }],
},
mcp_installed: {
title: "Use Skyvern from Claude, Cursor, or ChatGPT",
why: "Install the MCP server and run agents from your AI tools",
@ -139,6 +159,8 @@ const productionOrder: readonly TrackKey[] = [
"first_scheduled_run",
"first_api_run",
"mcp_installed",
"agent_edited",
"run_feedback_given",
"credential_saved",
];
const intentFirstRow: Record<string, TrackKey> = {
@ -435,6 +457,11 @@ function TrackRow({
: "Upcoming step: "}
</span>
{definition.title}
{item.reward_credits && !isDone && !isLocked ? (
<span className="ml-2 whitespace-nowrap rounded-full bg-badge-success px-2 py-0.5 align-middle text-[11px] font-medium tabular-nums text-foreground">
+{item.reward_credits.toLocaleString()} credits
</span>
) : null}
</p>
<p
className={

View file

@ -1,26 +0,0 @@
import { Component, type ReactNode } from "react";
type Props = {
onError: () => void;
children: ReactNode;
};
type State = { hasError: boolean };
class OnboardingErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(): void {
this.props.onError();
}
render(): ReactNode {
return this.state.hasError ? null : this.props.children;
}
}
export { OnboardingErrorBoundary };

View file

@ -1,60 +0,0 @@
import type { ComponentType, ReactNode } from "react";
import { cn } from "@/util/utils";
type Props = {
icon?: ComponentType<{ className?: string }>;
label: string;
description?: string;
meta?: ReactNode;
selected?: boolean;
disabled?: boolean;
onClick: () => void;
};
function OnboardingOptionRow(props: Readonly<Props>) {
const {
icon: Icon,
label,
description,
meta,
selected,
disabled,
onClick,
} = props;
return (
<button
type="button"
aria-pressed={selected}
disabled={disabled}
onClick={onClick}
className={cn(
"flex min-h-[3.5rem] w-full touch-manipulation flex-wrap items-center gap-x-3 gap-y-1 rounded-lg border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none",
selected
? "border-2 border-primary bg-primary/5 px-[15px] py-[11px]"
: "border-border hover:border-primary/60 hover:bg-muted/50",
disabled && "pointer-events-none opacity-50",
)}
>
{Icon ? (
<span aria-hidden="true" className="shrink-0">
<Icon className="h-5 w-5 text-primary" />
</span>
) : null}
<div className="min-w-0 flex-1 basis-[calc(100%-2rem)] sm:basis-0">
<p className="truncate text-sm font-medium">{label}</p>
{description ? (
<p className="line-clamp-2 text-xs text-muted-foreground">
{description}
</p>
) : null}
</div>
{meta ? (
<span className="basis-full pl-8 text-xs tabular-nums text-muted-foreground sm:ml-auto sm:basis-auto sm:pl-0 sm:text-right">
{meta}
</span>
) : null}
</button>
);
}
export { OnboardingOptionRow };

View file

@ -1,25 +0,0 @@
import type { ReactNode } from "react";
import { OnboardingStepProgress } from "./OnboardingStepProgress";
type Props = {
stepIndex: number;
stepCount: number;
chip?: "optional" | "final";
children: ReactNode;
};
function OnboardingStepShell(props: Readonly<Props>) {
const { stepIndex, stepCount, chip, children } = props;
return (
<div className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)_auto_auto] gap-4">
<OnboardingStepProgress
stepIndex={stepIndex}
stepCount={stepCount}
chip={chip}
/>
{children}
</div>
);
}
export { OnboardingStepShell };

View file

@ -1,364 +0,0 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import type {
QuestionnaireAnswersV1,
QuestionnairePatchV1,
} from "@/store/onboarding/types";
import { QuestionnaireDetailsStep } from "./QuestionnaireDetailsStep";
const ANSWERS: QuestionnaireAnswersV1 = {
role: "developer",
company_context: "personal_or_individual",
scale_intent: "exploring",
referral_source: "search",
};
const LABELS = [
"What best describes your role?",
"What kind of organization are you part of?",
"How do you plan to use Skyvern?",
"How did you hear about Skyvern?",
];
type Action = (patch: QuestionnairePatchV1) => Promise<void>;
function renderDetails({
action = "complete",
revision = 0,
initialAnswers = null,
externalError = null,
isPending = false,
onAction = vi.fn<Action>().mockResolvedValue(),
onBack = vi.fn(),
}: {
action?: "complete" | "update";
revision?: number;
initialAnswers?: QuestionnaireAnswersV1 | null;
externalError?: string | null;
isPending?: boolean;
onAction?: Mock<Action>;
onBack?: () => void;
} = {}) {
render(
<Dialog open>
<DialogContent>
<QuestionnaireDetailsStep
completionAction={action}
expectedRevision={revision}
organizationId="o_test"
initialAnswers={initialAnswers}
externalError={externalError}
isPending={isPending}
onAction={onAction}
onBack={onBack}
/>
</DialogContent>
</Dialog>,
);
return { onAction, onBack };
}
function selectAll() {
screen.getAllByRole("combobox").forEach((control) => {
fireEvent.click(control);
fireEvent.click(screen.getAllByRole("option")[0]!);
});
}
function submitted(onAction: Mock<Action>): QuestionnairePatchV1 {
const patch = onAction.mock.calls[onAction.mock.calls.length - 1]?.[0];
if (!patch) throw new Error("Expected questionnaire submission");
return patch;
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("QuestionnaireDetailsStep", () => {
it.each([
["Name", " Example Owner ", { name: "Example Owner" }],
[
"Work email",
"owner@example.com",
{ professional_email: "owner@example.com" },
],
["Role", "Project lead", { role: "Project lead" }],
] as const)(
"saves %s independently with confirmed organization",
async (label, value, fields) => {
const { onAction } = renderDetails({ initialAnswers: ANSWERS });
fireEvent.click(
screen.getByRole("button", {
name: /Who else owns this automation project/,
}),
);
fireEvent.change(screen.getByLabelText(label), { target: { value } });
fireEvent.click(
screen.getByRole("button", { name: "Complete and continue" }),
);
await waitFor(() => expect(onAction).toHaveBeenCalledOnce());
expect(submitted(onAction)).toEqual(
expect.objectContaining({
...ANSWERS,
project_owner: {
action: "set",
expected_organization_id: "o_test",
...fields,
},
}),
);
},
);
it.each(["Skip", "Discard owner details and continue"])(
"%s discards invalid drafts without extra requests or validation",
async (action) => {
const { onAction } = renderDetails({ initialAnswers: ANSWERS });
fireEvent.click(
screen.getByRole("button", {
name: /Who else owns this automation project/,
}),
);
fireEvent.change(screen.getByLabelText("Name"), {
target: { value: "Example Owner" },
});
fireEvent.change(screen.getByLabelText("Work email"), {
target: { value: "invalid" },
});
fireEvent.click(
screen.getByRole("button", { name: "Complete and continue" }),
);
expect(onAction).not.toHaveBeenCalled();
expect(
screen.getByLabelText("Work email").getAttribute("aria-invalid"),
).toBe("true");
fireEvent.click(screen.getByRole("button", { name: action }));
await waitFor(() => expect(onAction).toHaveBeenCalledOnce());
expect(submitted(onAction)).toEqual({
version: 1,
mutation_id: expect.any(String),
expected_revision: 0,
action: action === "Skip" ? "skip" : "complete",
...(action === "Skip" ? {} : ANSWERS),
});
expect(screen.getByLabelText("Name")).toHaveProperty("value", "");
expect(screen.getByLabelText("Work email")).toHaveProperty("value", "");
},
);
it("omits an all-blank owner and reuses a mutation only for the same report", async () => {
const onAction = vi.fn<Action>().mockRejectedValue(new Error("offline"));
renderDetails({ initialAnswers: ANSWERS, onAction });
fireEvent.click(
screen.getByRole("button", {
name: /Who else owns this automation project/,
}),
);
const name = screen.getByLabelText("Name");
const submit = screen.getByRole("button", {
name: "Complete and continue",
});
fireEvent.change(name, { target: { value: " " } });
fireEvent.click(submit);
await screen.findByRole("alert");
expect(submitted(onAction)).not.toHaveProperty("project_owner");
fireEvent.change(name, { target: { value: "Example Owner" } });
fireEvent.click(submit);
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(2));
await waitFor(() => expect(submit).toHaveProperty("disabled", false));
const patch = submitted(onAction);
fireEvent.click(submit);
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(3));
expect(submitted(onAction)).toEqual(patch);
await waitFor(() => expect(submit).toHaveProperty("disabled", false));
fireEvent.change(name, { target: { value: "Another Owner" } });
fireEvent.click(submit);
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(4));
expect(submitted(onAction).mutation_id).not.toBe(patch.mutation_id);
});
it("renders the optional details form as the final step", () => {
const { onBack } = renderDetails();
expect(
screen.getByRole("heading", { name: "Tell us about your setup" }),
).toBeTruthy();
expect(screen.getByText("STEP 2 OF 2")).toBeTruthy();
expect(screen.getByText("FINAL STEP")).toBeTruthy();
expect(
screen.getByText(/Optional\. Your answers help us recommend/),
).toBeTruthy();
expect(screen.queryByText(/About 30 seconds/)).toBeNull();
expect(screen.getAllByRole("combobox")).toHaveLength(4);
LABELS.forEach((label) =>
expect(screen.getByLabelText(label)).toBeTruthy(),
);
expect(screen.queryByRole("textbox")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Back" }));
expect(onBack).toHaveBeenCalledOnce();
});
it("keeps an incomplete Complete action focusable and explains why it cannot submit", () => {
const { onAction } = renderDetails();
const submit = screen.getByRole("button", {
name: "Complete and continue",
});
const hint = screen.getByText(
"Answer the four setup questions to complete and continue.",
);
expect(submit).toHaveProperty("disabled", false);
expect(submit.getAttribute("aria-disabled")).toBe("true");
expect(submit.getAttribute("aria-describedby")).toBe(hint.id);
expect(submit.classList.contains("cursor-not-allowed")).toBe(true);
expect(submit.classList.contains("opacity-50")).toBe(true);
submit.focus();
expect(document.activeElement).toBe(submit);
fireEvent.click(submit);
expect(onAction).not.toHaveBeenCalled();
});
it("natively disables Complete while a submission is pending", () => {
renderDetails({ isPending: true });
expect(
screen.getByRole("button", { name: "Complete and continue" }),
).toHaveProperty("disabled", true);
});
it("announces a parent close error without leaving details", () => {
renderDetails({
externalError: "We couldn't save your choice. Try again.",
});
expect(screen.getByRole("alert").textContent).toBe(
"We couldn't save your choice. Try again.",
);
expect(
screen.getByRole("heading", { name: "Tell us about your setup" }),
).toBeTruthy();
});
it("prefers a new close error over a stale submit error", async () => {
const onAction = vi
.fn<(patch: QuestionnairePatchV1) => Promise<void>>()
.mockRejectedValue(new Error("offline"));
const view = render(
<Dialog open>
<DialogContent>
<QuestionnaireDetailsStep
completionAction="complete"
expectedRevision={0}
initialAnswers={null}
externalError={null}
isPending={false}
onAction={onAction}
onBack={vi.fn()}
/>
</DialogContent>
</Dialog>,
);
selectAll();
fireEvent.click(
screen.getByRole("button", { name: "Complete and continue" }),
);
expect((await screen.findByRole("alert")).textContent).toBe(
"We couldn't save your details. Try again.",
);
view.rerender(
<Dialog open>
<DialogContent>
<QuestionnaireDetailsStep
completionAction="complete"
expectedRevision={0}
initialAnswers={null}
externalError="We couldn't save your choice. Try again."
isPending={false}
onAction={onAction}
onBack={vi.fn()}
/>
</DialogContent>
</Dialog>,
);
expect(screen.getByRole("alert").textContent).toBe(
"We couldn't save your choice. Try again.",
);
});
it.each([
["complete", 0, null],
["update", 3, ANSWERS],
] as const)(
"submits %s with all answers and the exact revision",
async (action, revision, initialAnswers) => {
const onAction = vi
.fn<(patch: QuestionnairePatchV1) => Promise<void>>()
.mockResolvedValue();
renderDetails({ action, revision, initialAnswers, onAction });
const submit = screen.getByRole("button", {
name: "Complete and continue",
});
if (!initialAnswers) {
selectAll();
}
fireEvent.click(submit);
await waitFor(() => expect(onAction).toHaveBeenCalledOnce());
expect(submitted(onAction)).toEqual({
version: 1,
mutation_id: expect.any(String),
action,
expected_revision: revision,
...ANSWERS,
});
},
);
it("submits answer-free Skip without a defer control", async () => {
const onAction = vi
.fn<(patch: QuestionnairePatchV1) => Promise<void>>()
.mockResolvedValue();
renderDetails({ onAction });
expect(screen.queryByRole("button", { name: "Maybe later" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Skip" }));
await waitFor(() => expect(onAction).toHaveBeenCalledOnce());
expect(submitted(onAction)).toEqual({
version: 1,
mutation_id: expect.any(String),
expected_revision: 0,
action: "skip",
});
});
it("keeps selections and mutation identity for an exact retry only", async () => {
const onAction = vi
.fn<(patch: QuestionnairePatchV1) => Promise<void>>()
.mockRejectedValueOnce(new Error("offline"))
.mockResolvedValue(undefined);
renderDetails({ onAction });
selectAll();
const submit = screen.getByRole("button", {
name: "Complete and continue",
});
fireEvent.click(submit);
expect((await screen.findByRole("alert")).textContent).toBe(
"We couldn't save your details. Try again.",
);
const firstMutation = submitted(onAction).mutation_id;
fireEvent.click(submit);
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(2));
expect(submitted(onAction).mutation_id).toBe(firstMutation);
fireEvent.click(screen.getAllByRole("combobox")[0]!);
fireEvent.click(screen.getAllByRole("option")[1]!);
fireEvent.click(submit);
await waitFor(() => expect(onAction).toHaveBeenCalledTimes(3));
expect(submitted(onAction).mutation_id).not.toBe(firstMutation);
});
});

View file

@ -1,594 +0,0 @@
import { ChevronDownIcon, ReloadIcon } from "@radix-ui/react-icons";
import { useId, useRef, useState } from "react";
import { z } from "zod";
import { cn } from "@/util/utils";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OnboardingStepShell } from "./OnboardingStepShell";
import type {
QuestionnaireAnswersV1,
QuestionnaireCompanyContextV1,
QuestionnairePatchV1,
QuestionnaireReferralSourceDetailV1,
QuestionnaireReferralSourceV1,
QuestionnaireRoleV1,
QuestionnaireScaleIntentV1,
ProjectOwnerFieldsV1,
} from "@/store/onboarding/types";
type Option<Value extends string> = {
value: Value;
label: string;
};
type Selections = {
[Key in keyof QuestionnaireAnswersV1]: QuestionnaireAnswersV1[Key] | "";
};
type Props = {
completionAction: "complete" | "update";
expectedRevision: number;
organizationId?: string | null;
initialAnswers?: QuestionnaireAnswersV1 | null;
externalError?: string | null;
isPending: boolean;
onAction: (patch: QuestionnairePatchV1) => Promise<void>;
onBack: () => void;
};
type SelectFieldProps<Value extends string> = {
id: string;
label: string;
placeholder: string;
value: Value | "";
choices: readonly Option<Value>[];
disabled: boolean;
onValueChange: (value: Value) => void;
onClear?: () => void;
};
function optionsFromLabels<Value extends string>(
labels: Record<Value, string>,
): readonly Option<Value>[] {
return (Object.keys(labels) as Value[]).map((value) => ({
value,
label: labels[value],
}));
}
const ROLE_LABELS: Record<QuestionnaireRoleV1, string> = {
developer: "Developer",
technical_operator: "Technical operator",
business_operator: "Business operator",
product_manager: "Product manager",
founder_or_executive: "Founder or executive",
other: "Other",
prefer_not_to_say: "Prefer not to say",
};
const ROLE_OPTIONS = optionsFromLabels(ROLE_LABELS);
const COMPANY_CONTEXT_LABELS: Record<QuestionnaireCompanyContextV1, string> = {
personal_or_individual: "Personal or individual",
startup: "Startup",
agency_or_services: "Agency or services",
established_company: "Established company",
education_or_research: "Education or research",
other: "Other",
prefer_not_to_say: "Prefer not to say",
};
const COMPANY_CONTEXT_OPTIONS = optionsFromLabels(COMPANY_CONTEXT_LABELS);
const SCALE_INTENT_LABELS: Record<QuestionnaireScaleIntentV1, string> = {
exploring: "Exploring",
single_workflow: "A single workflow",
recurring_individual: "Recurring individual workflows",
team_or_multi_workflow: "Team or multiple workflows",
production_high_volume: "Production or high volume",
unsure: "Unsure",
};
const SCALE_INTENT_OPTIONS = optionsFromLabels(SCALE_INTENT_LABELS);
const REFERRAL_SOURCE_LABELS: Record<QuestionnaireReferralSourceV1, string> = {
search: "Search",
social: "Social media",
ai_assistant: "AI assistant",
friend_or_colleague: "Friend or colleague",
video: "Video",
blog_or_article: "Blog or article",
event_or_community: "Event or community",
podcast: "Podcast",
other: "Other",
prefer_not_to_say: "Prefer not to say",
};
const REFERRAL_SOURCE_OPTIONS = optionsFromLabels(REFERRAL_SOURCE_LABELS);
const REFERRAL_SOURCE_DETAIL_OPTIONS: Partial<
Record<
QuestionnaireReferralSourceV1,
readonly Option<QuestionnaireReferralSourceDetailV1>[]
>
> = {
ai_assistant: optionsFromLabels({
chatgpt: "ChatGPT",
claude: "Claude",
perplexity: "Perplexity",
gemini: "Gemini",
other_ai_assistant: "Other AI assistant",
}),
social: optionsFromLabels({
reddit: "Reddit",
x: "X (Twitter)",
linkedin: "LinkedIn",
youtube: "YouTube",
other_social: "Other social network",
}),
};
function selectedAnswers(
selections: Selections,
): QuestionnaireAnswersV1 | null {
const {
role,
company_context,
scale_intent,
referral_source,
referral_source_detail,
} = selections;
return role && company_context && scale_intent && referral_source
? {
role,
company_context,
scale_intent,
referral_source,
...(REFERRAL_SOURCE_DETAIL_OPTIONS[referral_source]
? { referral_source_detail: referral_source_detail || null }
: {}),
}
: null;
}
function QuestionnaireSelect<Value extends string>({
id,
label,
placeholder,
value,
choices,
disabled,
onValueChange,
onClear,
}: Readonly<SelectFieldProps<Value>>) {
function selectValue(nextValue: string) {
const choice = choices.find((candidate) => candidate.value === nextValue);
if (choice) {
onValueChange(choice.value);
}
}
return (
<div className="grid gap-2">
<div
className={cn(
"flex items-center justify-between gap-2",
onClear && "min-h-11",
)}
>
<Label htmlFor={id} className="text-sm leading-snug">
{label}
</Label>
{onClear && value && (
<Button
type="button"
variant="ghost"
size="sm"
aria-label={`Clear ${label}`}
className="min-h-11 min-w-11 touch-manipulation motion-reduce:transition-none"
disabled={disabled}
onClick={onClear}
>
Clear
</Button>
)}
</div>
<Select value={value} onValueChange={selectValue} disabled={disabled}>
<SelectTrigger
id={id}
className="min-h-11 w-full touch-manipulation bg-background text-foreground motion-reduce:transition-none"
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent className="z-[2147480004]">
{choices.map((choice) => (
<SelectItem
key={choice.value}
value={choice.value}
className="min-h-11 touch-manipulation motion-reduce:transition-none"
>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
function QuestionnaireDetailsStep({
completionAction,
expectedRevision,
organizationId,
initialAnswers,
externalError,
isPending,
onAction,
onBack,
}: Readonly<Props>) {
const fieldPrefix = useId();
const incompleteHintId = `${fieldPrefix}-incomplete-hint`;
const [selections, setSelections] = useState<Selections>(() => ({
role: initialAnswers?.role ?? "",
company_context: initialAnswers?.company_context ?? "",
scale_intent: initialAnswers?.scale_intent ?? "",
referral_source: initialAnswers?.referral_source ?? "",
referral_source_detail: initialAnswers?.referral_source_detail ?? "",
}));
const [pendingAction, setPendingAction] = useState<
QuestionnairePatchV1["action"] | null
>(null);
const [error, setError] = useState<string | null>(null);
const [ownerExpanded, setOwnerExpanded] = useState(false);
const [ownerDraft, setOwnerDraft] = useState({
name: "",
professional_email: "",
role: "",
});
const [ownerError, setOwnerError] = useState<string | null>(null);
const displayedError = externalError ?? error;
const pending = isPending || pendingAction !== null;
const mutationIdentityRef = useRef<{
payload: string;
mutationId: string;
} | null>(null);
const answers = selectedAnswers(selections);
const detailOptions = selections.referral_source
? REFERRAL_SOURCE_DETAIL_OPTIONS[selections.referral_source]
: undefined;
function setSelection<Key extends keyof QuestionnaireAnswersV1>(
key: Key,
value: QuestionnaireAnswersV1[Key],
) {
setSelections((current) => ({ ...current, [key]: value }));
}
function mutationIdFor(payload: string) {
const mutationId =
mutationIdentityRef.current?.payload === payload
? mutationIdentityRef.current.mutationId
: crypto.randomUUID();
mutationIdentityRef.current = { payload, mutationId };
return mutationId;
}
function discardOwner() {
setOwnerDraft({ name: "", professional_email: "", role: "" });
setOwnerError(null);
}
async function submit(
action: QuestionnairePatchV1["action"],
discard = false,
) {
if (pending) return;
const owner: ProjectOwnerFieldsV1 = {};
if (action === "skip" || discard) {
discardOwner();
} else {
for (const key of ["name", "professional_email", "role"] as const) {
const value = ownerDraft[key].trim();
if (value) owner[key] = value;
}
if (
owner.professional_email &&
!z.string().max(254).email().safeParse(owner.professional_email).success
) {
setOwnerError("Enter a valid work email or discard the owner details.");
setOwnerExpanded(true);
return;
}
if (Object.keys(owner).length > 0 && !organizationId) {
setOwnerError(
"We couldn't confirm your organization. Discard the owner details to continue.",
);
setOwnerExpanded(true);
return;
}
}
try {
const payload =
action === "skip"
? { version: 1 as const, expected_revision: expectedRevision, action }
: answers && {
version: 1 as const,
expected_revision: expectedRevision,
action,
...answers,
...(Object.keys(owner).length > 0 && organizationId
? {
project_owner: {
action: "set" as const,
expected_organization_id: organizationId,
...owner,
},
}
: {}),
};
if (!payload) return;
const patch = {
...payload,
mutation_id: mutationIdFor(JSON.stringify(payload)),
} as QuestionnairePatchV1;
setPendingAction(action);
setError(null);
await onAction(patch);
mutationIdentityRef.current = null;
} catch (cause) {
if (cause instanceof Error && cause.message === "project_owner_invalid") {
setOwnerError("Check the owner details or discard them to continue.");
setOwnerExpanded(true);
} else {
setError("We couldn't save your details. Try again.");
}
} finally {
setPendingAction(null);
}
}
return (
<OnboardingStepShell stepIndex={2} stepCount={2} chip="final">
<DialogHeader className="pr-12">
<DialogTitle className="text-xl font-semibold">
Tell us about your setup
</DialogTitle>
<DialogDescription>
Optional. Your answers help us recommend templates and guidance. They
never limit what you can build.
</DialogDescription>
</DialogHeader>
<div className="grid min-h-0 gap-4 overflow-y-auto overscroll-contain pb-6 pr-1 pt-1">
<QuestionnaireSelect
id={`${fieldPrefix}-role`}
label="What best describes your role?"
placeholder="Choose a role"
value={selections.role}
choices={ROLE_OPTIONS}
disabled={pending}
onValueChange={(value) => setSelection("role", value)}
/>
<QuestionnaireSelect
id={`${fieldPrefix}-company-context`}
label="What kind of organization are you part of?"
placeholder="Choose an organization type"
value={selections.company_context}
choices={COMPANY_CONTEXT_OPTIONS}
disabled={pending}
onValueChange={(value) => setSelection("company_context", value)}
/>
<QuestionnaireSelect
id={`${fieldPrefix}-scale-intent`}
label="How do you plan to use Skyvern?"
placeholder="Choose a usage pattern"
value={selections.scale_intent}
choices={SCALE_INTENT_OPTIONS}
disabled={pending}
onValueChange={(value) => setSelection("scale_intent", value)}
/>
<QuestionnaireSelect
id={`${fieldPrefix}-referral-source`}
label="How did you hear about Skyvern?"
placeholder="Choose a source"
value={selections.referral_source}
choices={REFERRAL_SOURCE_OPTIONS}
disabled={pending}
onValueChange={(value) =>
setSelections((current) => ({
...current,
referral_source: value,
referral_source_detail: REFERRAL_SOURCE_DETAIL_OPTIONS[
value
]?.some(
(option) => option.value === current.referral_source_detail,
)
? current.referral_source_detail
: "",
}))
}
/>
{detailOptions && (
<QuestionnaireSelect
id={`${fieldPrefix}-referral-source-detail`}
label="Which one?"
placeholder="Select one (optional)"
value={selections.referral_source_detail ?? ""}
choices={detailOptions}
disabled={pending}
onValueChange={(value) =>
setSelection("referral_source_detail", value)
}
onClear={() =>
setSelections((current) => ({
...current,
referral_source_detail: "",
}))
}
/>
)}
{organizationId ? (
<div className="ph-no-capture grid gap-3 border-t border-border pt-3">
<Button
type="button"
variant="ghost"
className="h-auto min-h-11 justify-start whitespace-normal px-0 text-left"
aria-expanded={ownerExpanded}
aria-controls={`${fieldPrefix}-owner`}
disabled={pending}
onClick={() => setOwnerExpanded(!ownerExpanded)}
>
<ChevronDownIcon
aria-hidden
className={cn(
"mr-2 size-4 shrink-0",
ownerExpanded && "rotate-180",
)}
/>
Who else owns this automation project? (Optional)
</Button>
{ownerExpanded ? (
<div id={`${fieldPrefix}-owner`} className="grid gap-3">
<p className="text-sm text-muted-foreground">
Share a project owner or technical sponsor to help us
understand your project. This does not send an invitation or
message.
</p>
{(
[
["name", "Name", 120],
["professional_email", "Work email", 254],
["role", "Role", 120],
] as const
).map(([key, label, maxLength]) => (
<div key={key} className="grid gap-2">
<Label htmlFor={`${fieldPrefix}-owner-${key}`}>
{label}
</Label>
<Input
id={`${fieldPrefix}-owner-${key}`}
type={key === "professional_email" ? "email" : "text"}
autoComplete="off"
maxLength={maxLength}
value={ownerDraft[key]}
disabled={pending}
className="min-h-11"
aria-invalid={
(key === "professional_email" && !!ownerError) ||
undefined
}
aria-describedby={
ownerError ? `${fieldPrefix}-owner-error` : undefined
}
onChange={(event) => {
const value = event.target.value;
setOwnerDraft((current) => ({
...current,
[key]: value,
}));
setOwnerError(null);
}}
/>
</div>
))}
{ownerError ? (
<div className="grid gap-2">
<p
id={`${fieldPrefix}-owner-error`}
role="alert"
className="text-sm text-destructive"
>
{ownerError}
</p>
<Button
type="button"
variant="outline"
disabled={pending}
onClick={() => void submit(completionAction, true)}
>
Discard owner details and continue
</Button>
</div>
) : null}
</div>
) : null}
</div>
) : null}
{displayedError ? (
<Alert variant="destructive">
<AlertDescription>{displayedError}</AlertDescription>
</Alert>
) : null}
{!answers ? (
<p id={incompleteHintId} className="text-sm text-muted-foreground">
Answer the four setup questions to complete and continue.
</p>
) : null}
</div>
<DialogFooter className="sticky bottom-0 z-10 -mx-6 -mb-6 flex-col gap-2 border-t border-border bg-background px-6 pb-[max(1rem,env(safe-area-inset-bottom))] pt-4 sm:flex-row sm:justify-between sm:gap-0 sm:space-x-0">
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
disabled={pending}
onClick={onBack}
>
Back
</Button>
{completionAction === "complete" ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto"
disabled={pending}
onClick={() => void submit("skip")}
>
{pendingAction === "skip" ? (
<ReloadIcon
aria-hidden
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : null}
Skip
</Button>
) : null}
<Button
type="button"
size="sm"
className={cn(
"min-h-11 w-full min-w-11 touch-manipulation motion-reduce:transition-none sm:w-auto",
!answers && "cursor-not-allowed opacity-50",
)}
aria-describedby={!answers ? incompleteHintId : undefined}
aria-disabled={!answers || undefined}
disabled={pending}
onClick={() => void submit(completionAction)}
>
{pendingAction === completionAction ? (
<ReloadIcon
aria-hidden
className="mr-2 size-4 animate-spin motion-reduce:animate-none"
/>
) : null}
Complete and continue
</Button>
</DialogFooter>
</OnboardingStepShell>
);
}
export { QuestionnaireDetailsStep };

View file

@ -1,66 +0,0 @@
import { ClickIcon } from "@/components/icons/ClickIcon";
import { ExtractIcon } from "@/components/icons/ExtractIcon";
import { SearchIcon } from "@/components/icons/SearchIcon";
import { GovernmentIcon } from "@/components/icons/GovernmentIcon";
import { BagIcon } from "@/components/icons/BagIcon";
import { ReceiptIcon } from "@/components/icons/ReceiptIcon";
import { DocumentIcon } from "@/components/icons/DocumentIcon";
import type { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
const INTENT_KEYWORDS: Record<string, RegExp> = {
fill_forms:
/form|application|submit|apply|registration|contact|intake|questionnaire|filing/i,
extract_data:
/extract|scrape|data|lookup|search|download|invoice|collect|report/i,
monitor_website:
/monitor|track|watch|check|alert|notification|change|verify/i,
};
function getTemplatesForIntent(
templates: WorkflowApiResponse[],
intent: string,
): WorkflowApiResponse[] {
const pattern = INTENT_KEYWORDS[intent];
if (!pattern) return templates.slice(0, 4);
const matches = templates.filter((t) => {
const text = `${t.title} ${t.description}`;
return pattern.test(text);
});
if (matches.length >= 4) return matches.slice(0, 4);
const matchedIds = new Set(matches.map((m) => m.workflow_permanent_id));
const remaining = templates.filter(
(t) => !matchedIds.has(t.workflow_permanent_id),
);
return [...matches, ...remaining].slice(0, 4);
}
function getTemplateIcon(
template: WorkflowApiResponse,
): React.FC<{ className?: string }> {
const text = `${template.title} ${template.description}`.toLowerCase();
if (/form|application|submit|registration|contact/.test(text))
return ClickIcon;
if (/invoice|receipt|billing|payment/.test(text)) return ReceiptIcon;
if (/extract|scrape|data|download/.test(text)) return ExtractIcon;
if (/monitor|track|watch|check|alert/.test(text)) return SearchIcon;
if (/government|entity|ein|sam|irs|federal/.test(text)) return GovernmentIcon;
if (/job|career|hiring|recruit|employ/.test(text)) return BagIcon;
return DocumentIcon;
}
function getSetupTime(template: WorkflowApiResponse): string {
const blocks = template.workflow_definition.blocks.length;
if (blocks <= 3) return "2 min";
if (blocks <= 7) return "5 min";
return "10 min";
}
export {
INTENT_KEYWORDS,
getTemplatesForIntent,
getTemplateIcon,
getSetupTime,
};

View file

@ -19,7 +19,6 @@ import type {
ConfirmedPatch,
ConfirmedWriteResult,
OnboardingState,
QuestionnairePatchV1,
QuestionnaireStateV1,
} from "@/store/onboarding/types";
import { OnboardingContext } from "@/store/onboarding/useOnboardingState";
@ -31,15 +30,13 @@ const mocks = vi.hoisted(() => ({
createPending: false,
createWorkflow: vi.fn(),
createWorkflowOptions: undefined as { onCreated?: () => void } | undefined,
focusAndPrefillExample: vi.fn<(key: string) => void>(),
focusAndPrefillExample:
vi.fn<(key: string | null, fallback: string) => void>(),
homeViewed: vi.fn(),
telemetry: {
registerVariant: vi.fn(),
flowStarted: vi.fn(),
modalOpened: vi.fn(),
questionnaireShown: vi.fn<(input: unknown) => boolean>(() => true),
questionnaireCompleted: vi.fn(),
modalRenderError: vi.fn(),
},
}));
@ -86,15 +83,17 @@ vi.mock("@/routes/tasks/create/PromptBox", async () => {
const React = await vi.importActual<typeof import("react")>("react");
return {
PromptBox: React.forwardRef<
{ focusAndPrefillExample: (key: string) => void },
{
focusAndPrefillExample: (key: string | null, fallback: string) => void;
},
{ secondaryAction?: React.ReactNode }
>(function PromptBoxMock({ secondaryAction }, ref) {
const [value, setValue] = React.useState("");
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
React.useImperativeHandle(ref, () => ({
focusAndPrefillExample: (key) => {
mocks.focusAndPrefillExample(key);
setValue((current) => (current.trim() ? current : key));
focusAndPrefillExample: (key, fallback) => {
mocks.focusAndPrefillExample(key, fallback);
setValue((current) => (current.trim() ? current : (key ?? fallback)));
textareaRef.current?.focus({ preventScroll: true });
},
}));
@ -118,33 +117,6 @@ vi.mock("./WorkflowTemplates", () => ({
<div data-testid="discover-templates">templates</div>
),
}));
vi.mock("@/components/onboarding/QuestionnaireDetailsStep", () => ({
QuestionnaireDetailsStep: ({
expectedRevision,
onAction,
}: {
expectedRevision: number;
onAction: (patch: QuestionnairePatchV1) => Promise<void>;
}) => (
<button
type="button"
onClick={() =>
void onAction({
version: 1,
mutation_id: `complete-${expectedRevision}`,
expected_revision: expectedRevision,
action: "complete",
role: "developer",
company_context: "startup",
scale_intent: "exploring",
referral_source: "search",
})
}
>
details-submit
</button>
),
}));
vi.mock("@/util/onboarding/OnboardingTelemetry", () => ({
OnboardingTelemetry: mocks.telemetry,
}));
@ -189,14 +161,6 @@ const questionnaire = (completed = false): QuestionnaireStateV1 => ({
defer_prompt_count: completed ? 0 : 1,
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function LocationProbe() {
return <span data-testid="location">{useLocation().search}</span>;
}
@ -324,6 +288,7 @@ describe("DiscoverPage focus param", () => {
expect(mocks.focusAndPrefillExample).toHaveBeenCalledOnce(),
);
expect(mocks.focusAndPrefillExample).toHaveBeenCalledWith(
null,
"contact_us_forms",
);
expect((prompt as HTMLTextAreaElement).value).toBe("contact_us_forms");
@ -356,6 +321,7 @@ describe("DiscoverPage focus param", () => {
expect(mocks.focusAndPrefillExample).toHaveBeenCalledOnce(),
);
expect(mocks.focusAndPrefillExample).toHaveBeenCalledWith(
null,
"contact_us_forms",
);
expect(screen.getByTestId("location").textContent).toBe("");
@ -459,7 +425,7 @@ describe("DiscoverPage onboarding mount", () => {
expect(mocks.homeViewed).toHaveBeenCalledOnce();
});
it("preserves content order and mounts over seeded template data", async () => {
it("preserves content order and mounts over seeded template data", () => {
renderDiscover(baseState);
const content =
screen.getByTestId("discover-templates").parentElement?.parentElement;
@ -469,7 +435,6 @@ describe("DiscoverPage onboarding mount", () => {
expect(screen.queryByText("Build your first agent")).toBeNull();
expect(screen.queryByText(/Keep going/)).toBeNull();
expect(screen.queryByText("Resume getting started")).toBeNull();
expect(await screen.findByRole("dialog")).toBeTruthy();
});
it("starts one attributed blank-agent attempt", () => {
@ -504,147 +469,10 @@ describe("DiscoverPage onboarding mount", () => {
);
});
it("keeps Discover actions behind the modal while reservation is pending", async () => {
const reservation = deferred<ConfirmedWriteResult>();
mocks.confirmed.mockReturnValueOnce(reservation.promise);
const view = renderDiscover(baseState);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
const dialog = screen.getByRole("dialog");
expect(
screen.getByRole("heading", { name: "Getting started" }),
).toBeTruthy();
expect(screen.getByText("Checking your onboarding setup.")).toBeTruthy();
expect(mocks.telemetry.modalOpened).not.toHaveBeenCalled();
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
const blankCanvas = screen.getByRole("button", {
name: /start with a blank canvas/i,
hidden: true,
});
blankCanvas.focus();
await waitFor(() =>
expect(dialog.contains(document.activeElement)).toBe(true),
);
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
expect(mocks.createWorkflow).not.toHaveBeenCalled();
view.unmount();
await act(async () =>
reservation.resolve({
onboarding_state: {
...baseState,
questionnaire_prompted_at: "2026-08-27T00:00:00Z",
},
launch_date_at_signup: "2026-01-01T00:00:00Z",
recovery_guidance_assignment: null,
questionnaire_prompt_result: {
status: "reserved",
prompted_at: "2026-08-27T00:00:00Z",
},
}),
);
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
});
it("shields an eligible older-org user with a prior save", async () => {
const reservation = deferred<ConfirmedWriteResult>();
const firstSaveAt = "2026-08-20T00:00:00Z";
mocks.confirmed.mockReturnValueOnce(reservation.promise);
renderDiscover({ ...baseState, first_save_at: firstSaveAt }, false, false);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
const dialog = screen.getByRole("dialog");
const blankCanvas = screen.getByRole("button", {
name: /start with a blank canvas/i,
hidden: true,
});
expect(
screen.getByRole("heading", { name: "Getting started" }),
).toBeTruthy();
blankCanvas.focus();
await waitFor(() =>
expect(dialog.contains(document.activeElement)).toBe(true),
);
expect(mocks.createWorkflow).not.toHaveBeenCalled();
await act(async () =>
reservation.resolve({
onboarding_state: {
...baseState,
first_save_at: firstSaveAt,
questionnaire_prompted_at: "2026-08-27T00:00:00Z",
},
launch_date_at_signup: "2026-01-01T00:00:00Z",
recovery_guidance_assignment: null,
questionnaire_prompt_result: {
status: "reserved",
prompted_at: "2026-08-27T00:00:00Z",
},
}),
);
expect(
await screen.findByRole("heading", {
name: "What do you want to automate?",
}),
).toBeTruthy();
expect(mocks.telemetry.questionnaireShown).toHaveBeenCalledOnce();
});
it("renders without onboarding UI when no provider exists", () => {
render(
<MemoryRouter>
<DiscoverPage />
</MemoryRouter>,
);
it("never opens a dialog or reserves the questionnaire for an eligible new user", async () => {
renderDiscover(baseState);
await act(async () => {});
expect(screen.queryByRole("dialog")).toBeNull();
expect(mocks.telemetry.modalRenderError).not.toHaveBeenCalled();
});
it("routes stored intent into the editor fallback", async () => {
renderDiscover({ ...baseState, user_intent: "fill_forms" });
expect(await screen.findByText("Pick a template to start")).toBeTruthy();
});
it("keeps an established user without a pending questionnaire closed", async () => {
renderDiscover(
{ ...baseState, user_intent: "fill_forms", questionnaire: null },
false,
false,
);
await waitFor(() => expect(mocks.confirmed).toHaveBeenCalledOnce());
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
expect(screen.queryByText("Getting started")).toBeNull();
});
it("keeps a completed questionnaire closed", () => {
renderDiscover({
...baseState,
user_intent: "fill_forms",
questionnaire_prompted_at: "2026-08-27T00:00:00Z",
questionnaire: questionnaire(true),
});
expect(screen.queryByRole("dialog")).toBeNull();
expect(mocks.confirmed).not.toHaveBeenCalled();
});
it("never reopens a legacy deferred response", () => {
const deferredState = {
...baseState,
user_intent: "fill_forms",
questionnaire: questionnaire(),
};
mocks.confirmed.mockResolvedValue({
onboarding_state: {
...deferredState,
questionnaire: questionnaire(true),
},
launch_date_at_signup: "2026-01-01T00:00:00Z",
recovery_guidance_assignment: null,
});
renderDiscover(deferredState, true, false);
expect(screen.queryByText("Pick a template to start")).toBeNull();
expect(mocks.telemetry.questionnaireShown).not.toHaveBeenCalled();
expect(mocks.confirmed).not.toHaveBeenCalled();
});
});

View file

@ -1,8 +1,5 @@
import { useEffect, useRef } from "react";
import { HomeTelemetry } from "@/util/homeTelemetry";
import { GetStartedModal } from "@/components/onboarding/GetStartedModal";
import { OnboardingErrorBoundary } from "@/components/onboarding/OnboardingErrorBoundary";
import { OnboardingTelemetry } from "@/util/onboarding/OnboardingTelemetry";
import { useOnboardingStateOptional } from "@/store/onboarding/useOnboardingState";
import { useFeatureFlag } from "@/hooks/useFeatureFlag";
import {
@ -13,7 +10,7 @@ import {
import { WorkflowTemplates } from "./WorkflowTemplates";
import { useCreateWorkflowMutation } from "../workflows/hooks/useCreateWorkflowMutation";
import { Button } from "@/components/ui/button";
import { useSearchParams } from "react-router-dom";
import { useLocation, useSearchParams } from "react-router-dom";
import { FilePlusIcon, ReloadIcon } from "@radix-ui/react-icons";
import { defaultWorkflowRequest } from "../workflows/defaultWorkflowRequest";
@ -23,6 +20,8 @@ function getIntentExampleKey(
switch (intent) {
case "fill_forms":
return "contact_us_forms";
case "job_applications":
return "job_application";
case "extract_data":
return "extractIntegrationsFromGong";
case "monitor_website":
@ -84,6 +83,16 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
// `/discover?focus=prompt` is the sidebar card's first-agent link: focus + prefill once, then drop the param.
const [searchParams, setSearchParams] = useSearchParams();
const focusPrompt = searchParams.get("focus") === "prompt";
const requestedExample = searchParams.get("example");
// Free text arrives in router state rather than the URL, so it never lands in history or logs.
const locationState: unknown = useLocation().state;
const prefillPrompt =
locationState &&
typeof locationState === "object" &&
"prefillPrompt" in locationState &&
typeof locationState.prefillPrompt === "string"
? locationState.prefillPrompt
: null;
useEffect(() => {
if (!focusPrompt) {
handledFocus.current = false;
@ -94,14 +103,20 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
const promptBox = promptBoxRef.current;
if (!promptBox) return;
handledFocus.current = true;
promptBox.focusAndPrefillExample(
getIntentExampleKey(onboarding?.state?.user_intent),
);
if (prefillPrompt) {
promptBox.focusAndPrefillPrompt(prefillPrompt);
} else {
promptBox.focusAndPrefillExample(
requestedExample,
getIntentExampleKey(onboarding?.state?.user_intent),
);
}
}
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.delete("focus");
next.delete("example");
return next;
},
{ replace: true },
@ -110,17 +125,11 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
focusPrompt,
onboarding?.isLoading,
onboarding?.state?.user_intent,
prefillPrompt,
requestedExample,
setSearchParams,
]);
const onboardingModal = onboarding ? (
<OnboardingErrorBoundary
onError={() => OnboardingTelemetry.modalRenderError("discover")}
>
<GetStartedModal />
</OnboardingErrorBoundary>
) : null;
if (revamp) {
return (
<div className="flex min-h-[calc(100vh-9rem)] flex-col justify-center">
@ -153,7 +162,6 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
Skip — start from a blank agent
</Button>
</div>
{onboardingModal}
</div>
);
}
@ -192,7 +200,6 @@ function DiscoverPage({ revamp = false, onRevampComplete }: Props = {}) {
<div className="mx-auto w-full max-w-[60rem] pb-8">
<WorkflowTemplates />
</div>
{onboardingModal}
</div>
);
}

View file

@ -2,6 +2,7 @@ import { expect, it } from "vitest";
import {
countedTrackItems,
parseOnboardingTrack,
REWARD_TRACK_KEYS,
TRACK_KEYS,
} from "./useOnboardingTrack";
@ -27,6 +28,30 @@ it("accepts a well-formed track", () => {
expect(parseOnboardingTrack(validTrack())?.completed_count).toBe(1);
});
it("accepts the activation-rewards layout with its credit totals", () => {
const parsed = parseOnboardingTrack({
...validTrack(),
items: REWARD_TRACK_KEYS.map((key, index) => ({
key,
completed_at: index === 0 ? "2026-09-01T00:00:00Z" : null,
verification:
key === "github_starred" || key === "discord_joined"
? "self"
: "server",
reward_credits: 100,
reward_state: index === 0 ? "granted" : "unearned",
})),
reward_credits_earned: 100,
reward_credits_cap: 1000,
});
expect(parsed?.items[0]).toMatchObject({
key: "questionnaire_completed",
reward_state: "granted",
});
expect(parsed?.reward_credits_earned).toBe(100);
expect(parsed?.reward_credits_cap).toBe(1000);
});
it("accepts only the server-verified second-agent ninth row", () => {
const track = validTrack();
const ninth = {

View file

@ -22,13 +22,34 @@ const TRACK_KEYS = [
"discord_joined",
"social_followed",
] as const;
// The layout served once activation rewards v2 is live; the parser accepts either
// so a backend deploy ahead of the frontend doesn't blank the track.
const REWARD_TRACK_KEYS = [
"questionnaire_completed",
"first_successful_run",
"run_feedback_given",
"first_scheduled_run",
"credential_saved",
"agent_edited",
"github_starred",
"discord_joined",
] as const;
const SECOND_AGENT_KEY = "second_agent_run" as const;
type TrackKey = (typeof TRACK_KEYS)[number] | typeof SECOND_AGENT_KEY;
type TrackKey =
| (typeof TRACK_KEYS)[number]
| (typeof REWARD_TRACK_KEYS)[number]
| typeof SECOND_AGENT_KEY;
const SELF_ATTESTED_KEYS: ReadonlySet<TrackKey> = new Set([
"github_starred",
"discord_joined",
"social_followed",
]);
type TrackRewardState =
| "unearned"
| "pending"
| "blocked"
| "granted"
| "reversed";
// Rows that count toward N/M. The teammate row stays out until its
// destination exists; community rows are never counted.
const COUNTED_KEYS: readonly TrackKey[] = [
@ -36,6 +57,8 @@ const COUNTED_KEYS: readonly TrackKey[] = [
"first_scheduled_run",
"first_api_run",
"mcp_installed",
"agent_edited",
"run_feedback_given",
"credential_saved",
];
type TrackState = "ineligible" | "active" | "dismissed" | "completed";
@ -43,6 +66,9 @@ type OnboardingTrackItemV1 = {
key: TrackKey;
completed_at: string | null;
verification: "server" | "self";
// Credits the milestone grants; null while reward grants are disabled.
reward_credits?: number | null;
reward_state?: TrackRewardState | null;
};
type TrackMutation =
| { action: "dismiss" | "restore" }
@ -54,8 +80,30 @@ type OnboardingTrackV1 = {
completed_count: number;
total_count: 8 | 9;
items: OnboardingTrackItemV1[];
// Credits for answering the onboarding questionnaire; null while reward grants are disabled.
questionnaire_reward_credits?: number | null;
reward_credits_earned?: number | null;
reward_credits_cap?: number | null;
};
function isTrackRewardState(value: unknown): value is TrackRewardState {
return (
value === "unearned" ||
value === "pending" ||
value === "blocked" ||
value === "granted" ||
value === "reversed"
);
}
function wholeCredits(value: unknown, allowZero = false): number | null {
return typeof value === "number" &&
Number.isInteger(value) &&
(allowZero ? value >= 0 : value > 0)
? value
: null;
}
function isTrackState(value: unknown): value is TrackState {
return (
value === "ineligible" ||
@ -76,12 +124,17 @@ function parseOnboardingTrack(value: unknown): OnboardingTrackV1 | null {
total_count !== items.length
)
return null;
const first: unknown = items[0];
const layout: readonly TrackKey[] =
isRecord(first) && first.key === REWARD_TRACK_KEYS[0]
? REWARD_TRACK_KEYS
: TRACK_KEYS;
const parsed: OnboardingTrackItemV1[] = [];
for (const [index, item] of items.entries()) {
const key =
index < TRACK_KEYS.length
? TRACK_KEYS[index]
: index === TRACK_KEYS.length
index < layout.length
? layout[index]
: index === layout.length
? SECOND_AGENT_KEY
: undefined;
if (key === undefined || !isRecord(item) || item.key !== key) return null;
@ -89,7 +142,15 @@ function parseOnboardingTrack(value: unknown): OnboardingTrackV1 | null {
if (!isTimestampOrNull(completedAt)) return null;
const verification = SELF_ATTESTED_KEYS.has(key) ? "self" : "server";
if (item.verification !== verification) return null;
parsed.push({ key, completed_at: completedAt, verification });
parsed.push({
key,
completed_at: completedAt,
verification,
reward_credits: wholeCredits(item.reward_credits),
reward_state: isTrackRewardState(item.reward_state)
? item.reward_state
: null,
});
}
const derivedCount = parsed.filter((row) => row.completed_at !== null).length;
if (completed_count !== derivedCount) return null;
@ -101,17 +162,39 @@ function parseOnboardingTrack(value: unknown): OnboardingTrackV1 | null {
completed_count: derivedCount,
total_count: items.length,
items: parsed,
questionnaire_reward_credits: wholeCredits(
value.questionnaire_reward_credits,
),
reward_credits_earned: wholeCredits(value.reward_credits_earned, true),
reward_credits_cap: wholeCredits(value.reward_credits_cap),
};
}
function useOnboardingTrack() {
type UseOnboardingTrackOptions = {
/**
* For surfaces outside the track experiment: also load for users without a
* Clerk organization, and return the track for both arms.
*/
outsideExperiment?: boolean;
/** When false, never fetch; the track reads as null. */
enabled?: boolean;
};
function useOnboardingTrack({
outsideExperiment = false,
enabled: callerEnabled = true,
}: UseOnboardingTrackOptions = {}) {
const credentialGetter = useCredentialGetter();
const activeOrgId = useActiveOrgId();
const activeUserId = useUser().get()?.id;
const queryClient = useQueryClient();
const flag = useFeatureFlag(ONBOARDING_TRACK_FLAG);
const trackFlag = useFeatureFlag(ONBOARDING_TRACK_FLAG);
// Surfaces outside the experiment gate themselves, so only the holdout's own UI needs the flag.
const enabled =
flag === true && activeOrgId !== undefined && activeUserId !== undefined;
callerEnabled &&
(trackFlag === true || outsideExperiment) &&
(activeOrgId !== undefined || outsideExperiment) &&
activeUserId !== undefined;
const queryKey = getOrgScopedQueryKey(
["onboarding-track", activeUserId],
getActiveOrgQueryKeyScope(activeOrgId),
@ -148,9 +231,9 @@ function useOnboardingTrack() {
});
const track = enabled && !isError ? (data ?? null) : null;
// An undefined flag, org, or user is still resolving; only an explicit
// `false` flag disables the track.
// `false` flag or `enabled: false` disables the track.
const status: "disabled" | "loading" | "error" | "ready" = !enabled
? flag === false
? !callerEnabled || (trackFlag === false && !outsideExperiment)
? "disabled"
: "loading"
: isError
@ -159,7 +242,11 @@ function useOnboardingTrack() {
? "loading"
: "ready";
return {
track: track?.arm === "treatment" ? track : null,
// The track experiment's own UI shows only where its flag is on, not for journey-only orgs.
track:
(trackFlag === true && track?.arm === "treatment") || outsideExperiment
? track
: null,
status,
isPending,
refetch,
@ -183,9 +270,15 @@ function countedTrackItems(
export {
countedTrackItems,
parseOnboardingTrack,
REWARD_TRACK_KEYS,
SECOND_AGENT_KEY,
SELF_ATTESTED_KEYS,
TRACK_KEYS,
useOnboardingTrack,
};
export type { OnboardingTrackItemV1, OnboardingTrackV1, TrackKey };
export type {
OnboardingTrackItemV1,
OnboardingTrackV1,
TrackKey,
TrackRewardState,
};

View file

@ -90,59 +90,81 @@ function StreamModeBadge({
function StreamStatusPanel({
diagnostic,
children,
footer,
className,
}: {
diagnostic: StreamDiagnostic;
children?: ReactNode;
/** Interactive content shown under the status, outside its live region. */
footer?: ReactNode;
className?: string;
}) {
const isSuccess = diagnostic.tone === "success";
const status = (
<div
role={footer ? "status" : undefined}
aria-live={footer ? "polite" : undefined}
className="flex max-w-md flex-col gap-2 text-sm"
>
<div className="flex items-center gap-2 font-medium text-neutral-900 dark:text-slate-100">
{isSuccess ? (
<CheckCircledIcon className="h-4 w-4 flex-shrink-0 text-success" />
) : (
<InfoCircledIcon className="h-4 w-4 flex-shrink-0 text-neutral-500 dark:text-slate-400" />
)}
<span>{diagnostic.title}</span>
</div>
{diagnostic.detail && (
<div className="text-neutral-600 dark:text-slate-400">
{diagnostic.detail}
</div>
)}
{diagnostic.hint && (
<div className="text-xs text-neutral-500 dark:text-slate-500">
{diagnostic.hint}
</div>
)}
{diagnostic.pending && (
// Decorative and on a 7s rotation: inside the live region it would
// interrupt a screen reader again every time it cycles.
<div
aria-hidden="true"
className="mt-1 flex flex-col gap-1 text-neutral-600 dark:text-slate-400"
>
<RotateThrough interval={7 * 1000}>
{WHIMSICAL_LOADING_MESSAGES.map((message) => (
<span key={message}>{message}</span>
))}
</RotateThrough>
<AnimatedWave text={WHIMSICAL_SPARKLE} />
</div>
)}
{children}
</div>
);
const panelClassName = cn(
"flex h-full w-full rounded-md bg-white p-6 text-neutral-600 dark:bg-slate-900 dark:text-slate-300",
isSuccess && "border border-success/30",
className,
);
if (footer) {
// m-auto centers the column while still letting a tall footer scroll from the top.
return (
<div className={cn(panelClassName, "overflow-y-auto")}>
<div className="m-auto flex w-full max-w-md flex-col">
{status}
{footer}
</div>
</div>
);
}
return (
<div
role="status"
aria-live="polite"
className={cn(
"flex h-full w-full items-center justify-center rounded-md bg-white p-6 text-neutral-600 dark:bg-slate-900 dark:text-slate-300",
isSuccess && "border border-success/30",
className,
)}
className={cn(panelClassName, "items-center justify-center")}
>
<div className="flex max-w-md flex-col gap-2 text-sm">
<div className="flex items-center gap-2 font-medium text-neutral-900 dark:text-slate-100">
{isSuccess ? (
<CheckCircledIcon className="h-4 w-4 flex-shrink-0 text-success" />
) : (
<InfoCircledIcon className="h-4 w-4 flex-shrink-0 text-neutral-500 dark:text-slate-400" />
)}
<span>{diagnostic.title}</span>
</div>
{diagnostic.detail && (
<div className="text-neutral-600 dark:text-slate-400">
{diagnostic.detail}
</div>
)}
{diagnostic.hint && (
<div className="text-xs text-neutral-500 dark:text-slate-500">
{diagnostic.hint}
</div>
)}
{diagnostic.pending && (
// Decorative and on a 7s rotation: inside the live region it would
// interrupt a screen reader again every time it cycles.
<div
aria-hidden="true"
className="mt-1 flex flex-col gap-1 text-neutral-600 dark:text-slate-400"
>
<RotateThrough interval={7 * 1000}>
{WHIMSICAL_LOADING_MESSAGES.map((message) => (
<span key={message}>{message}</span>
))}
</RotateThrough>
<AnimatedWave text={WHIMSICAL_SPARKLE} />
</div>
)}
{children}
</div>
{status}
</div>
);
}

View file

@ -402,7 +402,9 @@ describe("PromptBox", () => {
const textarea = screen.getByPlaceholderText("Enter your prompt...");
textarea.scrollIntoView = vi.fn();
act(() => ref.current?.focusAndPrefillExample("AAPLStockPrice"));
act(() =>
ref.current?.focusAndPrefillExample("AAPLStockPrice", "finditparts"),
);
expect((textarea as HTMLTextAreaElement).value).toContain(
'find the "AAPL" stock price',
);
@ -416,7 +418,9 @@ describe("PromptBox", () => {
);
fireEvent.change(textarea, { target: { value: "Keep my agent prompt" } });
act(() => ref.current?.focusAndPrefillExample("contact_us_forms"));
act(() =>
ref.current?.focusAndPrefillExample("contact_us_forms", "finditparts"),
);
expect((textarea as HTMLTextAreaElement).value).toBe(
"Keep my agent prompt",
);
@ -699,7 +703,7 @@ describe("PromptBox", () => {
for (const [key, expected] of cases) {
fireEvent.change(textarea, { target: { value: "" } });
act(() => ref.current?.focusAndPrefillExample(key));
act(() => ref.current?.focusAndPrefillExample(key, "finditparts"));
expect(textarea.value).toContain(expected);
}
expect(mockPost).not.toHaveBeenCalled();

View file

@ -159,7 +159,13 @@ type PromptBoxProps = {
};
type PromptBoxHandle = {
focusAndPrefillExample: (key: ExamplePromptKey) => void;
/** Prefills `key`, or `fallback` when `key` is not a known example (e.g. from a URL). */
focusAndPrefillExample: (
key: string | null,
fallback: ExamplePromptKey,
) => void;
/** Prefills the user's own words; never overwrites a prompt already typed. */
focusAndPrefillPrompt: (text: string) => void;
};
const HANDOFF_TITLE_MAX_LEN = 80;
@ -288,9 +294,11 @@ function PromptBoxImpl(
}, []);
useImperativeHandle(ref, () => ({
focusAndPrefillExample: (key) => {
focusAndPrefillExample: (key, fallback) => {
const selectedExample =
exampleCases.find((example) => example.key === key) ?? exampleCases[0];
exampleCases.find((example) => example.key === key) ??
exampleCases.find((example) => example.key === fallback) ??
exampleCases[0];
if (!prompt.trim()) {
cancelSpeech();
setPrompt(selectedExample.prompt);
@ -299,6 +307,14 @@ function PromptBoxImpl(
textareaRef.current?.scrollIntoView?.({ block: "center" });
textareaRef.current?.focus({ preventScroll: true });
},
focusAndPrefillPrompt: (text) => {
if (!prompt.trim()) {
setPrompt(text);
setExampleAttribution(undefined);
}
textareaRef.current?.scrollIntoView?.({ block: "center" });
textareaRef.current?.focus({ preventScroll: true });
},
}));
const uploadDocumentMutation = useMutation({

View file

@ -5,6 +5,7 @@ import { usePostHog } from "posthog-js/react";
import { StreamStatusPanel } from "@/routes/streaming/StreamDiagnostics";
import type { StreamState } from "@/routes/streaming/streamState";
import { usePageSlots } from "@/store/PageSlots";
import { useStudioBrowserStore } from "@/store/useStudioBrowserStore";
import { HeroRecording } from "./runview/HeroRecording";
@ -31,6 +32,7 @@ export function BrowserTab() {
liveSurface,
} = useBrowserPaneView();
const postHog = usePostHog();
const { firstRunWaitCard: FirstRunWaitCard } = usePageSlots();
const setRunStreamState = useStudioBrowserStore((s) => s.setRunStreamState);
const {
@ -93,6 +95,11 @@ export function BrowserTab() {
detail: "Getting your run's browser ready…",
pending: true,
}}
footer={
FirstRunWaitCard ? (
<FirstRunWaitCard phase="run_provisioning" />
) : undefined
}
/>
) : (
<RunLiveStream
@ -128,6 +135,11 @@ export function BrowserTab() {
"Spinning up the debug browser — this only takes a moment.",
pending: true,
}}
footer={
FirstRunWaitCard ? (
<FirstRunWaitCard phase="debug_browser_warming" />
) : undefined
}
/>
)
) : view === "recording" ? (

View file

@ -8,10 +8,15 @@ export type WorkflowRunMilestoneCardProps = Readonly<{
rerun?: Readonly<{ to: string; state?: unknown }>;
}>;
export type FirstRunWaitCardProps = Readonly<{
phase: "run_provisioning" | "debug_browser_warming";
}>;
export type PageSlots = {
workflowAnalyticsPanel?: React.ComponentType<WorkflowAnalyticsPanelProps>;
workflowRunsFilterControls?: React.ComponentType;
workflowRunMilestoneCard?: React.ComponentType<WorkflowRunMilestoneCardProps>;
firstRunWaitCard?: React.ComponentType<FirstRunWaitCardProps>;
workflowCreatorDirectory?: React.ComponentType<{
children: React.ReactNode;
}>;

View file

@ -48,7 +48,11 @@ function isAuthoritativeConfirmedResponse(
response: OnboardingStateResponse,
): boolean {
const status = response.questionnaire_prompt_result?.status;
return status !== "flag_disabled" && status !== "ineligible";
return (
status !== "flag_disabled" &&
status !== "flag_unavailable" &&
status !== "ineligible"
);
}
function mergeConfirmedResponse(
@ -174,25 +178,26 @@ function OnboardingProvider({ children }: Readonly<Props>) {
},
[credentialGetter, isCurrent],
);
const { data, isLoading } = useQuery<OnboardingStateResponse>({
queryKey,
queryFn: async () => {
const { client, headers } = await requestClient(generation);
const response = await client.get<OnboardingStateResponse>(
"/users/me/onboarding",
{ headers },
);
if (!isCurrent(generation)) throw new CancelledError();
const legacyFields = legacyFieldsToReplay(
legacyWritesRef.current,
legacyWriteVersionRef.current + 1,
);
return Object.keys(legacyFields).length === 0
? response.data
: mergeNewerLegacyFields(response.data, legacyFields);
},
enabled: !!credentialGetter && !!userId,
});
const { data, isLoading, isError, isFetching, refetch } =
useQuery<OnboardingStateResponse>({
queryKey,
queryFn: async () => {
const { client, headers } = await requestClient(generation);
const response = await client.get<OnboardingStateResponse>(
"/users/me/onboarding",
{ headers },
);
if (!isCurrent(generation)) throw new CancelledError();
const legacyFields = legacyFieldsToReplay(
legacyWritesRef.current,
legacyWriteVersionRef.current + 1,
);
return Object.keys(legacyFields).length === 0
? response.data
: mergeNewerLegacyFields(response.data, legacyFields);
},
enabled: !!credentialGetter && !!userId,
});
useEffect(() => {
if (typeof BroadcastChannel === "undefined") return;
@ -454,11 +459,14 @@ function OnboardingProvider({ children }: Readonly<Props>) {
value={{
state: data?.onboarding_state ?? null,
isLoading,
loadFailed: isError && !isFetching,
retryLoad: () => void refetch(),
updateState,
updateStateConfirmed,
isNewUser,
abVariant,
recoveryGuidanceAssignment: data?.recovery_guidance_assignment ?? null,
organizationId: data?.organization_id ?? null,
}}
>
{children}

View file

@ -3,6 +3,11 @@ export const QUESTIONNAIRE_USER_INTENTS_V1 = [
"extract_data",
"monitor_website",
"something_else",
"invoices",
"job_applications",
"healthcare",
"government",
"employee_onboarding",
] as const;
export type QuestionnaireUserIntentV1 =
@ -24,7 +29,10 @@ export type QuestionnaireRoleV1 =
| "product_manager"
| "founder_or_executive"
| "other"
| "prefer_not_to_say";
| "prefer_not_to_say"
| "founder"
| "executive"
| "sales_or_marketing";
export type QuestionnaireCompanyContextV1 =
| "personal_or_individual"
@ -53,7 +61,8 @@ export type QuestionnaireReferralSourceV1 =
| "event_or_community"
| "podcast"
| "other"
| "prefer_not_to_say";
| "prefer_not_to_say"
| "github";
export type QuestionnaireReferralSourceDetailV1 =
| "chatgpt"
@ -67,12 +76,33 @@ export type QuestionnaireReferralSourceDetailV1 =
| "youtube"
| "other_social";
export type QuestionnaireAudienceV1 =
| "just_me"
| "my_team"
| "my_company"
| "my_customers"
| "school"
| "research";
export type QuestionnaireWeeklyRunVolumeV1 =
| "under_10"
| "10_to_100"
| "100_to_1000"
| "over_1000"
| "not_sure";
// The welcome page answers audience and weekly_run_volume in place of company_context and scale_intent.
export type QuestionnaireAnswersV1 = {
role: QuestionnaireRoleV1;
company_context: QuestionnaireCompanyContextV1;
scale_intent: QuestionnaireScaleIntentV1;
company_context?: QuestionnaireCompanyContextV1;
scale_intent?: QuestionnaireScaleIntentV1;
referral_source: QuestionnaireReferralSourceV1;
referral_source_detail?: QuestionnaireReferralSourceDetailV1 | null;
role_other?: string | null;
referral_source_other?: string | null;
audience?: QuestionnaireAudienceV1 | null;
weekly_run_volume?: QuestionnaireWeeklyRunVolumeV1 | null;
organization_name?: string | null;
};
export type QuestionnairePatchV1 =
@ -94,7 +124,13 @@ export type QuestionnairePromptPatchV1 = { version: 1; action: "reserve" };
export type QuestionnairePromptResultV1 =
| { status: "reserved"; prompted_at: string }
| { status: "flag_disabled" | "ineligible" | "already_prompted" };
| {
status:
| "flag_disabled"
| "flag_unavailable"
| "ineligible"
| "already_prompted";
};
export type QuestionnaireStatusV1 = "completed" | "skipped" | "deferred";
@ -130,6 +166,11 @@ export type QuestionnaireStateV1 = {
scale_intent: QuestionnaireScaleIntentV1 | null;
referral_source: QuestionnaireReferralSourceV1 | null;
referral_source_detail?: QuestionnaireReferralSourceDetailV1 | null;
role_other?: string | null;
referral_source_other?: string | null;
audience?: QuestionnaireAudienceV1 | null;
weekly_run_volume?: QuestionnaireWeeklyRunVolumeV1 | null;
organization_name?: string | null;
completed_at: string | null;
skipped_at: string | null;
deferred_at: string | null;
@ -145,6 +186,9 @@ export type OnboardingState = {
first_run_at: string | null;
ab_variant: string | null;
user_intent: string | null;
user_intents?: string[] | null;
user_intent_detail?: string | null;
email_domain_type?: "company" | "webmail" | null;
questionnaire_prompted_at?: string | null;
questionnaire?: QuestionnaireStateV1 | null;
seen_canvas: boolean | null;

View file

@ -14,11 +14,16 @@ type ConfirmedOnboardingStateWriter = (
type OnboardingContextValue = {
state: OnboardingState | null;
isLoading: boolean;
// The state read failed and is not being retried; isLoading is false and state stays null.
loadFailed?: boolean;
retryLoad?: () => void;
updateState: (patch: LegacyOnboardingStatePatch) => void;
updateStateConfirmed: ConfirmedOnboardingStateWriter;
isNewUser: boolean;
abVariant: string | null;
recoveryGuidanceAssignment: RecoveryGuidanceAssignment | null;
// The Skyvern organization the onboarding state was read for.
organizationId?: string | null;
};
const OnboardingContext = createContext<OnboardingContextValue | null>(null);

View file

@ -25,9 +25,6 @@ export const WORKFLOW_TAGGING_FLAG = "WORKFLOW_TAGGING";
// Not enrolled reads as disabled, so the default stays the flat folders/list.
export const WORKFLOWS_DIRECTORY_TREE_FLAG = "WORKFLOWS_DIRECTORY_TREE";
// Gates the optional, server-confirmed onboarding details step.
export const ONBOARDING_QUESTIONNAIRE_FLAG = "onboarding_questionnaire_v1";
// Gates the login-block fallback-credential editor. Off ⇒ the fallback config is hidden, because
// automatic retries only run for orgs in the CREDENTIAL_FALLBACK_RETRY rollout (backend gate), so
// showing the editor to other orgs would promise a retry that never fires. Server-evaluated via

View file

@ -136,7 +136,7 @@ describe("OnboardingTelemetry", () => {
{
questionnaire_version: 1,
"$feature/onboarding_questionnaire_v1": true,
surface: "get_started_modal",
surface: "welcome_page",
primary_intent: null,
prompt_reason: "initial",
organization_id: "org_123",
@ -149,12 +149,15 @@ describe("OnboardingTelemetry", () => {
{
questionnaire_version: 1,
"$feature/onboarding_questionnaire_v1": true,
surface: "get_started_modal",
surface: "welcome_page",
response_id: "response",
revision: 1,
primary_intent: "fill_forms",
...answers,
referral_source_detail: null,
audience: null,
weekly_run_volume: null,
organization_name_present: false,
organization_id: "org_123",
step_id: "onboarding_questionnaire_completed:v1",
project_owner_reported: false,
@ -169,7 +172,7 @@ describe("OnboardingTelemetry", () => {
{
questionnaire_version: 1,
"$feature/onboarding_questionnaire_v1": true,
surface: "get_started_modal",
surface: "welcome_page",
response_id: "response",
revision: 2,
disposition: "skip",
@ -184,13 +187,16 @@ describe("OnboardingTelemetry", () => {
{
questionnaire_version: 1,
"$feature/onboarding_questionnaire_v1": true,
surface: "get_started_modal",
surface: "welcome_page",
response_id: "response",
revision: 3,
primary_intent: "fill_forms",
previous_status: "deferred",
...answers,
referral_source_detail: null,
audience: null,
weekly_run_volume: null,
organization_name_present: false,
organization_id: "org_123",
step_id: "onboarding_questionnaire_updated:v1",
project_owner_reported: false,

View file

@ -16,6 +16,8 @@ type Surface =
type TourLayer = 1 | 2;
const QUESTIONNAIRE_SURFACE = "welcome_page";
type QuestionnaireShownInput = {
primaryIntent: QuestionnaireUserIntentV1 | null;
promptReason: "initial" | "resume";
@ -78,10 +80,6 @@ function capture(event: string, properties: Properties): boolean {
// -- Flow events --
function flowStarted(surface: Surface): void {
capture("onboarding.flow_started", { surface });
}
function flowCompleted(surface: Surface): void {
capture("onboarding.flow_completed", { surface });
}
@ -126,46 +124,11 @@ function tourDismissed(surface: Surface, lastStep: string): void {
capture("onboarding.tour_dismissed", { surface, last_step: lastStep });
}
// -- Modal events --
function modalOpened(surface: Surface): void {
capture("onboarding.modal_opened", { surface });
}
function modalTemplateSelected(
surface: Surface,
templateId: string,
intent: string,
): void {
capture("onboarding.modal_template_selected", {
surface,
template_id: templateId,
intent,
});
}
function modalCopilotClicked(
surface: Surface,
intent: string,
promptText: string,
): void {
capture("onboarding.modal_copilot_clicked", {
surface,
intent,
// capture only length, never the raw prompt - it can contain customer URLs / PII
prompt_length: promptText.length,
});
}
function modalSkipped(surface: Surface): void {
capture("onboarding.modal_skipped", { surface });
}
function questionnaireResponseProperties(input: QuestionnaireResponseInput) {
return {
questionnaire_version: 1,
"$feature/onboarding_questionnaire_v1": true,
surface: "get_started_modal",
surface: QUESTIONNAIRE_SURFACE,
response_id: input.responseId,
revision: input.revision,
...(input.organizationId ? { organization_id: input.organizationId } : {}),
@ -175,10 +138,14 @@ function questionnaireResponseProperties(input: QuestionnaireResponseInput) {
function questionnaireAnswerProperties(answers: QuestionnaireAnswersV1) {
return {
role: answers.role,
company_context: answers.company_context,
scale_intent: answers.scale_intent,
company_context: answers.company_context ?? null,
scale_intent: answers.scale_intent ?? null,
referral_source: answers.referral_source,
referral_source_detail: answers.referral_source_detail ?? null,
audience: answers.audience ?? null,
weekly_run_volume: answers.weekly_run_volume ?? null,
// Free text never leaves the app; only whether it was given.
organization_name_present: Boolean(answers.organization_name),
};
}
@ -186,7 +153,7 @@ function questionnaireShown(input: QuestionnaireShownInput): boolean {
return capture("onboarding_questionnaire_shown", {
questionnaire_version: 1,
"$feature/onboarding_questionnaire_v1": true,
surface: "get_started_modal",
surface: QUESTIONNAIRE_SURFACE,
primary_intent: input.primaryIntent,
prompt_reason: input.promptReason,
...(input.organizationId ? { organization_id: input.organizationId } : {}),
@ -283,10 +250,6 @@ function error(surface: Surface): void {
capture("onboarding.error", { surface });
}
function modalRenderError(surface: Surface): void {
capture("onboarding.modal_render_error", { surface });
}
function tourError(surface: Surface): void {
capture("onboarding.tour_error", { surface });
}
@ -302,7 +265,6 @@ function hintDismissed(surface: Surface, hintId: string): void {
}
export const OnboardingTelemetry = {
flowStarted,
flowCompleted,
dropOff,
tourStarted,
@ -311,10 +273,6 @@ export const OnboardingTelemetry = {
tourSkipped,
stepCompleted,
tourDismissed,
modalOpened,
modalTemplateSelected,
modalCopilotClicked,
modalSkipped,
questionnaireShown,
questionnaireCompleted,
questionnaireSkipped,
@ -328,7 +286,6 @@ export const OnboardingTelemetry = {
abVariantAssigned,
registerVariant,
error,
modalRenderError,
tourError,
hintShown,
hintDismissed,

View file

@ -2979,6 +2979,15 @@ class AgentFunction:
"""Fired after a workflow is saved. Overrides must be best-effort and never raise."""
return
async def on_workflow_updated_by_user(
self,
organization_id: str,
user_id: str | None,
workflow: Workflow,
) -> None:
"""Fired after the update-agent routes save a new version. Overrides must be best-effort and never raise."""
return
async def on_workflow_run_completed(
self,
organization_id: str,

View file

@ -1382,7 +1382,7 @@ async def update_workflow_legacy(
try:
workflow_create_request = WorkflowCreateYAMLRequest.model_validate(workflow_yaml)
return await app.WORKFLOW_SERVICE.create_workflow_from_request(
workflow = await app.WORKFLOW_SERVICE.create_workflow_from_request(
organization=current_org,
request=workflow_create_request,
workflow_permanent_id=workflow_id,
@ -1407,6 +1407,8 @@ async def update_workflow_legacy(
organization_id=current_org.organization_id,
)
raise FailedToUpdateWorkflow(workflow_id, f"<{type(e).__name__}: {str(e)}>")
await app.AGENT_FUNCTION.on_workflow_updated_by_user(current_org.organization_id, user_id, workflow)
return workflow
@base_router.post(
@ -1468,7 +1470,7 @@ async def update_workflow(
status_code=422,
detail="Invalid workflow definition. Workflow should be provided in either yaml or json format.",
)
return await app.WORKFLOW_SERVICE.create_workflow_from_request(
workflow = await app.WORKFLOW_SERVICE.create_workflow_from_request(
organization=current_org,
request=workflow_definition,
workflow_permanent_id=workflow_id,
@ -1490,6 +1492,8 @@ async def update_workflow(
workflow_permanent_id=workflow_id,
)
raise FailedToUpdateWorkflow(workflow_id, f"<{type(e).__name__}: {str(e)}>")
await app.AGENT_FUNCTION.on_workflow_updated_by_user(current_org.organization_id, user_id, workflow)
return workflow
@legacy_base_router.delete(

View file

@ -1426,6 +1426,10 @@ class ReusedSessionBelowLifetimeFloor(Exception):
self.shortfall = shortfall
def workflow_definitions_differ(previous: WorkflowDefinition, current: WorkflowDefinition) -> bool:
return _get_workflow_definition_core_data(previous) != _get_workflow_definition_core_data(current)
def _get_workflow_definition_core_data(
workflow_definition: WorkflowDefinition, *, unset_engine_is_v1: bool = True
) -> dict[str, Any]:

View file

@ -240,7 +240,9 @@ class TestWorkflowRoutesThreadUser:
data = WorkflowRequest(json_definition=_yaml_request())
with patch("skyvern.forge.sdk.routes.agent_protocol.app") as mock_app:
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=MagicMock())
saved = MagicMock()
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=saved)
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user = AsyncMock()
await update_workflow(
data=data,
workflow_id="wpid_1",
@ -248,6 +250,7 @@ class TestWorkflowRoutesThreadUser:
user_id="u_456",
)
kwargs = mock_app.WORKFLOW_SERVICE.create_workflow_from_request.await_args.kwargs
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user.assert_awaited_once_with("o_123", "u_456", saved)
assert kwargs.get("created_by") == "u_456"
assert kwargs.get("edited_by") == "u_456"
@ -288,7 +291,9 @@ class TestWorkflowRoutesThreadUser:
)
with patch("skyvern.forge.sdk.routes.agent_protocol.app") as mock_app:
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=MagicMock())
saved = MagicMock()
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=saved)
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user = AsyncMock()
await update_workflow_legacy(
request=raw_request,
workflow_id="wpid_1",
@ -296,6 +301,7 @@ class TestWorkflowRoutesThreadUser:
user_id="u_456",
)
kwargs = mock_app.WORKFLOW_SERVICE.create_workflow_from_request.await_args.kwargs
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user.assert_awaited_once_with("o_123", "u_456", saved)
assert kwargs.get("created_by") == "u_456"
assert kwargs.get("edited_by") == "u_456"

View file

@ -89,6 +89,7 @@ def test_only_the_detail_get_carries_the_effective_default_engine(
mock_app.WORKFLOW_SERVICE.create_workflow_from_request = AsyncMock(return_value=_stored_workflow())
mock_app.WORKFLOW_SERVICE.get_workflows_by_organization_id = AsyncMock(return_value=[_stored_workflow()])
mock_app.DATABASE.workflows.is_workflow_copilot_authored = AsyncMock(return_value=False)
mock_app.AGENT_FUNCTION.on_workflow_updated_by_user = AsyncMock()
with (
patch("skyvern.forge.sdk.routes.agent_protocol.app", mock_app),
patch("skyvern.forge.sdk.routes.agent_protocol.effective_default_engine", AsyncMock(return_value=computed)),