merge: resolve conflict in feature flags

This commit is contained in:
Zamil Majdy
2026-04-03 13:17:42 +02:00
72 changed files with 1806 additions and 1922 deletions

View File

@@ -0,0 +1,61 @@
from unittest.mock import AsyncMock
import fastapi
import fastapi.testclient
import pytest
from backend.api.features.v1 import v1_router
app = fastapi.FastAPI()
app.include_router(v1_router)
client = fastapi.testclient.TestClient(app)
@pytest.fixture(autouse=True)
def setup_app_auth(mock_jwt_user):
from autogpt_libs.auth.jwt_utils import get_jwt_payload
app.dependency_overrides[get_jwt_payload] = mock_jwt_user["get_jwt_payload"]
yield
app.dependency_overrides.clear()
def test_onboarding_profile_success(mocker):
mock_extract = mocker.patch(
"backend.api.features.v1.extract_business_understanding",
new_callable=AsyncMock,
)
mock_upsert = mocker.patch(
"backend.api.features.v1.upsert_business_understanding",
new_callable=AsyncMock,
)
from backend.data.understanding import BusinessUnderstandingInput
mock_extract.return_value = BusinessUnderstandingInput.model_construct(
user_name="John",
user_role="Founder/CEO",
pain_points=["Finding leads"],
suggested_prompts={"Learn": ["How do I automate lead gen?"]},
)
mock_upsert.return_value = AsyncMock()
response = client.post(
"/onboarding/profile",
json={
"user_name": "John",
"user_role": "Founder/CEO",
"pain_points": ["Finding leads", "Email & outreach"],
},
)
assert response.status_code == 200
mock_extract.assert_awaited_once()
mock_upsert.assert_awaited_once()
def test_onboarding_profile_missing_fields():
response = client.post(
"/onboarding/profile",
json={"user_name": "John"},
)
assert response.status_code == 422

View File

@@ -63,12 +63,17 @@ from backend.data.onboarding import (
UserOnboardingUpdate,
complete_onboarding_step,
complete_re_run_agent,
format_onboarding_for_extraction,
get_recommended_agents,
get_user_onboarding,
onboarding_enabled,
reset_user_onboarding,
update_user_onboarding,
)
from backend.data.tally import extract_business_understanding
from backend.data.understanding import (
BusinessUnderstandingInput,
upsert_business_understanding,
)
from backend.data.user import (
get_or_create_user,
get_user_by_id,
@@ -282,35 +287,33 @@ async def get_onboarding_agents(
return await get_recommended_agents(user_id)
class OnboardingStatusResponse(pydantic.BaseModel):
"""Response for onboarding status check."""
class OnboardingProfileRequest(pydantic.BaseModel):
"""Request body for onboarding profile submission."""
is_onboarding_enabled: bool
is_chat_enabled: bool
user_name: str = pydantic.Field(min_length=1, max_length=100)
user_role: str = pydantic.Field(min_length=1, max_length=100)
pain_points: list[str] = pydantic.Field(default_factory=list, max_length=20)
class OnboardingStatusResponse(pydantic.BaseModel):
"""Response for onboarding completion check."""
is_completed: bool
@v1_router.get(
"/onboarding/enabled",
summary="Is onboarding enabled",
"/onboarding/completed",
summary="Check if onboarding is completed",
tags=["onboarding", "public"],
response_model=OnboardingStatusResponse,
dependencies=[Security(requires_user)],
)
async def is_onboarding_enabled(
async def is_onboarding_completed(
user_id: Annotated[str, Security(get_user_id)],
) -> OnboardingStatusResponse:
# Check if chat is enabled for user
is_chat_enabled = await is_feature_enabled(Flag.CHAT, user_id, False)
# If chat is enabled, skip legacy onboarding
if is_chat_enabled:
return OnboardingStatusResponse(
is_onboarding_enabled=False,
is_chat_enabled=True,
)
user_onboarding = await get_user_onboarding(user_id)
return OnboardingStatusResponse(
is_onboarding_enabled=await onboarding_enabled(),
is_chat_enabled=False,
is_completed=OnboardingStep.VISIT_COPILOT in user_onboarding.completedSteps,
)
@@ -325,6 +328,38 @@ async def reset_onboarding(user_id: Annotated[str, Security(get_user_id)]):
return await reset_user_onboarding(user_id)
@v1_router.post(
"/onboarding/profile",
summary="Submit onboarding profile",
tags=["onboarding"],
dependencies=[Security(requires_user)],
)
async def submit_onboarding_profile(
data: OnboardingProfileRequest,
user_id: Annotated[str, Security(get_user_id)],
):
formatted = format_onboarding_for_extraction(
user_name=data.user_name,
user_role=data.user_role,
pain_points=data.pain_points,
)
try:
understanding_input = await extract_business_understanding(formatted)
except Exception:
understanding_input = BusinessUnderstandingInput.model_construct()
# Ensure the direct fields are set even if LLM missed them
understanding_input.user_name = data.user_name
understanding_input.user_role = data.user_role
if not understanding_input.pain_points:
understanding_input.pain_points = data.pain_points
await upsert_business_understanding(user_id, understanding_input)
return {"status": "ok"}
########################################################
##################### Blocks ###########################
########################################################

View File

@@ -730,11 +730,7 @@ async def stream_chat_completion_baseline(
# Without this, mode-switching after a failed turn would lose
# the partial assistant response from the transcript.
if _stream_error and state.assistant_text:
_last_is_asst = (
transcript_builder.entry_count > 0
and transcript_builder._entries[-1].type == "assistant"
)
if not _last_is_asst:
if transcript_builder.last_entry_type != "assistant":
transcript_builder.append_assistant(
content_blocks=[{"type": "text", "text": state.assistant_text}],
model=config.fast_model,

View File

@@ -233,3 +233,8 @@ class TranscriptBuilder:
def is_empty(self) -> bool:
"""Whether this builder has any entries."""
return len(self._entries) == 0
@property
def last_entry_type(self) -> str | None:
"""Type of the last entry, or None if empty."""
return self._entries[-1].type if self._entries else None

View File

@@ -436,6 +436,28 @@ async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]:
return [StoreAgentDetails.from_db(agent) for agent in recommended_agents]
def format_onboarding_for_extraction(
user_name: str,
user_role: str,
pain_points: list[str],
) -> str:
"""Format onboarding wizard answers as Q&A text for LLM extraction."""
def normalize(value: str) -> str:
return " ".join(value.strip().split())
name = normalize(user_name)
role = normalize(user_role)
points = [normalize(p) for p in pain_points if normalize(p)]
lines = [
f"Q: What is your name?\nA: {name}",
f"Q: What best describes your role?\nA: {role}",
f"Q: What tasks are eating your time?\nA: {', '.join(points)}",
]
return "\n\n".join(lines)
@cached(maxsize=1, ttl_seconds=300) # Cache for 5 minutes since this rarely changes
async def onboarding_enabled() -> bool:
"""

View File

@@ -0,0 +1,27 @@
from backend.data.onboarding import format_onboarding_for_extraction
def test_format_onboarding_for_extraction_basic():
result = format_onboarding_for_extraction(
user_name="John",
user_role="Founder/CEO",
pain_points=["Finding leads", "Email & outreach"],
)
assert "Q: What is your name?" in result
assert "A: John" in result
assert "Q: What best describes your role?" in result
assert "A: Founder/CEO" in result
assert "Q: What tasks are eating your time?" in result
assert "Finding leads" in result
assert "Email & outreach" in result
def test_format_onboarding_for_extraction_with_other():
result = format_onboarding_for_extraction(
user_name="Jane",
user_role="Data Scientist",
pain_points=["Research", "Building dashboards"],
)
assert "A: Jane" in result
assert "A: Data Scientist" in result
assert "Research, Building dashboards" in result

View File

@@ -122,6 +122,7 @@
"tailwind-merge": "2.6.0",
"tailwind-scrollbar": "3.1.0",
"tailwindcss-animate": "1.0.7",
"twemoji": "14.0.2",
"use-stick-to-bottom": "1.1.2",
"uuid": "11.1.0",
"vaul": "1.1.2",
@@ -150,6 +151,7 @@
"@types/react-modal": "3.16.3",
"@types/react-window": "2.0.0",
"@vitejs/plugin-react": "5.1.2",
"@vitest/coverage-v8": "4.0.17",
"axe-playwright": "2.2.2",
"chromatic": "13.3.3",
"concurrently": "9.2.1",
@@ -171,7 +173,6 @@
"tailwindcss": "3.4.17",
"typescript": "5.9.3",
"vite-tsconfig-paths": "6.0.4",
"@vitest/coverage-v8": "4.0.17",
"vitest": "4.0.17"
},
"msw": {

View File

@@ -288,6 +288,9 @@ importers:
tailwindcss-animate:
specifier: 1.0.7
version: 1.0.7(tailwindcss@3.4.17)
twemoji:
specifier: 14.0.2
version: 14.0.2
use-stick-to-bottom:
specifier: 1.1.2
version: 1.1.2(react@18.3.1)
@@ -5498,6 +5501,10 @@ packages:
resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
engines: {node: '>=14.14'}
fs-extra@8.1.0:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'}
fs-monkey@1.1.0:
resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==}
@@ -6149,6 +6156,12 @@ packages:
jsonc-parser@2.2.1:
resolution: {integrity: sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==}
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
jsonfile@5.0.0:
resolution: {integrity: sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==}
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@@ -8218,6 +8231,12 @@ packages:
tty-browserify@0.0.1:
resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==}
twemoji-parser@14.0.0:
resolution: {integrity: sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==}
twemoji@14.0.2:
resolution: {integrity: sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -8342,6 +8361,10 @@ packages:
unist-util-visit@5.0.0:
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -14595,6 +14618,12 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs-extra@8.1.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 4.0.0
universalify: 0.1.2
fs-monkey@1.1.0: {}
fs.realpath@1.0.0: {}
@@ -15333,6 +15362,16 @@ snapshots:
jsonc-parser@2.2.1: {}
jsonfile@4.0.0:
optionalDependencies:
graceful-fs: 4.2.11
jsonfile@5.0.0:
dependencies:
universalify: 0.1.2
optionalDependencies:
graceful-fs: 4.2.11
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1
@@ -17893,6 +17932,15 @@ snapshots:
tty-browserify@0.0.1: {}
twemoji-parser@14.0.0: {}
twemoji@14.0.2:
dependencies:
fs-extra: 8.1.0
jsonfile: 5.0.0
twemoji-parser: 14.0.0
universalify: 0.1.2
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -18030,6 +18078,8 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
universalify@0.1.2: {}
universalify@2.0.1: {}
unplugin@1.0.1:

View File

@@ -1,33 +0,0 @@
"use client";
import { OnboardingText } from "../components/OnboardingText";
import OnboardingButton from "../components/OnboardingButton";
import Image from "next/image";
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
export default function Page() {
useOnboarding(1);
return (
<>
<Image
src="/gpt_dark_RGB.svg"
alt="GPT Dark Logo"
className="-mb-2"
width={300}
height={300}
/>
<OnboardingText className="mb-3" variant="header" center>
Welcome to AutoGPT
</OnboardingText>
<OnboardingText className="mb-12" center>
Think of AutoGPT as your digital teammate, working intelligently to
<br />
complete tasks based on your directions. Let&apos;s learn a bit about
you to
<br />
tailor your experience.
</OnboardingText>
<OnboardingButton href="/onboarding/2-reason">Continue</OnboardingButton>
</>
);
}

View File

@@ -1,69 +0,0 @@
"use client";
import OnboardingButton from "../components/OnboardingButton";
import {
OnboardingFooter,
OnboardingHeader,
OnboardingStep,
} from "../components/OnboardingStep";
import { OnboardingText } from "../components/OnboardingText";
import OnboardingList from "../components/OnboardingList";
import { isEmptyOrWhitespace } from "@/lib/utils";
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
const reasons = [
{
label: "Content & Marketing",
text: "Content creation, social media management, blogging, creative writing",
id: "content_marketing",
},
{
label: "Business & Workflow Automation",
text: "Operations, task management, productivity",
id: "business_workflow_automation",
},
{
label: "Data & Research",
text: "Data analysis, insights, research, financial operation",
id: "data_research",
},
{
label: "AI & Innovation",
text: "AI experimentation, automation testing, advanced AI applications",
id: "ai_innovation",
},
{
label: "Personal productivity",
text: "Automating daily tasks, organizing information, personal workflows",
id: "personal_productivity",
},
];
export default function Page() {
const { state, updateState } = useOnboarding(2, "WELCOME");
return (
<OnboardingStep>
<OnboardingHeader backHref={"/onboarding/1-welcome"}>
<OnboardingText className="mt-4" variant="header" center>
What&apos;s your main reason for using AutoGPT?
</OnboardingText>
<OnboardingText className="mt-1" center>
Select the option that best matches your needs
</OnboardingText>
</OnboardingHeader>
<OnboardingList
elements={reasons}
selectedId={state?.usageReason}
onSelect={(usageReason) => updateState({ usageReason })}
/>
<OnboardingFooter>
<OnboardingButton
href="/onboarding/3-services"
disabled={isEmptyOrWhitespace(state?.usageReason)}
>
Next
</OnboardingButton>
</OnboardingFooter>
</OnboardingStep>
);
}

View File

@@ -1,171 +0,0 @@
"use client";
import OnboardingButton from "../components/OnboardingButton";
import {
OnboardingStep,
OnboardingHeader,
OnboardingFooter,
} from "../components/OnboardingStep";
import { OnboardingText } from "../components/OnboardingText";
import { OnboardingGrid } from "../components/OnboardingGrid";
import { useCallback } from "react";
import OnboardingInput from "../components/OnboardingInput";
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
const services = [
{
name: "D-ID",
text: "Generate AI-powered avatars and videos for dynamic content creation.",
icon: "/integrations/d-id.png",
},
{
name: "Discord",
text: "A chat platform for communities and teams, supporting text, voice, and video.",
icon: "/integrations/discord.png",
},
{
name: "GitHub",
text: "AutoGPT can track issues, manage repos, and automate workflows with GitHub.",
icon: "/integrations/github.png",
},
{
name: "Google Workspace",
text: "Automate emails, calendar events, and document management in AutoGPT with Google Workspace.",
icon: "/integrations/google.png",
},
{
name: "Google Maps",
text: "Fetch locations, directions, and real-time geodata for navigation.",
icon: "/integrations/maps.png",
},
{
name: "HubSpot",
text: "Manage customer relationships, automate marketing, and track sales.",
icon: "/integrations/hubspot.png",
},
{
name: "Linear",
text: "Streamline project management and issue tracking with a modern workflow.",
icon: "/integrations/linear.png",
},
{
name: "Medium",
text: "Publish and explore insightful content with a powerful writing platform.",
icon: "/integrations/medium.png",
},
{
name: "Mem0",
text: "AI-powered memory assistant for smarter data organization and recall.",
icon: "/integrations/mem0.png",
},
{
name: "Notion",
text: "Organize work, notes, and databases in an all-in-one workspace.",
icon: "/integrations/notion.png",
},
{
name: "NVIDIA",
text: "Accelerate AI, graphics, and computing with cutting-edge technology.",
icon: "/integrations/nvidia.jpg",
},
{
name: "OpenWeatherMap",
text: "Access real-time weather data and forecasts worldwide.",
icon: "/integrations/openweathermap.png",
},
{
name: "Pinecone",
text: "Store and search vector data for AI-driven applications.",
icon: "/integrations/pinecone.png",
},
{
name: "Reddit",
text: "Explore trending discussions and engage with online communities.",
icon: "/integrations/reddit.png",
},
{
name: "Slant3D",
text: "Automate and optimize 3D printing workflows with AI.",
icon: "/integrations/slant3d.jpeg",
},
{
name: "SMTP",
text: "Send and manage emails with secure and reliable delivery.",
icon: "/integrations/smtp.png",
},
{
name: "Todoist",
text: "Organize tasks and projects with a simple, intuitive to-do list.",
icon: "/integrations/todoist.png",
},
{
name: "Twitter (X)",
text: "Stay connected and share updates on the world's biggest conversation platform.",
icon: "/integrations/x.png",
},
{
name: "Unreal Speech",
text: "Generate natural-sounding AI voices for speech applications.",
icon: "/integrations/unreal-speech.png",
},
];
export default function Page() {
const { state, updateState } = useOnboarding(3, "USAGE_REASON");
const switchIntegration = useCallback(
(name: string) => {
if (!state) {
return;
}
const integrations = state.integrations.includes(name)
? state.integrations.filter((i) => i !== name)
: [...state.integrations, name];
updateState({ integrations });
},
[state, updateState],
);
return (
<OnboardingStep>
<OnboardingHeader backHref={"/onboarding/2-reason"}>
<OnboardingText className="mt-4" variant="header" center>
What platforms or services would you like AutoGPT to work with?
</OnboardingText>
<OnboardingText className="mt-1" center>
You can select more than one option
</OnboardingText>
</OnboardingHeader>
<div className="w-fit">
<OnboardingText className="my-4" variant="subheader">
Available integrations
</OnboardingText>
<OnboardingGrid
elements={services}
selected={state?.integrations}
onSelect={switchIntegration}
/>
<OnboardingText className="mt-12" variant="subheader">
Help us grow our integrations
</OnboardingText>
<OnboardingText className="my-4">
Let us know which partnerships you&apos;d like to see next
</OnboardingText>
<OnboardingInput
className="mb-4"
placeholder="Others (please specify)"
value={state?.otherIntegrations || ""}
onChange={(otherIntegrations) => updateState({ otherIntegrations })}
/>
</div>
<OnboardingFooter>
<OnboardingButton className="mb-2" href="/onboarding/4-agent">
Next
</OnboardingButton>
</OnboardingFooter>
</OnboardingStep>
);
}

View File

@@ -1,104 +0,0 @@
"use client";
import { isEmptyOrWhitespace } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
import OnboardingAgentCard from "../components/OnboardingAgentCard";
import OnboardingButton from "../components/OnboardingButton";
import {
OnboardingFooter,
OnboardingHeader,
OnboardingStep,
} from "../components/OnboardingStep";
import { OnboardingText } from "../components/OnboardingText";
import { getV1RecommendedOnboardingAgents } from "@/app/api/__generated__/endpoints/onboarding/onboarding";
import { resolveResponse } from "@/app/api/helpers";
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
export default function Page() {
const { state, updateState, completeStep } = useOnboarding(4, "INTEGRATIONS");
const [agents, setAgents] = useState<StoreAgentDetails[]>([]);
const router = useRouter();
useEffect(() => {
resolveResponse(getV1RecommendedOnboardingAgents()).then((agents) => {
if (agents.length < 2) {
completeStep("CONGRATS");
router.replace("/");
}
setAgents(agents);
});
}, []);
useEffect(() => {
// Deselect agent if it's not in the list of agents
if (
state?.selectedStoreListingVersionId &&
agents.length > 0 &&
!agents.some(
(agent) =>
agent.store_listing_version_id ===
state.selectedStoreListingVersionId,
)
) {
updateState({
selectedStoreListingVersionId: null,
agentInput: {},
});
}
}, [state?.selectedStoreListingVersionId, updateState, agents]);
return (
<OnboardingStep>
<OnboardingHeader backHref={"/onboarding/3-services"}>
<OnboardingText className="mt-4" variant="header" center>
Choose an agent
</OnboardingText>
<OnboardingText className="mt-1" center>
We think these agents are a good match for you based on your answers
</OnboardingText>
</OnboardingHeader>
<div className="my-12 flex items-center justify-between gap-5">
<OnboardingAgentCard
agent={agents[0]}
selected={
agents[0] !== undefined
? state?.selectedStoreListingVersionId ==
agents[0]?.store_listing_version_id
: false
}
onClick={() =>
updateState({
selectedStoreListingVersionId: agents[0].store_listing_version_id,
agentInput: {},
})
}
/>
<OnboardingAgentCard
agent={agents[1]}
selected={
agents[1] !== undefined
? state?.selectedStoreListingVersionId ==
agents[1]?.store_listing_version_id
: false
}
onClick={() =>
updateState({
selectedStoreListingVersionId: agents[1].store_listing_version_id,
})
}
/>
</div>
<OnboardingFooter>
<OnboardingButton
href="/onboarding/5-run"
disabled={isEmptyOrWhitespace(state?.selectedStoreListingVersionId)}
>
Next
</OnboardingButton>
</OnboardingFooter>
</OnboardingStep>
);
}

View File

@@ -1,62 +0,0 @@
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
import { useState } from "react";
import { getSchemaDefaultCredentials } from "../../helpers";
import { areAllCredentialsSet, getCredentialFields } from "./helpers";
type Credential = CredentialsMetaInput | undefined;
type Credentials = Record<string, Credential>;
type Props = {
agent: GraphModel | null;
siblingInputs?: Record<string, any>;
onCredentialsChange: (
credentials: Record<string, CredentialsMetaInput>,
) => void;
onValidationChange: (isValid: boolean) => void;
onLoadingChange: (isLoading: boolean) => void;
};
export function AgentOnboardingCredentials(props: Props) {
const [inputCredentials, setInputCredentials] = useState<Credentials>({});
const fields = getCredentialFields(props.agent);
const required = Object.keys(fields || {}).length > 0;
if (!required) return null;
function handleSelectCredentials(key: string, value: Credential) {
const updated = { ...inputCredentials, [key]: value };
setInputCredentials(updated);
const sanitized: Record<string, CredentialsMetaInput> = {};
for (const [k, v] of Object.entries(updated)) {
if (v) sanitized[k] = v;
}
props.onCredentialsChange(sanitized);
const isValid = !required || areAllCredentialsSet(fields, updated);
props.onValidationChange(isValid);
}
return (
<>
{Object.entries(fields).map(([key, inputSubSchema]) => (
<div key={key} className="mt-4">
<CredentialsInput
schema={inputSubSchema}
selectedCredentials={
inputCredentials[key] ??
getSchemaDefaultCredentials(inputSubSchema)
}
onSelectCredentials={(value) => handleSelectCredentials(key, value)}
siblingInputs={props.siblingInputs}
onLoaded={(loaded) => props.onLoadingChange(!loaded)}
/>
</div>
))}
</>
);
}

View File

@@ -1,32 +0,0 @@
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
import { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api/types";
export function getCredentialFields(
agent: GraphModel | null,
): AgentCredentialsFields {
if (!agent) return {};
const hasNoInputs =
!agent.credentials_input_schema ||
typeof agent.credentials_input_schema !== "object" ||
!("properties" in agent.credentials_input_schema) ||
!agent.credentials_input_schema.properties;
if (hasNoInputs) return {};
return agent.credentials_input_schema.properties as AgentCredentialsFields;
}
export type AgentCredentialsFields = Record<
string,
BlockIOCredentialsSubSchema
>;
export function areAllCredentialsSet(
fields: AgentCredentialsFields,
inputs: Record<string, CredentialsMetaInput | undefined>,
) {
const required = Object.keys(fields || {});
return required.every((k) => Boolean(inputs[k]));
}

View File

@@ -1,45 +0,0 @@
import { cn } from "@/lib/utils";
import { OnboardingText } from "../../components/OnboardingText";
type RunAgentHintProps = {
handleNewRun: () => void;
};
export function RunAgentHint(props: RunAgentHintProps) {
return (
<div className="ml-[104px] w-[481px] pl-5">
<div className="flex flex-col">
<OnboardingText variant="header">Run your first agent</OnboardingText>
<span className="mt-9 text-base font-normal leading-normal text-zinc-600">
A &apos;run&apos; is when your agent starts working on a task
</span>
<span className="mt-4 text-base font-normal leading-normal text-zinc-600">
Click on <b>New Run</b> below to try it out
</span>
<div
onClick={props.handleNewRun}
className={cn(
"mt-16 flex h-[68px] w-[330px] items-center justify-center rounded-xl border-2 border-violet-700 bg-neutral-50",
"cursor-pointer transition-all duration-200 ease-in-out hover:bg-violet-50",
)}
>
<svg
width="38"
height="38"
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<g stroke="#6d28d9" strokeWidth="1.2" strokeLinecap="round">
<line x1="16" y1="8" x2="16" y2="24" />
<line x1="8" y1="16" x2="24" y2="16" />
</g>
</svg>
<span className="ml-3 font-sans text-[19px] font-medium leading-normal text-violet-700">
New run
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,52 +0,0 @@
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
import StarRating from "../../components/StarRating";
import SmartImage from "@/components/__legacy__/SmartImage";
type Props = {
storeAgent: StoreAgentDetails | null;
};
export function SelectedAgentCard(props: Props) {
return (
<div className="fixed left-1/4 top-1/2 w-[481px] -translate-x-1/2 -translate-y-1/2">
<div className="h-[156px] w-[481px] rounded-xl bg-white px-6 pb-5 pt-4">
<span className="font-sans text-xs font-medium tracking-wide text-zinc-500">
SELECTED AGENT
</span>
{props.storeAgent ? (
<div className="mt-4 flex h-20 rounded-lg bg-violet-50 p-3">
{/* Left image */}
<SmartImage
src={props.storeAgent.agent_image[0]}
alt="Agent cover"
className="w-[350px] rounded-lg"
/>
{/* Right content */}
<div className="ml-3 flex flex-1 flex-col">
<div className="mb-2 flex flex-col items-start">
<span className="data-sentry-unmask w-[292px] truncate font-sans text-[14px] font-medium leading-tight text-zinc-800">
{props.storeAgent.agent_name}
</span>
<span className="data-sentry-unmask font-norma w-[292px] truncate font-sans text-xs text-zinc-600">
by {props.storeAgent.creator}
</span>
</div>
<div className="flex w-[292px] items-center justify-between">
<span className="truncate font-sans text-xs font-normal leading-tight text-zinc-600">
{props.storeAgent.runs.toLocaleString("en-US")} runs
</span>
<StarRating
className="font-sans text-xs font-normal leading-tight text-zinc-600"
starSize={12}
rating={props.storeAgent.rating || 0}
/>
</div>
</div>
</div>
) : (
<div className="mt-4 flex h-20 animate-pulse rounded-lg bg-gray-300 p-2" />
)}
</div>
</div>
);
}

View File

@@ -1,57 +0,0 @@
import type {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import type { InputValues } from "./types";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
export function computeInitialAgentInputs(
agent: GraphModel | null,
existingInputs?: InputValues | null,
): InputValues {
const properties = agent?.input_schema?.properties || {};
const result: InputValues = {};
Object.entries(properties).forEach(([key, subSchema]) => {
if (
existingInputs &&
key in existingInputs &&
existingInputs[key] != null
) {
result[key] = existingInputs[key];
return;
}
const def = (subSchema as unknown as { default?: string | number }).default;
result[key] = def ?? "";
});
return result;
}
type IsRunDisabledParams = {
agent: GraphModel | null;
isRunning: boolean;
agentInputs: InputValues | null | undefined;
};
export function isRunDisabled({
agent,
isRunning,
agentInputs,
}: IsRunDisabledParams) {
const hasEmptyInput = Object.values(agentInputs || {}).some(
(value) => String(value).trim() === "",
);
if (hasEmptyInput) return true;
if (!agent) return true;
if (isRunning) return true;
return false;
}
export function getSchemaDefaultCredentials(
schema: BlockIOCredentialsSubSchema,
): CredentialsMetaInput | undefined {
return schema.default as CredentialsMetaInput | undefined;
}

View File

@@ -1,124 +0,0 @@
"use client";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/__legacy__/ui/card";
import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { CircleNotchIcon } from "@phosphor-icons/react/dist/ssr";
import { Play } from "lucide-react";
import OnboardingButton from "../components/OnboardingButton";
import { OnboardingHeader, OnboardingStep } from "../components/OnboardingStep";
import { OnboardingText } from "../components/OnboardingText";
import { AgentOnboardingCredentials } from "./components/AgentOnboardingCredentials/AgentOnboardingCredentials";
import { RunAgentHint } from "./components/RunAgentHint";
import { SelectedAgentCard } from "./components/SelectedAgentCard";
import { isRunDisabled } from "./helpers";
import type { InputValues } from "./types";
import { useOnboardingRunStep } from "./useOnboardingRunStep";
export default function Page() {
const {
ready,
error,
showInput,
agentGraph,
onboarding,
storeAgent,
runningAgent,
handleSetAgentInput,
handleRunAgent,
handleNewRun,
handleCredentialsChange,
handleCredentialsValidationChange,
handleCredentialsLoadingChange,
} = useOnboardingRunStep();
if (error) {
return <ErrorCard responseError={error} />;
}
if (!ready) {
return (
<div className="flex flex-col gap-8">
<CircleNotchIcon className="size-10 animate-spin" />
</div>
);
}
return (
<OnboardingStep dotted>
<OnboardingHeader backHref={"/onboarding/4-agent"} transparent />
<div className="flex min-h-[80vh] items-center justify-center">
<SelectedAgentCard storeAgent={storeAgent} />
<div className="w-[481px]" />
{!showInput ? (
<RunAgentHint handleNewRun={handleNewRun} />
) : (
<div className="ml-[104px] w-[481px] pl-5">
<div className="flex flex-col">
<OnboardingText variant="header">
Provide details for your agent
</OnboardingText>
<span className="mt-9 text-base font-normal leading-normal text-zinc-600">
Give your agent the details it needs to workjust enter <br />
the key information and get started.
</span>
<span className="mt-4 text-base font-normal leading-normal text-zinc-600">
When you&apos;re done, click <b>Run Agent</b>.
</span>
<Card className="agpt-box mt-4">
<CardHeader>
<CardTitle className="font-poppins text-lg">Input</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{Object.entries(
agentGraph?.input_schema.properties || {},
).map(([key, inputSubSchema]) => (
<RunAgentInputs
key={key}
schema={inputSubSchema}
value={onboarding.state?.agentInput?.[key]}
placeholder={inputSubSchema.description}
onChange={(value) => handleSetAgentInput(key, value)}
/>
))}
<AgentOnboardingCredentials
agent={agentGraph}
siblingInputs={
(onboarding.state?.agentInput as Record<string, any>) ||
undefined
}
onCredentialsChange={handleCredentialsChange}
onValidationChange={handleCredentialsValidationChange}
onLoadingChange={handleCredentialsLoadingChange}
/>
</CardContent>
</Card>
<OnboardingButton
variant="violet"
className="mt-8 w-[136px]"
loading={runningAgent}
disabled={isRunDisabled({
agent: agentGraph,
isRunning: runningAgent,
agentInputs:
(onboarding.state?.agentInput as unknown as InputValues) ||
null,
})}
onClick={handleRunAgent}
icon={<Play className="mr-2" size={18} />}
>
Run agent
</OnboardingButton>
</div>
</div>
)}
</div>
</OnboardingStep>
);
}

View File

@@ -1,2 +0,0 @@
export type InputPrimitive = string | number;
export type InputValues = Record<string, InputPrimitive>;

View File

@@ -1,157 +0,0 @@
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { computeInitialAgentInputs } from "./helpers";
import { InputValues } from "./types";
import { okData, resolveResponse } from "@/app/api/helpers";
import { postV2AddMarketplaceAgent } from "@/app/api/__generated__/endpoints/library/library";
import {
useGetV2GetAgentByVersion,
useGetV2GetAgentGraph,
} from "@/app/api/__generated__/endpoints/store/store";
import { CredentialsMetaInput } from "@/app/api/__generated__/models/credentialsMetaInput";
import { GraphID } from "@/lib/autogpt-server-api";
export function useOnboardingRunStep() {
const onboarding = useOnboarding(undefined, "AGENT_CHOICE");
const [showInput, setShowInput] = useState(false);
const [runningAgent, setRunningAgent] = useState(false);
const [inputCredentials, setInputCredentials] = useState<
Record<string, CredentialsMetaInput>
>({});
const [credentialsValid, setCredentialsValid] = useState(true);
const [credentialsLoaded, setCredentialsLoaded] = useState(false);
const { toast } = useToast();
const router = useRouter();
const api = useBackendAPI();
const currentAgentVersion =
onboarding.state?.selectedStoreListingVersionId ?? "";
const {
data: storeAgent,
error: storeAgentQueryError,
isSuccess: storeAgentQueryIsSuccess,
} = useGetV2GetAgentByVersion(currentAgentVersion, {
query: {
enabled: !!currentAgentVersion,
select: okData,
},
});
const {
data: agentGraphMeta,
error: agentGraphQueryError,
isSuccess: agentGraphQueryIsSuccess,
} = useGetV2GetAgentGraph(currentAgentVersion, {
query: {
enabled: !!currentAgentVersion,
select: okData,
},
});
useEffect(() => {
onboarding.setStep(5);
}, []);
useEffect(() => {
if (agentGraphMeta && onboarding.state) {
const initialAgentInputs = computeInitialAgentInputs(
agentGraphMeta,
(onboarding.state.agentInput as unknown as InputValues) || null,
);
onboarding.updateState({ agentInput: initialAgentInputs });
}
}, [agentGraphMeta]);
function handleNewRun() {
if (!onboarding.state) return;
setShowInput(true);
onboarding.setStep(6);
onboarding.completeStep("AGENT_NEW_RUN");
}
function handleSetAgentInput(key: string, value: string) {
if (!onboarding.state) return;
onboarding.updateState({
agentInput: {
...onboarding.state.agentInput,
[key]: value,
},
});
}
async function handleRunAgent() {
if (!agentGraphMeta || !storeAgent || !onboarding.state) {
toast({
title: "Error getting agent",
description:
"Either the agent is not available or there was an error getting it.",
variant: "destructive",
});
return;
}
setRunningAgent(true);
try {
const libraryAgent = await resolveResponse(
postV2AddMarketplaceAgent({
store_listing_version_id: storeAgent?.store_listing_version_id || "",
source: "onboarding",
}),
);
const { id: runID } = await api.executeGraph(
libraryAgent.graph_id as GraphID,
libraryAgent.graph_version,
onboarding.state.agentInput || {},
inputCredentials,
"onboarding",
);
onboarding.updateState({ onboardingAgentExecutionId: runID });
router.push("/onboarding/6-congrats");
} catch (error) {
console.error("Error running agent:", error);
toast({
title: "Error running agent",
description:
"There was an error running your agent. Please try again or try choosing a different agent if it still fails.",
variant: "destructive",
});
setRunningAgent(false);
}
}
return {
ready: agentGraphQueryIsSuccess && storeAgentQueryIsSuccess,
error: agentGraphQueryError || storeAgentQueryError,
agentGraph: agentGraphMeta || null,
onboarding,
showInput,
storeAgent: storeAgent || null,
runningAgent,
credentialsValid,
credentialsLoaded,
handleSetAgentInput,
handleRunAgent,
handleNewRun,
handleCredentialsChange: setInputCredentials,
handleCredentialsValidationChange: setCredentialsValid,
handleCredentialsLoadingChange: (v: boolean) => setCredentialsLoaded(!v),
};
}

View File

@@ -1,127 +0,0 @@
"use client";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { cn } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
import { resolveResponse } from "@/app/api/helpers";
import { getV1OnboardingState } from "@/app/api/__generated__/endpoints/onboarding/onboarding";
import { postV2AddMarketplaceAgent } from "@/app/api/__generated__/endpoints/library/library";
import { Confetti } from "@/components/molecules/Confetti/Confetti";
import type { ConfettiRef } from "@/components/molecules/Confetti/Confetti";
export default function Page() {
const { completeStep } = useOnboarding(7, "AGENT_INPUT");
const router = useRouter();
const api = useBackendAPI();
const [showText, setShowText] = useState(false);
const [showSubtext, setShowSubtext] = useState(false);
const confettiRef = useRef<ConfettiRef>(null);
useEffect(() => {
// Fire side cannons for a celebratory effect
const duration = 1500;
const end = Date.now() + duration;
function frame() {
confettiRef.current?.fire({
particleCount: 4,
angle: 60,
spread: 70,
origin: { x: 0, y: 0.6 },
shapes: ["square"],
scalar: 0.8,
gravity: 0.6,
decay: 0.93,
});
confettiRef.current?.fire({
particleCount: 4,
angle: 120,
spread: 70,
origin: { x: 1, y: 0.6 },
shapes: ["square"],
scalar: 0.8,
gravity: 0.6,
decay: 0.93,
});
if (Date.now() < end) {
requestAnimationFrame(frame);
}
}
frame();
const timer0 = setTimeout(() => {
setShowText(true);
}, 100);
const timer1 = setTimeout(() => {
setShowSubtext(true);
}, 500);
const timer2 = setTimeout(async () => {
completeStep("CONGRATS");
try {
const onboarding = await resolveResponse(getV1OnboardingState());
if (onboarding?.selectedStoreListingVersionId) {
try {
const libraryAgent = await resolveResponse(
postV2AddMarketplaceAgent({
store_listing_version_id:
onboarding.selectedStoreListingVersionId,
source: "onboarding",
}),
);
router.replace(`/library/agents/${libraryAgent.id}`);
} catch (error) {
console.error("Failed to add agent to library:", error);
router.replace("/library");
}
} else {
router.replace("/library");
}
} catch (error) {
console.error("Failed to get onboarding data:", error);
router.replace("/library");
}
}, 3000);
return () => {
clearTimeout(timer0);
clearTimeout(timer1);
clearTimeout(timer2);
};
}, [completeStep, router, api]);
return (
<div className="flex h-screen w-screen flex-col items-center justify-center bg-violet-100">
<Confetti ref={confettiRef} manualstart />
<div
className={cn(
"z-10 -mb-16 text-9xl duration-500",
showText ? "opacity-100" : "opacity-0",
)}
>
🎉
</div>
<h1
className={cn(
"font-poppins text-9xl font-medium tracking-tighter text-violet-700 duration-500",
showText ? "opacity-100" : "opacity-0",
)}
>
Congrats!
</h1>
<p
className={cn(
"mb-16 mt-4 font-poppins text-2xl font-medium text-violet-800 transition-opacity duration-500",
showSubtext ? "opacity-100" : "opacity-0",
)}
>
You earned 3$ for running your first agent
</p>
</div>
);
}

View File

@@ -1,105 +0,0 @@
import { cn } from "@/lib/utils";
import StarRating from "./StarRating";
import SmartImage from "@/components/__legacy__/SmartImage";
import { StoreAgentDetails } from "@/app/api/__generated__/models/storeAgentDetails";
type OnboardingAgentCardProps = {
agent?: StoreAgentDetails;
selected?: boolean;
onClick: () => void;
};
export default function OnboardingAgentCard({
agent,
selected,
onClick,
}: OnboardingAgentCardProps) {
if (!agent) {
return (
<div
className={cn(
"relative animate-pulse",
"h-[394px] w-[368px] rounded-[20px] border border-transparent bg-zinc-200",
)}
/>
);
}
const {
agent_image,
creator_avatar,
agent_name,
description,
creator,
runs,
rating,
} = agent;
return (
<div
className={cn(
"relative cursor-pointer transition-all duration-200 ease-in-out",
"h-[394px] w-[368px] rounded-[20px] border border-transparent bg-white",
selected ? "bg-[#F5F3FF80]" : "hover:border-zinc-400",
)}
onClick={onClick}
>
{/* Image container */}
<div className="relative">
<SmartImage
src={agent_image?.[0]}
alt="Agent cover"
className="m-2 h-[196px] w-[350px] rounded-[16px]"
/>
{/* Profile picture overlay */}
<div className="absolute bottom-2 left-4">
<SmartImage
src={creator_avatar}
alt="Profile picture"
className="h-[50px] w-[50px] rounded-full border border-white"
/>
</div>
</div>
{/* Content container */}
<div className="flex h-[180px] flex-col justify-between px-4 pb-3">
{/* Text content wrapper */}
<div>
{/* Title - 2 lines max */}
<p className="data-sentry-unmask text-md line-clamp-2 max-h-[50px] font-sans text-base font-medium leading-normal text-zinc-800">
{agent_name}
</p>
{/* Author - single line with truncate */}
<p className="data-sentry-unmask truncate text-sm font-normal leading-normal text-zinc-600">
by {creator}
</p>
{/* Description - 3 lines max */}
<p
className={cn(
"mt-2 line-clamp-3 text-sm leading-5",
selected ? "text-zinc-500" : "text-zinc-400",
)}
>
{description}
</p>
</div>
{/* Bottom stats */}
<div className="flex w-full items-center justify-between">
<span className="mt-1 font-sans text-sm font-medium text-zinc-800">
{runs?.toLocaleString("en-US")} runs
</span>
<StarRating rating={rating} />
</div>
</div>
<div
className={cn(
"pointer-events-none absolute inset-0 rounded-[20px] border-2 transition-all duration-200 ease-in-out",
selected ? "border-violet-700" : "border-transparent",
)}
/>
</div>
);
}

View File

@@ -1,20 +0,0 @@
import { ChevronLeft } from "lucide-react";
import Link from "next/link";
interface OnboardingBackButtonProps {
href: string;
}
export default function OnboardingBackButton({
href,
}: OnboardingBackButtonProps) {
return (
<Link
className="flex items-center gap-2 font-sans text-base font-medium text-zinc-700 transition-colors duration-200 hover:text-zinc-800"
href={href}
>
<ChevronLeft size={24} className="-mr-1" />
<span>Back</span>
</Link>
);
}

View File

@@ -1,76 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
import { cn } from "@/lib/utils";
import Link from "next/link";
const variants = {
default: "bg-zinc-700 hover:bg-zinc-800",
violet: "bg-violet-600 hover:bg-violet-700",
};
type OnboardingButtonProps = {
className?: string;
variant?: keyof typeof variants;
children?: React.ReactNode;
loading?: boolean;
disabled?: boolean;
onClick?: () => void;
href?: string;
icon?: React.ReactNode;
};
export default function OnboardingButton({
className,
variant = "default",
children,
loading,
disabled,
onClick,
href,
icon,
}: OnboardingButtonProps) {
const [internalLoading, setInternalLoading] = useState(false);
const isLoading = loading !== undefined ? loading : internalLoading;
const buttonClasses = useMemo(
() =>
cn(
"font-sans text-white text-sm font-medium",
"inline-flex justify-center items-center",
"h-12 min-w-[100px] rounded-full py-3 px-5",
"transition-colors duration-200",
className,
disabled ? "bg-zinc-300 cursor-not-allowed" : variants[variant],
),
[disabled, variant, className],
);
const onClickInternal = useCallback(() => {
setInternalLoading(true);
if (onClick) {
onClick();
}
}, [setInternalLoading, onClick]);
if (href && !disabled) {
return (
<Link href={href} onClick={onClickInternal} className={buttonClasses}>
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</Link>
);
}
return (
<button
onClick={onClickInternal}
disabled={disabled}
className={buttonClasses}
>
{isLoading && <LoadingSpinner className="mr-2 size-5" />}
{icon && !isLoading && <>{icon}</>}
{children}
</button>
);
}

View File

@@ -1,86 +0,0 @@
import { cn } from "@/lib/utils";
import SmartImage from "@/components/__legacy__/SmartImage";
type OnboardingGridElementProps = {
name: string;
text: string;
icon: string;
selected: boolean;
onClick: () => void;
};
function OnboardingGridElement({
name,
text,
icon,
selected,
onClick,
}: OnboardingGridElementProps) {
return (
<button
className={cn(
"relative flex h-[236px] w-[200px] flex-col items-start gap-2 rounded-xl border border-transparent bg-white p-[15px] font-sans",
"transition-all duration-200 ease-in-out",
selected ? "bg-[#F5F3FF80]" : "hover:border-zinc-400",
)}
onClick={onClick}
>
<SmartImage
src={icon}
alt={`Logo of ${name}`}
imageContain
className="h-12 w-12 rounded-lg"
/>
<span className="text-md mt-4 w-full text-left font-medium leading-normal text-[#121212]">
{name}
</span>
<span className="w-full text-left text-[11.5px] font-normal leading-5 text-zinc-500">
{text}
</span>
<div
className={cn(
"pointer-events-none absolute inset-0 rounded-xl border-2 transition-all duration-200 ease-in-out",
selected ? "border-violet-700" : "border-transparent",
)}
/>
</button>
);
}
type OnboardingGridProps = {
className?: string;
elements: Array<{
name: string;
text: string;
icon: string;
}>;
selected?: string[];
onSelect: (name: string) => void;
};
export function OnboardingGrid({
className,
elements,
selected,
onSelect,
}: OnboardingGridProps) {
return (
<div
className={cn(
className,
"grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4",
)}
>
{elements.map((element) => (
<OnboardingGridElement
key={element.name}
name={element.name}
text={element.text}
icon={element.icon}
selected={selected?.includes(element.name) || false}
onClick={() => onSelect(element.name)}
/>
))}
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { cn } from "@/lib/utils";
interface OnboardingInputProps {
className?: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
}
export default function OnboardingInput({
className,
placeholder,
value,
onChange,
}: OnboardingInputProps) {
return (
<input
className={cn(
className,
"font-poppin relative h-[50px] w-[512px] rounded-[25px] border border-transparent bg-white px-4 text-sm font-normal leading-normal text-zinc-900",
"transition-all duration-200 ease-in-out placeholder:text-zinc-400",
"focus:border-transparent focus:bg-[#F5F3FF80] focus:outline-none focus:ring-2 focus:ring-violet-700",
)}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}

View File

@@ -1,135 +0,0 @@
import { cn } from "@/lib/utils";
import { Check } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
type OnboardingListElementProps = {
label: string;
text: string;
selected?: boolean;
custom?: boolean;
onClick: (content: string) => void;
};
export function OnboardingListElement({
label,
text,
selected,
custom,
onClick,
}: OnboardingListElementProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [content, setContent] = useState(text);
useEffect(() => {
if (selected && custom && inputRef.current) {
inputRef.current.focus();
}
}, [selected, custom]);
const setCustomText = (e: React.ChangeEvent<HTMLInputElement>) => {
setContent(e.target.value);
onClick(e.target.value);
};
return (
<button
onClick={() => onClick(content)}
className={cn(
"relative flex h-[78px] w-[530px] items-center rounded-xl border border-transparent px-5 py-4 transition-all duration-200 ease-in-out",
selected ? "bg-[#F5F3FF80]" : "bg-white hover:border-zinc-400",
)}
>
<div className="flex w-full flex-col items-start gap-1">
<span className="text-sm font-medium text-zinc-700">{label}</span>
{custom && selected ? (
<input
ref={inputRef}
className={cn(
selected ? "text-zinc-600" : "text-zinc-400",
"font-poppin w-full border-0 bg-[#F5F3FF80] text-sm focus:outline-none",
)}
placeholder="Please specify"
value={content}
onChange={setCustomText}
/>
) : (
<span
className={cn(
selected ? "text-zinc-600" : "text-zinc-400",
"text-sm",
)}
>
{custom ? "Please specify" : text}
</span>
)}
</div>
{!custom && (
<div className="absolute right-4">
<Check
size={24}
className={cn(
"transition-all duration-200 ease-in-out",
selected ? "text-violet-700" : "text-transparent",
)}
/>
</div>
)}
<div
className={cn(
"pointer-events-none absolute inset-0 rounded-xl border-2 transition-all duration-200 ease-in-out",
selected ? "border-violet-700" : "border-transparent",
)}
/>
</button>
);
}
type OnboardingListProps = {
className?: string;
elements: Array<{
label: string;
text: string;
id: string;
}>;
selectedId?: string | null;
onSelect: (id: string) => void;
};
function OnboardingList({
className,
elements,
selectedId,
onSelect,
}: OnboardingListProps) {
const isCustom = useCallback(() => {
return (
selectedId !== null &&
!elements.some((element) => element.id === selectedId)
);
}, [selectedId, elements]);
return (
<div className={cn(className, "flex flex-col gap-2")}>
{elements.map((element) => (
<OnboardingListElement
key={element.id}
label={element.label}
text={element.text}
selected={element.id === selectedId}
onClick={() => onSelect(element.id)}
/>
))}
<OnboardingListElement
label="Other"
text={isCustom() ? selectedId! : ""}
selected={isCustom()}
custom
onClick={(c) => {
onSelect(c);
}}
/>
</div>
);
}
export default OnboardingList;

View File

@@ -1,45 +0,0 @@
import { useState, useEffect, useRef } from "react";
interface OnboardingProgressProps {
totalSteps: number;
toStep: number;
}
export default function OnboardingProgress({
totalSteps,
toStep,
}: OnboardingProgressProps) {
const [animatedStep, setAnimatedStep] = useState(toStep - 1);
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
// On initial mount, just set the position without animation
isInitialMount.current = false;
return;
}
// After initial mount, animate position changes
setAnimatedStep(toStep - 1);
}, [toStep]);
return (
<div className="relative flex items-center justify-center gap-3">
{/* Background circles */}
{Array.from({ length: totalSteps + 1 }).map((_, index) => (
<div key={index} className="h-2 w-2 rounded-full bg-zinc-400" />
))}
{/* Animated progress indicator */}
<div
className={`absolute left-0 h-2 w-7 rounded-full bg-zinc-400 ${
!isInitialMount.current
? "transition-all duration-300 ease-in-out"
: ""
}`}
style={{
transform: `translateX(${animatedStep * 20}px)`,
}}
/>
</div>
);
}

View File

@@ -1,66 +0,0 @@
"use client";
import { ReactNode } from "react";
import OnboardingBackButton from "./OnboardingBackButton";
import { cn } from "@/lib/utils";
import OnboardingProgress from "./OnboardingProgress";
import { useOnboarding } from "../../../../providers/onboarding/onboarding-provider";
export function OnboardingStep({
dotted,
children,
}: {
dotted?: boolean;
children: ReactNode;
}) {
return (
<div className="relative flex min-h-screen w-full flex-col">
{dotted && (
<div className="absolute left-1/2 h-full w-1/2 bg-white bg-[radial-gradient(#e5e7eb77_1px,transparent_1px)] [background-size:10px_10px]"></div>
)}
<div className="z-10 flex flex-col items-center">{children}</div>
</div>
);
}
interface OnboardingHeaderProps {
backHref: string;
transparent?: boolean;
children?: ReactNode;
}
export function OnboardingHeader({
backHref,
transparent,
children,
}: OnboardingHeaderProps) {
const { step } = useOnboarding();
return (
<div className="sticky top-0 z-10 w-full">
<div
className={cn(transparent ? "bg-transparent" : "bg-gray-100", "pb-5")}
>
<div className="flex w-full items-center justify-between px-5 py-4">
<OnboardingBackButton href={backHref} />
<OnboardingProgress totalSteps={5} toStep={(step || 1) - 1} />
</div>
{children}
</div>
{!transparent && (
<div className="h-4 w-full bg-gradient-to-b from-gray-100 via-gray-100/50 to-transparent" />
)}
</div>
);
}
export function OnboardingFooter({ children }: { children?: ReactNode }) {
return (
<div className="sticky bottom-0 z-10 w-full">
<div className="h-4 w-full bg-gradient-to-t from-gray-100 via-gray-100/50 to-transparent" />
<div className="flex justify-center bg-gray-100">
<div className="px-5 py-5">{children}</div>
</div>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import { cn } from "@/lib/utils";
import { ReactNode } from "react";
const variants = {
header: "font-poppin text-xl font-medium leading-7 text-zinc-900",
subheader: "font-sans text-sm font-medium leading-6 text-zinc-800",
default: "font-sans text-sm font-normal leading-6 text-zinc-500",
};
export function OnboardingText({
className,
center,
variant = "default",
children,
}: {
className?: string;
center?: boolean;
variant?: keyof typeof variants;
children: ReactNode;
}) {
return (
<div
className={cn(
"w-full",
center ? "text-center" : "text-left",
variants[variant] || variants.default,
className,
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,17 @@
interface Props {
currentStep: number;
totalSteps: number;
}
export function ProgressBar({ currentStep, totalSteps }: Props) {
const percent = (currentStep / totalSteps) * 100;
return (
<div className="absolute left-0 top-0 h-[0.625rem] w-full bg-neutral-300">
<div
className="h-full bg-purple-400 shadow-[0_0_4px_2px_rgba(168,85,247,0.5)] transition-all duration-500 ease-out"
style={{ width: `${percent}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
interface Props {
icon: React.ReactNode;
label: string;
selected: boolean;
onClick: () => void;
className?: string;
}
export function SelectableCard({
icon,
label,
selected,
onClick,
className,
}: Props) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
className={cn(
"flex h-[9rem] w-[10.375rem] shrink-0 flex-col items-center justify-center gap-3 rounded-xl border-2 bg-white px-6 py-5 transition-all hover:shadow-sm md:shrink lg:gap-2 lg:px-10 lg:py-8",
className,
selected
? "border-purple-500 bg-purple-50 shadow-sm"
: "border-transparent",
)}
>
<Text
variant="lead"
as="span"
className={selected ? "text-neutral-900" : "text-purple-600"}
>
{icon}
</Text>
<Text variant="body-medium" as="span" className="whitespace-nowrap">
{label}
</Text>
</button>
);
}

View File

@@ -1,63 +0,0 @@
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import { FaRegStar, FaStar, FaStarHalfAlt } from "react-icons/fa";
export default function StarRating({
className,
starSize,
rating,
}: {
className?: string;
starSize?: number;
rating: number;
}) {
// Round to 1 decimal place
const roundedRating = Math.round(rating * 10) / 10;
starSize ??= 15;
// Generate array of 5 star values
const stars = useMemo(
() =>
Array(5)
.fill(0)
.map((_, index) => {
const difference = roundedRating - index;
if (difference >= 1) {
return "full";
} else if (difference > 0) {
// Half star for values between 0.2 and 0.8
return difference >= 0.8
? "full"
: difference >= 0.2
? "half"
: "empty";
}
return "empty";
}),
[roundedRating],
);
return (
<div
className={cn(
"flex items-center gap-0.5 text-sm font-medium text-zinc-800",
className,
)}
>
{/* Display numerical rating */}
<span className="mr-1 mt-0.5">{roundedRating}</span>
{/* Display stars */}
{stars.map((starType, index) => {
if (starType === "full") {
return <FaStar size={starSize} key={index} />;
} else if (starType === "half") {
return <FaStarHalfAlt size={starSize} key={index} />;
} else {
return <FaRegStar size={starSize} key={index} />;
}
})}
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { cn } from "@/lib/utils";
interface Props {
totalSteps: number;
currentStep: number;
}
export function StepIndicator({ totalSteps, currentStep }: Props) {
return (
<div className="flex items-center gap-2">
{Array.from({ length: totalSteps }, (_, i) => (
<div
key={i}
className={cn(
"h-2 rounded-full transition-all",
i + 1 === currentStep ? "w-6 bg-foreground" : "w-2 bg-gray-300",
)}
/>
))}
</div>
);
}

View File

@@ -6,8 +6,8 @@ export default function OnboardingLayout({
children: ReactNode;
}) {
return (
<div className="flex min-h-screen w-full items-center justify-center bg-gray-100">
<main className="mx-auto flex w-full flex-col items-center">
<div className="relative flex min-h-screen w-full flex-col bg-gray-100">
<main className="flex w-full flex-1 flex-col items-center justify-center">
{children}
</main>
</div>

View File

@@ -1,73 +1,59 @@
"use client";
import { getV1OnboardingState } from "@/app/api/__generated__/endpoints/onboarding/onboarding";
import { getOnboardingStatus, resolveResponse } from "@/app/api/helpers";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { CaretLeft } from "@phosphor-icons/react";
import { ProgressBar } from "./components/ProgressBar";
import { StepIndicator } from "./components/StepIndicator";
import { PainPointsStep } from "./steps/PainPointsStep";
import { PreparingStep } from "./steps/PreparingStep";
import { RoleStep } from "./steps/RoleStep";
import { WelcomeStep } from "./steps/WelcomeStep";
import { useOnboardingWizardStore } from "./store";
import { useOnboardingPage } from "./useOnboardingPage";
export default function OnboardingPage() {
const router = useRouter();
const { currentStep, isLoading, handlePreparingComplete } =
useOnboardingPage();
const prevStep = useOnboardingWizardStore((s) => s.prevStep);
useEffect(() => {
async function redirectToStep() {
try {
// Check if onboarding is enabled (also gets chat flag for redirect)
const { shouldShowOnboarding } = await getOnboardingStatus();
if (isLoading) return null;
if (!shouldShowOnboarding) {
router.replace("/");
return;
}
const totalSteps = 4;
const showDots = currentStep <= 3;
const showBack = currentStep > 1 && currentStep <= 3;
const onboarding = await resolveResponse(getV1OnboardingState());
const showProgressBar = currentStep <= 3;
// Handle completed onboarding
if (onboarding.completedSteps.includes("GET_RESULTS")) {
router.replace("/");
return;
}
return (
<div className="flex min-h-screen w-full flex-col items-center">
{showProgressBar && (
<ProgressBar currentStep={currentStep} totalSteps={totalSteps} />
)}
// Redirect to appropriate step based on completed steps
if (onboarding.completedSteps.includes("AGENT_INPUT")) {
router.push("/onboarding/5-run");
return;
}
{showBack && (
<button
type="button"
onClick={prevStep}
className="text-md absolute left-6 top-6 flex items-center gap-1 text-zinc-500 transition-colors duration-200 hover:text-zinc-900"
>
<CaretLeft size={16} />
Back
</button>
)}
if (onboarding.completedSteps.includes("AGENT_NEW_RUN")) {
router.push("/onboarding/5-run");
return;
}
<div className="flex flex-1 items-center py-16">
{currentStep === 1 && <WelcomeStep />}
{currentStep === 2 && <RoleStep />}
{currentStep === 3 && <PainPointsStep />}
{currentStep === 4 && (
<PreparingStep onComplete={handlePreparingComplete} />
)}
</div>
if (onboarding.completedSteps.includes("AGENT_CHOICE")) {
router.push("/onboarding/5-run");
return;
}
if (onboarding.completedSteps.includes("INTEGRATIONS")) {
router.push("/onboarding/4-agent");
return;
}
if (onboarding.completedSteps.includes("USAGE_REASON")) {
router.push("/onboarding/3-services");
return;
}
if (onboarding.completedSteps.includes("WELCOME")) {
router.push("/onboarding/2-reason");
return;
}
// Default: redirect to first step
router.push("/onboarding/1-welcome");
} catch (error) {
console.error("Failed to determine onboarding step:", error);
router.replace("/");
}
}
redirectToStep();
}, [router]);
return <LoadingSpinner size="large" cover />;
{showDots && (
<div className="pb-8">
<StepIndicator totalSteps={3} currentStep={currentStep} />
</div>
)}
</div>
);
}

View File

@@ -1,33 +0,0 @@
"use client";
import { postV1ResetOnboardingProgress } from "@/app/api/__generated__/endpoints/onboarding/onboarding";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function OnboardingResetPage() {
const { toast } = useToast();
const router = useRouter();
useEffect(() => {
postV1ResetOnboardingProgress()
.then(() => {
toast({
title: "Onboarding reset successfully",
description: "You can now start the onboarding process again",
variant: "success",
});
router.push("/onboarding");
})
.catch(() => {
toast({
title: "Failed to reset onboarding",
description: "Please try again later",
variant: "destructive",
});
});
}, [toast, router]);
return <LoadingSpinner cover />;
}

View File

@@ -0,0 +1,141 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { ReactNode } from "react";
import { FadeIn } from "@/components/atoms/FadeIn/FadeIn";
import { SelectableCard } from "../components/SelectableCard";
import { usePainPointsStep } from "./usePainPointsStep";
import { Emoji } from "@/components/atoms/Emoji/Emoji";
const ALL_PAIN_POINTS: { id: string; label: string; icon: ReactNode }[] = [
{
id: "Finding leads",
label: "Finding leads",
icon: <Emoji text="🔍" size={32} />,
},
{
id: "Email & outreach",
label: "Email & outreach",
icon: <Emoji text="📧" size={32} />,
},
{
id: "Reports & data",
label: "Reports & data",
icon: <Emoji text="📊" size={32} />,
},
{
id: "Customer support",
label: "Customer support",
icon: <Emoji text="💬" size={32} />,
},
{
id: "Social media",
label: "Social media",
icon: <Emoji text="📱" size={32} />,
},
{
id: "CRM & data entry",
label: "CRM & data entry",
icon: <Emoji text="📝" size={32} />,
},
{
id: "Scheduling",
label: "Scheduling",
icon: <Emoji text="🗓️" size={32} />,
},
{ id: "Research", label: "Research", icon: <Emoji text="🔬" size={32} /> },
{
id: "Something else",
label: "Something else",
icon: <Emoji text="🚩" size={32} />,
},
];
function orderPainPoints(topIDs: string[]) {
const top = topIDs
.map((id) => ALL_PAIN_POINTS.find((p) => p.id === id))
.filter((p): p is (typeof ALL_PAIN_POINTS)[number] => p != null);
const rest = ALL_PAIN_POINTS.filter(
(p) => !topIDs.includes(p.id) && p.id !== "Something else",
);
const somethingElse = ALL_PAIN_POINTS.find((p) => p.id === "Something else")!;
return [...top, ...rest, somethingElse];
}
export function PainPointsStep() {
const {
topIDs,
painPoints,
otherPainPoint,
togglePainPoint,
setOtherPainPoint,
hasSomethingElse,
canContinue,
handleLaunch,
} = usePainPointsStep();
const orderedPainPoints = orderPainPoints(topIDs);
return (
<FadeIn>
<div className="flex w-full flex-col items-center gap-12 px-4">
<div className="flex max-w-lg flex-col items-center gap-2 px-4 text-center">
<Text
variant="h3"
className="!text-[1.5rem] !leading-[2rem] md:!text-[1.75rem] md:!leading-[2.5rem]"
>
What&apos;s eating your time?
</Text>
<Text variant="lead" className="!text-zinc-500">
Pick the tasks you&apos;d love to hand off to Autopilot
</Text>
</div>
<div className="flex w-full flex-col items-center gap-4">
<div className="flex w-full max-w-[100vw] flex-nowrap gap-4 overflow-x-auto px-8 scrollbar-none md:grid md:grid-cols-3 md:overflow-hidden md:px-0">
{orderedPainPoints.map((p) => (
<SelectableCard
key={p.id}
icon={p.icon}
label={p.label}
selected={painPoints.includes(p.id)}
onClick={() => togglePainPoint(p.id)}
className="p-8"
/>
))}
</div>
{!hasSomethingElse ? (
<Text variant="small" className="!text-zinc-500">
Pick as many as you want you can always change later
</Text>
) : null}
</div>
{hasSomethingElse && (
<div className="-mb-5 w-full px-8 md:px-0">
<Input
id="other-pain-point"
label="Other pain point"
hideLabel
placeholder="What else takes up your time?"
value={otherPainPoint}
onChange={(e) => setOtherPainPoint(e.target.value)}
autoFocus
/>
</div>
)}
<Button
onClick={handleLaunch}
disabled={!canContinue}
className="w-full max-w-xs"
>
Launch Autopilot
</Button>
</div>
</FadeIn>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { AutoGPTLogo } from "@/components/atoms/AutoGPTLogo/AutoGPTLogo";
import { Text } from "@/components/atoms/Text/Text";
import { TypingText } from "@/components/molecules/TypingText/TypingText";
import { cn } from "@/lib/utils";
import { Check } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
const CHECKLIST = [
"Personalizing your experience",
"Connecting automation engines",
"Building your space",
] as const;
const STEP_DURATION_MS = 4000;
const STEP_INTERVAL = STEP_DURATION_MS / CHECKLIST.length;
interface Props {
onComplete: () => void;
}
export function PreparingStep({ onComplete }: Props) {
const [started, setStarted] = useState(false);
const [completedItems, setCompletedItems] = useState(0);
const [progress, setProgress] = useState(0);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
useEffect(() => {
const timer = setTimeout(() => setStarted(true), 300);
return () => clearTimeout(timer);
}, []);
useEffect(() => {
if (!started) return;
const startTime = Date.now();
const progressInterval = setInterval(() => {
const elapsed = Date.now() - startTime;
const pct = Math.min(100, (elapsed / STEP_DURATION_MS) * 100);
setProgress(pct);
const items = Math.min(
CHECKLIST.length,
Math.floor(elapsed / STEP_INTERVAL) + 1,
);
setCompletedItems(items);
if (elapsed >= STEP_DURATION_MS) {
clearInterval(progressInterval);
onCompleteRef.current();
}
}, 50);
return () => clearInterval(progressInterval);
}, [started]);
return (
<div className="flex w-full max-w-md flex-col items-center gap-8 px-4">
<div className="flex flex-col items-center gap-4">
<AutoGPTLogo
className="relative right-[3rem] h-24 w-[12rem]"
hideText
/>
<Text variant="h3" className="text-center">
<TypingText
text="Preparing your workspace..."
active={started}
delay={400}
speed={60}
/>
</Text>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-purple-500 transition-all duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
<ul className="flex flex-col gap-3">
{CHECKLIST.map((item, i) => (
<li key={item} className="flex items-center gap-3">
<div
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full transition-colors",
i < completedItems
? "bg-neutral-900 text-white"
: "bg-gray-200 text-gray-400",
)}
>
<Check size={14} weight="bold" />
</div>
<Text
variant="body"
as="span"
className={cn(
"transition-colors",
i < completedItems ? "!text-black" : "!text-zinc-500",
)}
>
{item}
</Text>
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,122 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import { FadeIn } from "@/components/atoms/FadeIn/FadeIn";
import { SelectableCard } from "../components/SelectableCard";
import { useOnboardingWizardStore } from "../store";
import { Emoji } from "@/components/atoms/Emoji/Emoji";
const IMG_SIZE = 42;
const ROLES = [
{
id: "Founder/CEO",
label: "Founder / CEO",
icon: <Emoji text="🎯" size={IMG_SIZE} />,
},
{
id: "Operations",
label: "Operations",
icon: <Emoji text="⚙️" size={IMG_SIZE} />,
},
{
id: "Sales/BD",
label: "Sales / BD",
icon: <Emoji text="📈" size={IMG_SIZE} />,
},
{
id: "Marketing",
label: "Marketing",
icon: <Emoji text="📢" size={IMG_SIZE} />,
},
{
id: "Product/PM",
label: "Product / PM",
icon: <Emoji text="🔨" size={IMG_SIZE} />,
},
{
id: "Engineering",
label: "Engineering",
icon: <Emoji text="💻" size={IMG_SIZE} />,
},
{
id: "HR/People",
label: "HR / People",
icon: <Emoji text="👤" size={IMG_SIZE} />,
},
{ id: "Other", label: "Other", icon: <Emoji text="🚩" size={IMG_SIZE} /> },
] as const;
export function RoleStep() {
const name = useOnboardingWizardStore((s) => s.name);
const role = useOnboardingWizardStore((s) => s.role);
const otherRole = useOnboardingWizardStore((s) => s.otherRole);
const setRole = useOnboardingWizardStore((s) => s.setRole);
const setOtherRole = useOnboardingWizardStore((s) => s.setOtherRole);
const nextStep = useOnboardingWizardStore((s) => s.nextStep);
const isOther = role === "Other";
const canContinue = role && (!isOther || otherRole.trim());
function handleContinue() {
if (canContinue) {
nextStep();
}
}
return (
<FadeIn>
<div className="flex w-full flex-col items-center gap-12 px-4">
<div className="mx-auto flex w-full max-w-lg flex-col items-center gap-2 px-4 text-center">
<Text
variant="h3"
className="!text-[1.5rem] !leading-[2rem] md:!text-[1.75rem] md:!leading-[2.5rem]"
>
What best describes you, {name}?
</Text>
<Text variant="lead" className="!text-zinc-500">
Autopilot will tailor automations to your world
</Text>
</div>
<div className="flex w-full max-w-[100vw] flex-nowrap gap-4 overflow-x-auto px-8 scrollbar-none md:grid md:grid-cols-4 md:overflow-hidden md:px-0">
{ROLES.map((r) => (
<SelectableCard
key={r.id}
icon={r.icon}
label={r.label}
selected={role === r.id}
onClick={() => setRole(r.id)}
className="p-8"
/>
))}
</div>
{isOther && (
<div className="-mb-5 w-full px-8 md:px-0">
<Input
id="other-role"
label="Other role"
hideLabel
placeholder="Describe your role..."
value={otherRole}
onChange={(e) => setOtherRole(e.target.value)}
autoFocus
/>
</div>
)}
<Button
onClick={handleContinue}
disabled={!canContinue}
className="w-full max-w-xs"
>
Continue
</Button>
</div>
</FadeIn>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { AutoGPTLogo } from "@/components/atoms/AutoGPTLogo/AutoGPTLogo";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Text } from "@/components/atoms/Text/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Question } from "@phosphor-icons/react";
import { FadeIn } from "@/components/atoms/FadeIn/FadeIn";
import { useOnboardingWizardStore } from "../store";
export function WelcomeStep() {
const name = useOnboardingWizardStore((s) => s.name);
const setName = useOnboardingWizardStore((s) => s.setName);
const nextStep = useOnboardingWizardStore((s) => s.nextStep);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (name.trim()) {
nextStep();
}
}
return (
<FadeIn>
<form
onSubmit={handleSubmit}
className="flex w-full max-w-lg flex-col items-center gap-4 px-4 md:gap-8"
>
<div className="mb-8 flex flex-col items-center gap-3 text-center md:mb-0">
<AutoGPTLogo
className="relative right-[3rem] h-24 w-[12rem]"
hideText
/>
<Text variant="h3">Welcome to AutoGPT</Text>
<Text variant="lead" as="span" className="!text-zinc-500">
Let&apos;s personalize your experience so{" "}
<span className="relative mr-3 inline-block bg-gradient-to-r from-purple-500 to-indigo-500 bg-clip-text text-transparent">
Autopilot
<span className="absolute -right-4 top-0">
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="What is Autopilot?"
className="inline-flex text-purple-500"
>
<Question size={14} />
</button>
</TooltipTrigger>
<TooltipContent>
Autopilot is AutoGPT&apos;s AI assistant that watches your
connected apps, spots repetitive tasks you do every day
and runs them for you automatically.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</span>{" "}
can start saving you time right away
</Text>
</div>
<Input
id="first-name"
label="Your first name"
placeholder="e.g. John"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full"
autoFocus
/>
<Button
type="submit"
disabled={!name.trim()}
className="w-full max-w-xs"
>
Continue
</Button>
</form>
</FadeIn>
);
}

View File

@@ -0,0 +1,54 @@
import { useOnboardingWizardStore } from "../store";
const ROLE_TOP_PICKS: Record<string, string[]> = {
"Founder/CEO": [
"Finding leads",
"Reports & data",
"Email & outreach",
"Scheduling",
],
Operations: ["CRM & data entry", "Scheduling", "Reports & data"],
"Sales/BD": ["Finding leads", "Email & outreach", "CRM & data entry"],
Marketing: ["Social media", "Email & outreach", "Research"],
"Product/PM": ["Research", "Reports & data", "Scheduling"],
Engineering: ["Research", "Reports & data", "CRM & data entry"],
"HR/People": ["Scheduling", "Email & outreach", "CRM & data entry"],
};
export function getTopPickIDs(role: string) {
return ROLE_TOP_PICKS[role] ?? [];
}
export function usePainPointsStep() {
const role = useOnboardingWizardStore((s) => s.role);
const painPoints = useOnboardingWizardStore((s) => s.painPoints);
const otherPainPoint = useOnboardingWizardStore((s) => s.otherPainPoint);
const togglePainPoint = useOnboardingWizardStore((s) => s.togglePainPoint);
const setOtherPainPoint = useOnboardingWizardStore(
(s) => s.setOtherPainPoint,
);
const nextStep = useOnboardingWizardStore((s) => s.nextStep);
const topIDs = getTopPickIDs(role);
const hasSomethingElse = painPoints.includes("Something else");
const canContinue =
painPoints.length > 0 &&
(!hasSomethingElse || Boolean(otherPainPoint.trim()));
function handleLaunch() {
if (canContinue) {
nextStep();
}
}
return {
topIDs,
painPoints,
otherPainPoint,
togglePainPoint,
setOtherPainPoint,
hasSomethingElse,
canContinue,
handleLaunch,
};
}

View File

@@ -0,0 +1,77 @@
import { create } from "zustand";
export type Step = 1 | 2 | 3 | 4;
interface OnboardingWizardState {
currentStep: Step;
name: string;
role: string;
otherRole: string;
painPoints: string[];
otherPainPoint: string;
setName(name: string): void;
setRole(role: string): void;
setOtherRole(otherRole: string): void;
togglePainPoint(painPoint: string): void;
setOtherPainPoint(otherPainPoint: string): void;
nextStep(): void;
prevStep(): void;
goToStep(step: Step): void;
reset(): void;
}
export const useOnboardingWizardStore = create<OnboardingWizardState>(
(set) => ({
currentStep: 1,
name: "",
role: "",
otherRole: "",
painPoints: [],
otherPainPoint: "",
setName(name) {
set({ name });
},
setRole(role) {
set({ role });
},
setOtherRole(otherRole) {
set({ otherRole });
},
togglePainPoint(painPoint) {
set((state) => {
const exists = state.painPoints.includes(painPoint);
return {
painPoints: exists
? state.painPoints.filter((p) => p !== painPoint)
: [...state.painPoints, painPoint],
};
});
},
setOtherPainPoint(otherPainPoint) {
set({ otherPainPoint });
},
nextStep() {
set((state) => ({
currentStep: Math.min(4, state.currentStep + 1) as Step,
}));
},
prevStep() {
set((state) => ({
currentStep: Math.max(1, state.currentStep - 1) as Step,
}));
},
goToStep(step) {
set({ currentStep: step });
},
reset() {
set({
currentStep: 1,
name: "",
role: "",
otherRole: "",
painPoints: [],
otherPainPoint: "",
});
},
}),
);

View File

@@ -0,0 +1,109 @@
import {
getV1OnboardingState,
postV1CompleteOnboardingStep,
postV1SubmitOnboardingProfile,
} from "@/app/api/__generated__/endpoints/onboarding/onboarding";
import { resolveResponse } from "@/app/api/helpers";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { Step, useOnboardingWizardStore } from "./store";
function parseStep(value: string | null): Step {
const n = Number(value);
if (n >= 1 && n <= 4) return n as Step;
return 1;
}
export function useOnboardingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { isLoggedIn } = useSupabase();
const currentStep = useOnboardingWizardStore((s) => s.currentStep);
const goToStep = useOnboardingWizardStore((s) => s.goToStep);
const reset = useOnboardingWizardStore((s) => s.reset);
const [isLoading, setIsLoading] = useState(true);
const hasSubmitted = useRef(false);
const hasInitialized = useRef(false);
// Initialise store from URL on mount, reset form data
useEffect(() => {
if (hasInitialized.current) return;
hasInitialized.current = true;
const urlStep = parseStep(searchParams.get("step"));
reset();
goToStep(urlStep);
}, [searchParams, reset, goToStep]);
// Sync store → URL when step changes
useEffect(() => {
const urlStep = parseStep(searchParams.get("step"));
if (currentStep !== urlStep) {
router.replace(`/onboarding?step=${currentStep}`, { scroll: false });
}
}, [currentStep, router, searchParams]);
// Check if onboarding already completed
useEffect(() => {
if (!isLoggedIn) return;
async function checkCompletion() {
try {
const onboarding = await resolveResponse(getV1OnboardingState());
if (onboarding.completedSteps.includes("VISIT_COPILOT")) {
router.replace("/copilot");
return;
}
} catch {
// If we can't check, show onboarding anyway
}
setIsLoading(false);
}
checkCompletion();
}, [isLoggedIn, router]);
// Submit profile when entering step 4
useEffect(() => {
if (currentStep !== 4 || hasSubmitted.current) return;
hasSubmitted.current = true;
const { name, role, otherRole, painPoints, otherPainPoint } =
useOnboardingWizardStore.getState();
const resolvedRole = role === "Other" ? otherRole : role;
const resolvedPainPoints = painPoints
.filter((p) => p !== "Something else")
.concat(
painPoints.includes("Something else") && otherPainPoint.trim()
? [otherPainPoint.trim()]
: [],
);
postV1SubmitOnboardingProfile({
user_name: name,
user_role: resolvedRole,
pain_points: resolvedPainPoints,
}).catch(() => {
// Best effort — profile data is non-critical for accessing copilot
});
}, [currentStep]);
async function handlePreparingComplete() {
for (let attempt = 0; attempt < 3; attempt++) {
try {
await postV1CompleteOnboardingStep({ step: "VISIT_COPILOT" });
router.replace("/copilot");
return;
} catch {
if (attempt < 2) await new Promise((r) => setTimeout(r, 1000));
}
}
router.replace("/copilot");
}
return {
currentStep,
isLoading,
handlePreparingComplete,
};
}

View File

@@ -9,7 +9,7 @@ export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
let next = "/";
let next = "/copilot";
if (code) {
const supabase = await getServerSupabase();
@@ -25,15 +25,9 @@ export async function GET(request: Request) {
const api = new BackendAPI();
await api.createUser();
// Get onboarding status from backend (includes chat flag evaluated for this user)
const { shouldShowOnboarding } = await getOnboardingStatus();
if (shouldShowOnboarding) {
next = "/onboarding";
revalidatePath("/onboarding", "layout");
} else {
next = "/";
revalidatePath(next, "layout");
}
next = shouldShowOnboarding ? "/onboarding" : "/copilot";
revalidatePath(next, "layout");
} catch (createUserError) {
console.error("Error creating user:", createUserError);

View File

@@ -1,13 +1,7 @@
"use client";
import { FeatureFlagPage } from "@/services/feature-flags/FeatureFlagPage";
import { Flag } from "@/services/feature-flags/use-get-flag";
import { CopilotPage } from "./CopilotPage";
export default function Page() {
return (
<FeatureFlagPage flag={Flag.CHAT} whenDisabled="/library">
<CopilotPage />
</FeatureFlagPage>
);
return <CopilotPage />;
}

View File

@@ -36,13 +36,11 @@ export async function login(email: string, password: string) {
const api = new BackendAPI();
await api.createUser();
// Get onboarding status from backend (includes chat flag evaluated for this user)
const { shouldShowOnboarding } = await getOnboardingStatus();
const next = shouldShowOnboarding ? "/onboarding" : "/";
return {
success: true,
next,
next: shouldShowOnboarding ? "/onboarding" : "/copilot",
};
} catch (err) {
Sentry.captureException(err);

View File

@@ -112,6 +112,7 @@ export function useLoginPage() {
: "Unexpected error during login",
variant: "destructive",
});
setIsLoading(false);
setIsLoggingIn(false);
}

View File

@@ -69,11 +69,12 @@ export async function signup(
};
}
// Get onboarding status from backend (includes chat flag evaluated for this user)
const { shouldShowOnboarding } = await getOnboardingStatus();
const next = shouldShowOnboarding ? "/onboarding" : "/";
return { success: true, next };
return {
success: true,
next: shouldShowOnboarding ? "/onboarding" : "/copilot",
};
} catch (err) {
Sentry.captureException(err);
return {

View File

@@ -106,8 +106,6 @@ export function useSignupPage() {
data.agreeToTerms,
);
setIsLoading(false);
if (!result.success) {
if (result.error === "user_already_exists") {
setFeedback("User with this email already exists");
@@ -141,6 +139,10 @@ export function useSignupPage() {
: "Unexpected error during signup",
variant: "destructive",
});
} finally {
setTimeout(() => {
setIsLoading(false);
}, 3000);
}
}

View File

@@ -1,8 +1,5 @@
import type { InfiniteData } from "@tanstack/react-query";
import {
getV1IsOnboardingEnabled,
getV1OnboardingState,
} from "./__generated__/endpoints/onboarding/onboarding";
import { getV1CheckIfOnboardingIsCompleted } from "./__generated__/endpoints/onboarding/onboarding";
import { Pagination } from "./__generated__/models/pagination";
export type OKData<TResponse extends { status: number; data?: any }> =
@@ -178,10 +175,8 @@ export async function resolveResponse<
}
export async function getOnboardingStatus() {
const status = await resolveResponse(getV1IsOnboardingEnabled());
const onboarding = await resolveResponse(getV1OnboardingState());
const isCompleted = onboarding.completedSteps.includes("CONGRATS");
const status = await resolveResponse(getV1CheckIfOnboardingIsCompleted());
return {
shouldShowOnboarding: status.is_onboarding_enabled && !isCompleted,
shouldShowOnboarding: !status.is_completed,
};
}

View File

@@ -5348,11 +5348,11 @@
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/onboarding/enabled": {
"/api/onboarding/completed": {
"get": {
"tags": ["v1", "onboarding", "public"],
"summary": "Is onboarding enabled",
"operationId": "getV1Is onboarding enabled",
"summary": "Check if onboarding is completed",
"operationId": "getV1Check if onboarding is completed",
"responses": {
"200": {
"description": "Successful Response",
@@ -5371,6 +5371,41 @@
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/onboarding/profile": {
"post": {
"tags": ["v1", "onboarding"],
"summary": "Submit onboarding profile",
"operationId": "postV1Submit onboarding profile",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OnboardingProfileRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/onboarding/reset": {
"post": {
"tags": ["v1", "onboarding"],
@@ -11182,18 +11217,40 @@
"title": "OAuthApplicationPublicInfo",
"description": "Public information about an OAuth application (for consent screen)"
},
"OnboardingStatusResponse": {
"OnboardingProfileRequest": {
"properties": {
"is_onboarding_enabled": {
"type": "boolean",
"title": "Is Onboarding Enabled"
"user_name": {
"type": "string",
"maxLength": 100,
"minLength": 1,
"title": "User Name"
},
"is_chat_enabled": { "type": "boolean", "title": "Is Chat Enabled" }
"user_role": {
"type": "string",
"maxLength": 100,
"minLength": 1,
"title": "User Role"
},
"pain_points": {
"items": { "type": "string" },
"type": "array",
"maxItems": 20,
"title": "Pain Points"
}
},
"type": "object",
"required": ["is_onboarding_enabled", "is_chat_enabled"],
"required": ["user_name", "user_role"],
"title": "OnboardingProfileRequest",
"description": "Request body for onboarding profile submission."
},
"OnboardingStatusResponse": {
"properties": {
"is_completed": { "type": "boolean", "title": "Is Completed" }
},
"type": "object",
"required": ["is_completed"],
"title": "OnboardingStatusResponse",
"description": "Response for onboarding status check."
"description": "Response for onboarding completion check."
},
"OnboardingStep": {
"type": "string",

View File

@@ -46,14 +46,6 @@ export default async function RootLayout({
className={`${fonts.poppins.variable} ${fonts.sans.variable} ${fonts.mono.variable}`}
suppressHydrationWarning
>
<head>
<SetupAnalytics
host={host}
ga={{
gaId: process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || "G-FH2XK2W4GN",
}}
/>
</head>
<body className="min-h-screen">
<ErrorBoundary context="application">
<Providers
@@ -63,6 +55,13 @@ export default async function RootLayout({
// enableSystem
disableTransitionOnChange
>
<SetupAnalytics
host={host}
ga={{
gaId:
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || "G-FH2XK2W4GN",
}}
/>
<div className="flex min-h-screen flex-col items-stretch justify-items-stretch">
{children}
<TallyPopupSimple />

View File

@@ -0,0 +1,121 @@
interface Props extends React.SVGProps<SVGSVGElement> {
hideText?: boolean;
}
export function AutoGPTLogo({ hideText = false, className, ...props }: Props) {
return (
<svg
viewBox="0 0 89 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-label="AutoGPT Logo"
className={className ?? "h-10 w-[5.5rem]"}
{...props}
>
<g id="AutoGPT-logo 1" clipPath="url(#clip0_3364_2463)">
<path
id="Vector"
d="M69.1364 28.8681V38.6414C69.1364 39.3617 68.5471 39.951 67.8301 39.951C67.0541 39.951 66.4124 39.4599 66.4124 38.6414V24.0584C66.4124 20.9644 68.9236 18.4531 72.0177 18.4531C75.1117 18.4531 77.623 20.9644 77.623 24.0584C77.623 27.1525 75.1117 29.6637 72.0177 29.6637C70.9634 29.6637 69.9812 29.3723 69.1397 28.8681H69.1364ZM70.2856 22.3231C71.2417 22.3231 72.0177 23.0991 72.0177 24.0552C72.0177 25.0112 71.2417 25.7872 70.2856 25.7872C70.1088 25.7872 69.9353 25.761 69.7749 25.7119C70.2824 26.3994 71.0976 26.8447 72.0177 26.8447C73.5565 26.8447 74.8039 25.5973 74.8039 24.0584C74.8039 22.5196 73.5565 21.2721 72.0177 21.2721C71.0976 21.2721 70.2824 21.7174 69.7749 22.405C69.9353 22.3559 70.1088 22.3297 70.2856 22.3297V22.3231Z"
fill="url(#paint0_linear_3364_2463)"
/>
<path
id="Vector_2"
d="M62.133 28.8675V35.144C62.133 35.7137 61.9005 36.2343 61.524 36.6075C60.6989 37.4326 59.1699 37.4326 58.3448 36.6075C57.2611 35.5238 58.2891 33.6903 56.3509 31.752C54.4126 29.8137 51.1974 29.8694 49.318 31.752C48.4504 32.6196 47.9102 33.8212 47.9102 35.144C47.9102 35.8643 48.4995 36.4536 49.2198 36.4536C49.999 36.4536 50.6375 35.9625 50.6375 35.144C50.6375 34.5743 50.87 34.057 51.2465 33.6805C52.0716 32.8554 53.6006 32.8554 54.4257 33.6805C55.6076 34.8624 54.4126 36.5289 56.4196 38.536C58.3022 40.4186 61.5731 40.4186 63.4524 38.536C64.3201 37.6683 64.8603 36.4667 64.8603 35.144V24.0545C64.8603 20.9605 62.3491 18.4492 59.255 18.4492C56.161 18.4492 53.6497 20.9605 53.6497 24.0545C53.6497 27.1486 56.161 29.6598 59.255 29.6598C60.3093 29.6598 61.2948 29.3684 62.133 28.8642V28.8675ZM59.255 26.8441C58.335 26.8441 57.5197 26.3988 57.0122 25.7112C57.1727 25.7603 57.3462 25.7865 57.523 25.7865C58.479 25.7865 59.255 25.0106 59.255 24.0545C59.255 23.0985 58.479 22.3225 57.523 22.3225C57.3462 22.3225 57.1727 22.3487 57.0122 22.3978C57.5197 21.7103 58.335 21.265 59.255 21.265C60.7938 21.265 62.0413 22.5124 62.0413 24.0512C62.0413 25.5901 60.7938 26.8375 59.255 26.8375V26.8441Z"
fill="url(#paint1_linear_3364_2463)"
/>
<path
id="Vector_3"
d="M81.709 12.959C81.709 9.51134 80.3371 6.24048 77.9045 3.80453C75.4685 1.36858 72.1977 0 68.75 0C65.3024 0 62.0315 1.37186 59.5956 3.80453C57.1596 6.24048 55.791 9.51461 55.791 12.959V13.5451C55.791 14.2948 56.4 14.9038 57.1498 14.9038C57.8996 14.9038 58.5085 14.2948 58.5085 13.5451V12.959C58.5085 10.2349 59.5956 7.64836 61.5175 5.72645C63.4394 3.80453 66.0259 2.71425 68.75 2.71425C71.4741 2.71425 74.0574 3.80126 75.9826 5.72645C77.9045 7.64836 78.9948 10.2349 78.9948 12.959C78.9948 13.7088 79.6037 14.3178 80.3535 14.3178C81.1033 14.3178 81.7123 13.7088 81.7123 12.959H81.709Z"
fill="url(#paint2_linear_3364_2463)"
/>
<path
id="Vector_4"
d="M81.7092 17.061V18.7341H83.8963C84.6232 18.7341 85.2191 19.33 85.2191 20.0569C85.2191 20.7837 84.6952 21.4582 83.8963 21.4582H81.7092V35.1964C81.7092 35.7661 81.9417 36.2834 82.3182 36.6599C83.1433 37.485 84.6723 37.485 85.4974 36.6599C85.8739 36.2834 86.1064 35.7661 86.1064 35.1964V34.738C86.1064 33.9228 86.7481 33.4284 87.5241 33.4284C88.2444 33.4284 88.8337 34.0177 88.8337 34.738V35.1964C88.8337 36.5192 88.2935 37.7208 87.4258 38.5884C85.5432 40.471 82.2822 40.471 80.3996 38.5884C79.5319 37.7208 78.9917 36.5192 78.9917 35.1964V17.061C78.9917 16.272 79.6171 15.7383 80.3832 15.7383C81.1493 15.7383 81.706 16.3342 81.706 17.061H81.7092Z"
fill="url(#paint3_linear_3364_2463)"
/>
<path
id="Vector_5"
d="M75.4293 38.6377C75.4293 39.358 74.8399 39.9441 74.1196 39.9441C73.3436 39.9441 72.7019 39.453 72.7019 38.6377V34.2013C72.7019 33.4809 73.2912 32.8916 74.0116 32.8916C74.7875 32.8916 75.4293 33.3827 75.4293 34.2013V38.6377Z"
fill="url(#paint4_linear_3364_2463)"
/>
{!hideText && (
<path
id="Vector_6"
d="M11.7672 22.2907V31.6252H8.94164V26.9399H2.82557V31.6252H0V22.2907C0 14.5998 11.7672 14.4983 11.7672 22.2907ZM44.3808 31.6252C48.5618 31.6252 51.9506 28.2365 51.9506 24.0554C51.9506 19.8744 48.5618 16.4857 44.3808 16.4857C40.1997 16.4857 36.811 19.8744 36.811 24.0554C36.811 28.2365 40.1997 31.6252 44.3808 31.6252ZM44.3808 28.7309C41.8008 28.7309 39.7086 26.6387 39.7086 24.0587C39.7086 21.4787 41.8008 19.3865 44.3808 19.3865C46.9608 19.3865 49.053 21.4787 49.053 24.0587C49.053 26.6387 46.9608 28.7309 44.3808 28.7309ZM37.3218 16.4857V19.2097H33.2095V31.6252H30.4854V19.2097H26.3731V16.4857H37.3218ZM25.0111 25.8202V16.4857H22.1855V25.8202C22.1855 30.0242 16.0661 29.9489 16.0661 25.8202V16.4857H13.2406V25.8202C13.2406 33.5111 25.0078 33.6126 25.0078 25.8202H25.0111ZM8.94164 24.2159V22.294C8.94164 18.09 2.8223 18.1653 2.8223 22.294V24.2159H8.94164Z"
fill="#000030"
/>
)}
<path
id="Vector_7"
d="M87.4713 32.257C88.2434 32.257 88.8693 31.6311 88.8693 30.859C88.8693 30.0869 88.2434 29.4609 87.4713 29.4609C86.6992 29.4609 86.0732 30.0869 86.0732 30.859C86.0732 31.6311 86.6992 32.257 87.4713 32.257Z"
fill="#669CF6"
/>
<path
id="Vector_8"
d="M49.2167 39.9475C49.9888 39.9475 50.6147 39.3215 50.6147 38.5494C50.6147 37.7773 49.9888 37.1514 49.2167 37.1514C48.4445 37.1514 47.8186 37.7773 47.8186 38.5494C47.8186 39.3215 48.4445 39.9475 49.2167 39.9475Z"
fill="#669CF6"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_3364_2463"
x1="62.7328"
y1="20.9589"
x2="62.7328"
y2="33.2932"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#000030" />
<stop offset="1" stopColor="#9900FF" />
</linearGradient>
<linearGradient
id="paint1_linear_3364_2463"
x1="47.5336"
y1="20.947"
x2="47.5336"
y2="33.2951"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#000030" />
<stop offset="1" stopColor="#4285F4" />
</linearGradient>
<linearGradient
id="paint2_linear_3364_2463"
x1="69.4138"
y1="6.17402"
x2="48.0898"
y2="-3.94009"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#4285F4" />
<stop offset="1" stopColor="#9900FF" />
</linearGradient>
<linearGradient
id="paint3_linear_3364_2463"
x1="74.2976"
y1="15.7136"
x2="74.2976"
y2="34.5465"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#000030" />
<stop offset="1" stopColor="#4285F4" />
</linearGradient>
<linearGradient
id="paint4_linear_3364_2463"
x1="64.3579"
y1="24.1914"
x2="65.0886"
y2="30.9756"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#4285F4" />
<stop offset="1" stopColor="#9900FF" />
</linearGradient>
<clipPath id="clip0_3364_2463">
<rect width="88.8696" height="40" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { Emoji } from "./Emoji";
const meta: Meta<typeof Emoji> = {
title: "Atoms/Emoji",
tags: ["autodocs"],
component: Emoji,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Renders emoji text as cross-platform SVG images using Twemoji.",
},
},
},
argTypes: {
text: {
control: "text",
description: "Emoji character(s) to render",
},
size: {
control: { type: "number", min: 12, max: 96, step: 4 },
description: "Size in pixels (width and height)",
},
},
args: {
text: "🚀",
size: 24,
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
text: "🚀",
},
};
export const Small: Story = {
args: {
text: "✨",
size: 16,
},
};
export const Large: Story = {
args: {
text: "🎉",
size: 48,
},
};
export const AllSizes: Story = {
render: renderAllSizes,
};
function renderAllSizes() {
const sizes = [16, 24, 32, 48, 64];
return (
<div className="flex items-end gap-4">
{sizes.map((size) => (
<div key={size} className="flex flex-col items-center gap-2">
<Emoji text="🔥" size={size} />
<span className="text-xs text-muted-foreground">{size}px</span>
</div>
))}
</div>
);
}
export const MultipleEmojis: Story = {
render: renderMultipleEmojis,
};
function renderMultipleEmojis() {
const emojis = ["😀", "🎯", "⚡", "🌈", "🤖", "💡", "🔧", "📦"];
return (
<div className="flex flex-wrap gap-3">
{emojis.map((emoji) => (
<Emoji key={emoji} text={emoji} size={32} />
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import twemoji from "twemoji";
interface Props {
text: string;
size?: number;
}
export function Emoji({ text, size = 24 }: Props) {
return (
<span
className="inline-flex items-center"
style={{ width: size, height: size }}
dangerouslySetInnerHTML={{
// twemoji.parse only converts emoji codepoints to <img> tags
// pointing to Twitter's CDN SVGs — it does not inject arbitrary HTML
__html: twemoji.parse(text, {
folder: "svg",
ext: ".svg",
attributes: () => ({
width: String(size),
height: String(size),
}),
}),
}}
/>
);
}

View File

@@ -0,0 +1,33 @@
import { FadeIn } from "./FadeIn";
import type { Meta, StoryObj } from "@storybook/nextjs";
const meta: Meta<typeof FadeIn> = {
title: "Atoms/FadeIn",
tags: ["autodocs"],
component: FadeIn,
parameters: {
layout: "centered",
docs: {
description: {
component:
"A wrapper that fades in its children with a subtle upward slide animation using framer-motion.",
},
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<FadeIn>
<div className="rounded-lg border border-zinc-200 bg-white p-8 text-center">
<p className="text-lg font-medium">This content fades in</p>
<p className="text-sm text-zinc-500">
With a subtle upward slide animation
</p>
</div>
</FadeIn>
),
};

View File

@@ -0,0 +1,22 @@
"use client";
import { motion } from "framer-motion";
import { ReactNode } from "react";
interface Props {
children: ReactNode;
onComplete?: () => void;
}
export function FadeIn({ children, onComplete }: Props) {
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
onAnimationComplete={onComplete}
>
{children}
</motion.div>
);
}

View File

@@ -225,7 +225,7 @@ export function Input({
return hideLabel ? (
inputWithError
) : (
<label htmlFor={props.id} className="flex flex-col gap-2">
<label htmlFor={props.id} className="flex w-full flex-col gap-2">
<div className="flex items-center justify-between">
<Text variant="large-medium" as="span" className="text-black">
{label}

View File

@@ -2,14 +2,14 @@
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
import { okData } from "@/app/api/helpers";
import { IconAutoGPTLogo, IconType } from "@/components/__legacy__/ui/icons";
import { IconType } from "@/components/__legacy__/ui/icons";
import { AutoGPTLogo } from "@/components/atoms/AutoGPTLogo/AutoGPTLogo";
import { PreviewBanner } from "@/components/layout/Navbar/components/PreviewBanner/PreviewBanner";
import { isLogoutInProgress } from "@/lib/autogpt-server-api/helpers";
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { environment } from "@/services/environment";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { AccountMenu } from "./components/AccountMenu/AccountMenu";
import { FeedbackButton } from "./components/FeedbackButton";
import { AgentActivityDropdown } from "./components/AgentActivityDropdown/AgentActivityDropdown";
@@ -25,7 +25,6 @@ export function Navbar() {
const breakpoint = useBreakpoint();
const isSmallScreen = breakpoint === "sm" || breakpoint === "base";
const dynamicMenuItems = getAccountMenuItems(user?.role);
const isChatEnabled = useGetFlag(Flag.CHAT);
const previewBranchName = environment.getPreviewStealingDev();
const logoutInProgress = isLogoutInProgress();
@@ -44,11 +43,9 @@ export function Navbar() {
const shouldShowPreviewBanner = Boolean(isLoggedIn && previewBranchName);
const homeHref = isChatEnabled === true ? "/copilot" : "/library";
const actualLoggedInLinks = [
{ name: "Home", href: homeHref },
...(isChatEnabled === true ? [{ name: "Agents", href: "/library" }] : []),
{ name: "Home", href: "/copilot" },
{ name: "Agents", href: "/library" },
...loggedInLinks,
];
@@ -88,8 +85,8 @@ export function Navbar() {
) : null}
{/* Centered logo */}
<div className="static h-auto w-[4.5rem] md:absolute md:left-1/2 md:top-1/2 md:w-[5.5rem] md:-translate-x-1/2 md:-translate-y-1/2">
<IconAutoGPTLogo className="h-full w-full" />
<div className="static md:absolute md:left-1/2 md:top-1/2 md:-translate-x-1/2 md:-translate-y-1/2">
<AutoGPTLogo className="h-auto w-[4.5rem] md:w-[5.5rem]" />
</div>
{/* Right section */}

View File

@@ -1,7 +1,6 @@
"use client";
import { cn } from "@/lib/utils";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { Laptop, ListChecksIcon } from "@phosphor-icons/react/dist/ssr";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -22,12 +21,10 @@ interface Props {
export function NavbarLink({ name, href }: Props) {
const pathname = usePathname();
const isChatEnabled = useGetFlag(Flag.CHAT);
const expectedHomeRoute = isChatEnabled ? "/copilot" : "/library";
const isActive =
href === expectedHomeRoute
? pathname === "/" || pathname.startsWith(expectedHomeRoute)
href === "/copilot"
? pathname === "/" || pathname.startsWith("/copilot")
: pathname.includes(href);
return (
@@ -77,24 +74,14 @@ export function NavbarLink({ name, href }: Props) {
<HomepageIcon />
</div>
)}
{href === "/library" &&
(isChatEnabled ? (
<ListChecksIcon
className={cn(
"h-5 w-5 shrink-0",
isActive && "text-white dark:text-black",
)}
/>
) : (
<div
className={cn(
iconNudgedClass,
isActive && "text-white dark:text-black",
)}
>
<HomepageIcon />
</div>
))}
{href === "/library" && (
<ListChecksIcon
className={cn(
"h-5 w-5 shrink-0",
isActive && "text-white dark:text-black",
)}
/>
)}
<Text
variant="h5"
className={cn(

View File

@@ -0,0 +1,56 @@
import { TypingText } from "./TypingText";
import type { Meta, StoryObj } from "@storybook/nextjs";
const meta: Meta<typeof TypingText> = {
title: "Molecules/TypingText",
tags: ["autodocs"],
component: TypingText,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Animates text appearing character by character with a blinking cursor. Useful for loading states and onboarding flows.",
},
},
},
argTypes: {
text: { control: "text" },
active: { control: "boolean" },
speed: { control: { type: "range", min: 10, max: 100, step: 5 } },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
text: "Personalizing your experience...",
active: true,
speed: 30,
},
};
export const Fast: Story = {
args: {
text: "This types out quickly!",
active: true,
speed: 15,
},
};
export const Slow: Story = {
args: {
text: "This types out slowly...",
active: true,
speed: 80,
},
};
export const Inactive: Story = {
args: {
text: "This text is hidden because active is false",
active: false,
},
};

View File

@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
interface Props {
text: string;
active: boolean;
speed?: number;
delay?: number;
}
export function TypingText({ text, active, speed = 30, delay = 0 }: Props) {
const [charCount, setCharCount] = useState(0);
useEffect(() => {
if (!active) {
setCharCount(0);
return;
}
setCharCount(0);
const timeout = setTimeout(() => {
const interval = setInterval(() => {
setCharCount((prev) => {
if (prev >= text.length) {
clearInterval(interval);
return prev;
}
return prev + 1;
});
}, speed);
cleanupRef = () => clearInterval(interval);
}, delay);
let cleanupRef = () => {};
return () => {
clearTimeout(timeout);
cleanupRef();
};
}, [active, text, speed, delay]);
if (!active) return null;
return (
<>
{text.slice(0, charCount)}
{charCount < text.length && (
<span className="inline-block h-4 w-[2px] animate-pulse bg-current" />
)}
</>
);
}

View File

@@ -36,7 +36,7 @@ export function shouldRedirectFromOnboarding(
pathname: string,
): boolean {
return (
completedSteps.includes("CONGRATS") &&
completedSteps.includes("VISIT_COPILOT") &&
!pathname.startsWith("/onboarding/reset")
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import {
getV1IsOnboardingEnabled,
getV1CheckIfOnboardingIsCompleted,
getV1OnboardingState,
patchV1UpdateOnboardingState,
postV1CompleteOnboardingStep,
@@ -142,13 +142,16 @@ export default function OnboardingProvider({
async function initializeOnboarding() {
try {
// Check onboarding enabled only for onboarding routes
if (isOnOnboardingRoute) {
const enabled = await resolveResponse(getV1IsOnboardingEnabled());
if (!enabled) {
router.push("/");
return;
}
const { is_completed } = await resolveResponse(
getV1CheckIfOnboardingIsCompleted(),
);
if (!is_completed && !isOnOnboardingRoute) {
router.replace("/onboarding");
return;
} else if (is_completed && isOnOnboardingRoute) {
router.replace("/copilot");
return;
}
const onboarding = await fetchOnboarding();
@@ -158,7 +161,7 @@ export default function OnboardingProvider({
isOnOnboardingRoute &&
shouldRedirectFromOnboarding(onboarding.completedSteps, pathname)
) {
router.push("/");
router.replace("/copilot");
}
} catch (error) {
console.error("Failed to initialize onboarding:", error);

View File

@@ -13,8 +13,7 @@ export enum Flag {
AGENT_FAVORITING = "agent-favoriting",
MARKETPLACE_SEARCH_TERMS = "marketplace-search-terms",
ENABLE_PLATFORM_PAYMENT = "enable-platform-payment",
CHAT = "chat",
CHAT_MODE_OPTION = "copilot-fast-mode-option",
CHAT_MODE_OPTION = "chat-mode-option",
}
const isPwMockEnabled = process.env.NEXT_PUBLIC_PW_TEST === "true";
@@ -28,7 +27,6 @@ const defaultFlags = {
[Flag.AGENT_FAVORITING]: false,
[Flag.MARKETPLACE_SEARCH_TERMS]: DEFAULT_SEARCH_TERMS,
[Flag.ENABLE_PLATFORM_PAYMENT]: false,
[Flag.CHAT]: false,
[Flag.CHAT_MODE_OPTION]: false,
};

View File

@@ -0,0 +1,120 @@
import test, { expect } from "@playwright/test";
import { signupTestUser } from "./utils/signup";
import { completeOnboardingWizard } from "./utils/onboarding";
import { getSelectors } from "./utils/selectors";
test("new user completes full onboarding wizard", async ({ page }) => {
// Signup WITHOUT skipping onboarding (ignoreOnboarding=false)
await signupTestUser(page, undefined, undefined, false);
// Should be on onboarding
await expect(page).toHaveURL(/\/onboarding/);
// Complete the wizard
await completeOnboardingWizard(page, {
name: "Alice",
role: "Marketing",
painPoints: ["Social media", "Email & outreach"],
});
// Should have been redirected to /copilot
await expect(page).toHaveURL(/\/copilot/);
// User should be authenticated
await page
.getByTestId("profile-popout-menu-trigger")
.waitFor({ state: "visible", timeout: 10000 });
});
test("onboarding wizard step navigation works", async ({ page }) => {
await signupTestUser(page, undefined, undefined, false);
await expect(page).toHaveURL(/\/onboarding/);
// Step 1: Welcome
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
await page.getByLabel("Your first name").fill("Bob");
await page.getByRole("button", { name: "Continue" }).click();
// Step 2: Role — verify we're here, then go back
await expect(page.getByText("What best describes you")).toBeVisible();
await page.getByText("Back").click();
// Should be back on step 1 with name preserved
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
await expect(page.getByLabel("Your first name")).toHaveValue("Bob");
});
test("onboarding wizard validates required fields", async ({ page }) => {
await signupTestUser(page, undefined, undefined, false);
await expect(page).toHaveURL(/\/onboarding/);
// Step 1: Continue should be disabled without a name
const continueButton = page.getByRole("button", { name: "Continue" });
await expect(continueButton).toBeDisabled();
// Fill name — continue should become enabled
await page.getByLabel("Your first name").fill("Charlie");
await expect(continueButton).toBeEnabled();
await continueButton.click();
// Step 2: Continue should be disabled without a role
const step2Continue = page.getByRole("button", { name: "Continue" });
await expect(step2Continue).toBeDisabled();
// Select role — continue should become enabled
await page.getByText("Engineering").click();
await expect(step2Continue).toBeEnabled();
await step2Continue.click();
// Step 3: Launch Autopilot should be disabled without any pain points
const launchButton = page.getByRole("button", { name: "Launch Autopilot" });
await expect(launchButton).toBeDisabled();
// Select a pain point — button should become enabled
await page.getByText("Research", { exact: true }).click();
await expect(launchButton).toBeEnabled();
});
test("completed onboarding redirects away from /onboarding", async ({
page,
}) => {
// Create user and complete onboarding
await signupTestUser(page, undefined, undefined, false);
await completeOnboardingWizard(page);
// Try to navigate back to onboarding — should be redirected to /copilot
await page.goto("http://localhost:3000/onboarding");
await page.waitForURL(/\/copilot/, { timeout: 10000 });
});
test("onboarding URL params sync with steps", async ({ page }) => {
await signupTestUser(page, undefined, undefined, false);
await expect(page).toHaveURL(/\/onboarding/);
// Step 1: URL may or may not include step=1 on initial load (no param is equivalent to step 1)
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible();
// Fill name and go to step 2
await page.getByLabel("Your first name").fill("Test");
await page.getByRole("button", { name: "Continue" }).click();
// URL should show step=2
await expect(page).toHaveURL(/step=2/);
});
test("role-based pain point ordering works", async ({ page }) => {
await signupTestUser(page, undefined, undefined, false);
// Complete step 1
await page.getByLabel("Your first name").fill("Test");
await page.getByRole("button", { name: "Continue" }).click();
// Select Sales/BD role
await page.getByText("Sales / BD").click();
await page.getByRole("button", { name: "Continue" }).click();
// On pain points step, "Finding leads" should be visible (top pick for Sales)
await expect(page.getByText("What's eating your time?")).toBeVisible();
const { getText } = getSelectors(page);
await expect(getText("Finding leads")).toBeVisible();
});

View File

@@ -1,4 +1,5 @@
import { Page } from "@playwright/test";
import { skipOnboardingIfPresent } from "../utils/onboarding";
export class LoginPage {
constructor(private page: Page) {}
@@ -64,6 +65,9 @@ export class LoginPage {
await new Promise((resolve) => setTimeout(resolve, 200)); // allow time for client-side redirect
await this.page.waitForLoadState("load", { timeout: 10_000 });
// If redirected to onboarding, complete it via API so tests can proceed
await skipOnboardingIfPresent(this.page, "/marketplace");
console.log("➡️ Navigating to /marketplace ...");
await this.page.goto("/marketplace", { timeout: 20_000 });
console.log("✅ Login process complete");

View File

@@ -182,10 +182,10 @@ test("logged in user is redirected from /login to /copilot", async ({
await hasUrl(page, "/marketplace");
await page.goto("/login");
await hasUrl(page, "/library?sort=updatedAt");
await hasUrl(page, "/copilot");
});
test("logged in user is redirected from /signup to /library", async ({
test("logged in user is redirected from /signup to /copilot", async ({
page,
}) => {
const testUser = await getTestUser();
@@ -195,5 +195,5 @@ test("logged in user is redirected from /signup to /library", async ({
await hasUrl(page, "/marketplace");
await page.goto("/signup");
await hasUrl(page, "/library?sort=updatedAt");
await hasUrl(page, "/copilot");
});

View File

@@ -0,0 +1,81 @@
import { Page, expect } from "@playwright/test";
/**
* Complete the onboarding wizard via API.
* Use this when a test needs an authenticated user who has already finished onboarding
* (e.g., tests that navigate to marketplace, library, or build pages).
*
* The function sends a POST request to the onboarding completion endpoint using
* the page's request context, which inherits the browser's auth cookies.
*/
export async function completeOnboardingViaAPI(page: Page) {
await page.request.post(
"http://localhost:3000/api/proxy/api/onboarding/step?step=VISIT_COPILOT",
{ headers: { "Content-Type": "application/json" } },
);
}
/**
* Handle the onboarding redirect that occurs after login/signup.
* If the page is on /onboarding, completes onboarding via API and navigates
* to the given destination. If already past onboarding, does nothing.
*/
export async function skipOnboardingIfPresent(
page: Page,
destination: string = "/marketplace",
) {
const url = page.url();
if (!url.includes("/onboarding")) return;
await completeOnboardingViaAPI(page);
await page.goto(`http://localhost:3000${destination}`);
await page.waitForLoadState("domcontentloaded", { timeout: 10000 });
}
/**
* Walk through the full 4-step onboarding wizard in the browser.
* Returns the data that was entered so tests can verify it was submitted.
*/
export async function completeOnboardingWizard(
page: Page,
options?: {
name?: string;
role?: string;
painPoints?: string[];
},
) {
const name = options?.name ?? "TestUser";
const role = options?.role ?? "Engineering";
const painPoints = options?.painPoints ?? ["Research", "Reports & data"];
// Step 1: Welcome — enter name
await expect(page.getByText("Welcome to AutoGPT")).toBeVisible({
timeout: 10000,
});
await page.getByLabel("Your first name").fill(name);
await page.getByRole("button", { name: "Continue" }).click();
// Step 2: Role — select a role
await expect(page.getByText("What best describes you")).toBeVisible({
timeout: 5000,
});
await page.getByText(role, { exact: false }).click();
await page.getByRole("button", { name: "Continue" }).click();
// Step 3: Pain points — select tasks
await expect(page.getByText("What's eating your time?")).toBeVisible({
timeout: 5000,
});
for (const point of painPoints) {
await page.getByText(point, { exact: true }).click();
}
await page.getByRole("button", { name: "Launch Autopilot" }).click();
// Step 4: Preparing — wait for animation to complete and redirect to /copilot
await expect(page.getByText("Preparing your workspace")).toBeVisible({
timeout: 5000,
});
await page.waitForURL(/\/copilot/, { timeout: 15000 });
return { name, role, painPoints };
}

View File

@@ -1,5 +1,6 @@
import { Page } from "@playwright/test";
import { LoginPage } from "../pages/login.page";
import { skipOnboardingIfPresent } from "./onboarding";
type TestUser = {
email: string;
@@ -25,8 +26,15 @@ export class SigninUtils {
await this.page.goto("/login");
await this.loginPage.login(testUser.email, testUser.password);
// Verify we're on marketplace
await this.page.waitForURL("/marketplace");
// Wait for redirect — could land on /onboarding, /marketplace, or /copilot
await this.page.waitForURL(
(url: URL) =>
/\/(onboarding|marketplace|copilot|library)/.test(url.pathname),
{ timeout: 15000 },
);
// Skip onboarding if present
await skipOnboardingIfPresent(this.page, "/marketplace");
// Verify profile menu is visible (user is authenticated)
await this.page.getByTestId("profile-popout-menu-trigger").waitFor({

View File

@@ -2,6 +2,7 @@ import { TestUser } from "./auth";
import { getSelectors } from "./selectors";
import { isVisible } from "./assertion";
import { BuildPage } from "../pages/build.page";
import { skipOnboardingIfPresent } from "./onboarding";
export async function signupTestUser(
page: any,
@@ -58,18 +59,15 @@ export async function signupTestUser(
// Handle onboarding redirect if needed
if (currentUrl.includes("/onboarding") && ignoreOnboarding) {
await page.goto("http://localhost:3000/marketplace");
await page.waitForLoadState("domcontentloaded", { timeout: 10000 });
await skipOnboardingIfPresent(page, "/marketplace");
}
// Verify we're on an expected final page and user is authenticated
if (currentUrl.includes("/copilot") || currentUrl.includes("/library")) {
// For copilot/library landing pages, just verify user is authenticated
await page
.getByTestId("profile-popout-menu-trigger")
.waitFor({ state: "visible", timeout: 10000 });
} else if (ignoreOnboarding || currentUrl.includes("/marketplace")) {
// Verify we're on marketplace
await page
.getByText(
"Bringing you AI agents designed by thinkers from around the world",
@@ -77,7 +75,6 @@ export async function signupTestUser(
.first()
.waitFor({ state: "visible", timeout: 10000 });
// Verify user is authenticated (profile menu visible)
await page
.getByTestId("profile-popout-menu-trigger")
.waitFor({ state: "visible", timeout: 10000 });