feat(platform): WebSocket Onboarding notifications (#11335)

Use WebSocket notifications from the backend to display confetti.

### Changes 🏗️

- Send WebSocket notifications to the browser when new onboarding steps
are completed
- Handle WebSocket notifications events in the Wallet and use them
instead of frontend-based logic to play confetti (fixes confetti
appearing on every refresh)
- Scroll to newly completed tasks when wallet opens just before confetti
plays
- Fix: make `Run again` button complete `RE_RUN_AGENT` task

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] Confetti are displayed when previously uncompleted tasks are
completed
  - [x] Confetti do not appear on page refresh
  - [x] Wallet scrolls on open before confetti is displayed
  - [x] `Run again` button completes `RE_RUN_AGENT` task
This commit is contained in:
Krzysztof Czerwinski
2025-11-13 18:45:13 +09:00
committed by GitHub
parent be01a1316a
commit 27d886f05c
5 changed files with 93 additions and 74 deletions

View File

@@ -11,6 +11,11 @@ from prisma.types import UserOnboardingCreateInput, UserOnboardingUpdateInput
from backend.data.block import get_blocks
from backend.data.credit import get_user_credit_model
from backend.data.model import CredentialsMetaInput
from backend.data.notification_bus import (
AsyncRedisNotificationEventBus,
NotificationEvent,
)
from backend.server.model import OnboardingNotificationPayload
from backend.server.v2.store.model import StoreAgentDetails
from backend.util.cache import cached
from backend.util.json import SafeJson
@@ -82,22 +87,10 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
update["completedSteps"] = list(
set(data.completedSteps + onboarding.completedSteps)
)
for step in (
OnboardingStep.AGENT_NEW_RUN,
OnboardingStep.MARKETPLACE_VISIT,
OnboardingStep.MARKETPLACE_ADD_AGENT,
OnboardingStep.MARKETPLACE_RUN_AGENT,
OnboardingStep.BUILDER_SAVE_AGENT,
OnboardingStep.RE_RUN_AGENT,
OnboardingStep.SCHEDULE_AGENT,
OnboardingStep.RUN_AGENTS,
OnboardingStep.RUN_3_DAYS,
OnboardingStep.TRIGGER_WEBHOOK,
OnboardingStep.RUN_14_DAYS,
OnboardingStep.RUN_AGENTS_100,
):
if step in data.completedSteps:
await reward_user(user_id, step, onboarding)
for step in data.completedSteps:
if step not in onboarding.completedSteps:
await _reward_user(user_id, onboarding, step)
await _send_onboarding_notification(user_id, step)
if data.walletShown:
update["walletShown"] = data.walletShown
if data.notified is not None:
@@ -130,7 +123,7 @@ async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate):
)
async def reward_user(user_id: str, step: OnboardingStep, onboarding: UserOnboarding):
async def _reward_user(user_id: str, onboarding: UserOnboarding, step: OnboardingStep):
reward = 0
match step:
# Reward user when they clicked New Run during onboarding
@@ -180,20 +173,32 @@ async def reward_user(user_id: str, step: OnboardingStep, onboarding: UserOnboar
)
async def complete_webhook_trigger_step(user_id: str):
async def complete_onboarding_step(user_id: str, step: OnboardingStep):
"""
Completes the TRIGGER_WEBHOOK onboarding step for the user if not already completed.
Completes the specified onboarding step for the user if not already completed.
"""
onboarding = await get_user_onboarding(user_id)
if OnboardingStep.TRIGGER_WEBHOOK not in onboarding.completedSteps:
if step not in onboarding.completedSteps:
await update_user_onboarding(
user_id,
UserOnboardingUpdate(
completedSteps=onboarding.completedSteps
+ [OnboardingStep.TRIGGER_WEBHOOK]
),
UserOnboardingUpdate(completedSteps=onboarding.completedSteps + [step]),
)
await _send_onboarding_notification(user_id, step)
async def _send_onboarding_notification(user_id: str, step: OnboardingStep):
"""
Sends an onboarding notification to the user for the specified step.
"""
payload = OnboardingNotificationPayload(
type="onboarding",
event="step_completed",
step=step.value,
)
await AsyncRedisNotificationEventBus().publish(
NotificationEvent(user_id=user_id, payload=payload)
)
def clean_and_split(text: str) -> list[str]:

View File

@@ -33,7 +33,7 @@ from backend.data.model import (
OAuth2Credentials,
UserIntegrations,
)
from backend.data.onboarding import complete_webhook_trigger_step
from backend.data.onboarding import OnboardingStep, complete_onboarding_step
from backend.data.user import get_user_integrations
from backend.executor.utils import add_graph_execution
from backend.integrations.ayrshare import AyrshareClient, SocialPlatform
@@ -376,7 +376,7 @@ async def webhook_ingress_generic(
if not (webhook.triggered_nodes or webhook.triggered_presets):
return
await complete_webhook_trigger_step(user_id)
await complete_onboarding_step(user_id, OnboardingStep.TRIGGER_WEBHOOK)
# Execute all triggers concurrently for better performance
tasks = []

View File

@@ -38,6 +38,7 @@ import { AgentRunStatus, agentRunStatusMap } from "./agent-run-status-chip";
import useCredits from "@/hooks/useCredits";
import { AgentRunOutputView } from "./agent-run-output-view";
import { analytics } from "@/services/analytics";
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
export function AgentRunDetailsView({
agent,
@@ -64,6 +65,8 @@ export function AgentRunDetailsView({
[run],
);
const { completeStep } = useOnboarding();
const toastOnFail = useToastOnFail();
const infoStats: { label: string; value: React.ReactNode }[] = useMemo(() => {
@@ -154,6 +157,7 @@ export function AgentRunDetailsView({
name: graph.name,
id: graph.id,
});
completeStep("RE_RUN_AGENT");
onRun(id);
})
.catch(toastOnFail("execute agent"));

View File

@@ -8,7 +8,10 @@ import {
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
import { Text } from "@/components/atoms/Text/Text";
import useCredits from "@/hooks/useCredits";
import { OnboardingStep } from "@/lib/autogpt-server-api";
import {
OnboardingStep,
WebSocketNotification,
} from "@/lib/autogpt-server-api";
import { cn } from "@/lib/utils";
import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
@@ -20,6 +23,7 @@ import * as party from "party-js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { WalletRefill } from "./components/WalletRefill";
import { TaskGroups } from "./components/WalletTaskGroups";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
export interface Task {
id: OnboardingStep;
@@ -163,6 +167,7 @@ export function Wallet() {
];
}, [state]);
const api = useBackendAPI();
const { credits, formatCredits, fetchCredits } = useCredits({
fetchInitialCredits: true,
});
@@ -176,12 +181,8 @@ export function Wallet() {
return groups.reduce((acc, group) => acc + group.tasks.length, 0);
}, [groups]);
// Get total completed count for all groups
// Total completed task count across all groups
const [completedCount, setCompletedCount] = useState<number | null>(null);
// Needed to show confetti when a new step is completed
const [prevCompletedCount, setPrevCompletedCount] = useState<number | null>(
null,
);
const walletRef = useRef<HTMLButtonElement | null>(null);
@@ -250,47 +251,45 @@ export function Wallet() {
);
// Confetti effect on the wallet button
const handleNotification = useCallback(
(notification: WebSocketNotification) => {
if (notification.type !== "onboarding") {
return;
}
if (walletRef.current) {
// Fix confetti appearing in the top left corner
const rect = walletRef.current.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return;
}
fetchCredits();
party.confetti(walletRef.current!, {
count: 30,
spread: 120,
shapes: ["square", "circle"],
size: party.variation.range(1, 2),
speed: party.variation.range(200, 300),
modules: [fadeOut],
});
}
},
[],
);
// WebSocket setup for onboarding notifications
useEffect(() => {
// It's enough to check completed count,
// because the order of completed steps is not important
// If the count is the same, we don't need to do anything
if (completedCount === null || completedCount === prevCompletedCount) {
return;
}
// Otherwise, we need to set the new prevCompletedCount
setPrevCompletedCount(completedCount);
// If there was no previous count, we don't show confetti
if (prevCompletedCount === null || !walletRef.current) {
return;
}
setTimeout(() => {
fetchCredits();
if (!walletRef.current) {
return;
}
// Fix confetti appearing in the top left corner
const rect = walletRef.current.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
return;
}
party.confetti(walletRef.current, {
count: 30,
spread: 120,
shapes: ["square", "circle"],
size: party.variation.range(1, 2),
speed: party.variation.range(200, 300),
modules: [fadeOut],
});
}, 800);
}, [
state?.completedSteps,
state?.notified,
fadeOut,
fetchCredits,
completedCount,
prevCompletedCount,
walletRef,
]);
const detachMessage = api.onWebSocketMessage(
"notification",
handleNotification,
);
api.connectWebSocket();
return () => {
detachMessage();
};
}, [api, handleNotification]);
// Wallet flash on credits change
useEffect(() => {

View File

@@ -70,6 +70,14 @@ export function TaskGroups({ groups }: Props) {
}
};
const scrollIntoViewCentered = useCallback((el: HTMLDivElement) => {
el.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
}, []);
const delayConfetti = useCallback((el: HTMLDivElement, count: number) => {
setTimeout(() => {
if (!el) return;
@@ -101,7 +109,8 @@ export function TaskGroups({ groups }: Props) {
if (groupCompleted) {
const el = refs.current[group.name];
if (el && !alreadyCelebrated) {
delayConfetti(el, 50);
scrollIntoViewCentered(el);
delayConfetti(el, 600);
// Update the state to include all group tasks as notified
// This ensures that the confetti effect isn't perpetually triggered on Wallet
const notifiedTasks = group.tasks.map((task) => task.id);
@@ -115,7 +124,8 @@ export function TaskGroups({ groups }: Props) {
group.tasks.forEach((task) => {
const el = refs.current[task.id];
if (el && isTaskCompleted(task) && !state?.notified.includes(task.id)) {
delayConfetti(el, 40);
scrollIntoViewCentered(el);
delayConfetti(el, 400);
// Update the state to include the task as notified
updateState({ notified: [...(state?.notified || []), task.id] });
}
@@ -129,6 +139,7 @@ export function TaskGroups({ groups }: Props) {
state?.notified,
isGroupCompleted,
isTaskCompleted,
scrollIntoViewCentered,
]);
return (