+ {/* Timestamps section */}
+
+
+
+ Started At:
+
+
+ {result.started_at
+ ? new Date(
+ result.started_at,
+ ).toLocaleString()
+ : "—"}
+
+
+
+
+ Ended At:
+
+
+ {result.ended_at
+ ? new Date(result.ended_at).toLocaleString()
+ : "—"}
+
+
+
+
{result.summary_text && (
- Execution Accuracy Trends
+
+
Execution Accuracy Trends
+
+
+ Chart Filters (matches monitoring system):
+
+
+ Only days with ≥1 execution with correctness score
+ Last 30 days
+ Averages calculated from scored executions only
+
+
+
{/* Alert Section */}
{trendsData.alert && (
diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts
index 13f8d988fe..e7e2997d0d 100644
--- a/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/auth/callback/route.ts
@@ -1,15 +1,15 @@
-import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
+import { getOnboardingStatus } from "@/app/api/helpers";
import BackendAPI from "@/lib/autogpt-server-api";
-import { NextResponse } from "next/server";
+import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { revalidatePath } from "next/cache";
-import { shouldShowOnboarding } from "@/app/api/helpers";
+import { NextResponse } from "next/server";
// Handle the callback to complete the user session login
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
- let next = "/marketplace";
+ let next = "/";
if (code) {
const supabase = await getServerSupabase();
@@ -25,11 +25,14 @@ export async function GET(request: Request) {
const api = new BackendAPI();
await api.createUser();
- if (await shouldShowOnboarding()) {
+ // Get onboarding status from backend (includes chat flag evaluated for this user)
+ const { shouldShowOnboarding } = await getOnboardingStatus();
+ if (shouldShowOnboarding) {
next = "/onboarding";
revalidatePath("/onboarding", "layout");
} else {
- revalidatePath("/", "layout");
+ next = "/";
+ revalidatePath(next, "layout");
}
} catch (createUserError) {
console.error("Error creating user:", createUserError);
diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts
index 41d05a9afb..fd67519957 100644
--- a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts
@@ -1,6 +1,17 @@
import { OAuthPopupResultMessage } from "./types";
import { NextResponse } from "next/server";
+/**
+ * Safely encode a value as JSON for embedding in a script tag.
+ * Escapes characters that could break out of the script context to prevent XSS.
+ */
+function safeJsonStringify(value: unknown): string {
+ return JSON.stringify(value)
+ .replace(//g, "\\u003e")
+ .replace(/&/g, "\\u0026");
+}
+
// This route is intended to be used as the callback for integration OAuth flows,
// controlled by the CredentialsInput component. The CredentialsInput opens the login
// page in a pop-up window, which then redirects to this route to close the loop.
@@ -23,12 +34,13 @@ export async function GET(request: Request) {
console.debug("Sending message to opener:", message);
// Return a response with the message as JSON and a script to close the window
+ // Use safeJsonStringify to prevent XSS by escaping <, >, and & characters
return new NextResponse(
`
diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx
index 3372772c89..9e2e637ef6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/setup-wizard/page.tsx
@@ -1,22 +1,22 @@
"use client";
-import Image from "next/image";
-import Link from "next/link";
-import { useSearchParams } from "next/navigation";
-import { useState, useMemo, useRef } from "react";
-import { AuthCard } from "@/components/auth/AuthCard";
-import { Text } from "@/components/atoms/Text/Text";
+import { useGetOauthGetOauthAppInfo } from "@/app/api/__generated__/endpoints/oauth/oauth";
+import { okData } from "@/app/api/helpers";
import { Button } from "@/components/atoms/Button/Button";
+import { Text } from "@/components/atoms/Text/Text";
+import { AuthCard } from "@/components/auth/AuthCard";
+import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
-import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import type {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
CredentialsType,
} from "@/lib/autogpt-server-api";
import { CheckIcon, CircleIcon } from "@phosphor-icons/react";
-import { useGetOauthGetOauthAppInfo } from "@/app/api/__generated__/endpoints/oauth/oauth";
-import { okData } from "@/app/api/helpers";
+import Image from "next/image";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { useMemo, useRef, useState } from "react";
// All credential types - we accept any type of credential
const ALL_CREDENTIAL_TYPES: CredentialsType[] = [
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/BuilderActions.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/BuilderActions.tsx
index 64eb624621..86e4a3eb9c 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/BuilderActions.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/BuilderActions.tsx
@@ -10,7 +10,10 @@ export const BuilderActions = memo(() => {
flowID: parseAsString,
});
return (
-
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx
index de56bb46b8..8ec1ba8be3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx
@@ -1,11 +1,6 @@
import { BlockUIType } from "@/app/(platform)/build/components/types";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
-import {
- globalRegistry,
- OutputActions,
- OutputItem,
-} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
import { Label } from "@/components/__legacy__/ui/label";
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
import {
@@ -23,6 +18,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
+import {
+ globalRegistry,
+ OutputActions,
+ OutputItem,
+} from "@/components/contextual/OutputRenderers";
import { BookOpenIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useShallow } from "zustand/react/shallow";
@@ -38,8 +38,12 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
return outputNodes
.map((node) => {
- const executionResult = node.data.nodeExecutionResult;
- const outputData = executionResult?.output_data?.output;
+ const executionResults = node.data.nodeExecutionResults || [];
+ const latestResult =
+ executionResults.length > 0
+ ? executionResults[executionResults.length - 1]
+ : undefined;
+ const outputData = latestResult?.output_data?.output;
const renderer = globalRegistry.getRenderer(outputData);
@@ -79,6 +83,7 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/RunGraph.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/RunGraph.tsx
index 7ee00ec285..57890b1f17 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/RunGraph.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/RunGraph.tsx
@@ -5,10 +5,11 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
-import { PlayIcon, StopIcon } from "@phosphor-icons/react";
+import { CircleNotchIcon, PlayIcon, StopIcon } from "@phosphor-icons/react";
import { useShallow } from "zustand/react/shallow";
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
import { useRunGraph } from "./useRunGraph";
+import { cn } from "@/lib/utils";
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
const {
@@ -24,6 +25,31 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
useShallow((state) => state.isGraphRunning),
);
+ const isLoading = isExecutingGraph || isTerminatingGraph || isSaving;
+
+ // Determine which icon to show with proper animation
+ const renderIcon = () => {
+ const iconClass = cn(
+ "size-4 transition-transform duration-200 ease-out",
+ !isLoading && "group-hover:scale-110",
+ );
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (isGraphRunning) {
+ return ;
+ }
+
+ return ;
+ };
+
return (
<>
@@ -31,19 +57,20 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
- {!isGraphRunning ? (
-
- ) : (
-
- )}
+ {renderIcon()}
- {isGraphRunning ? "Stop agent" : "Run agent"}
+ {isLoading
+ ? "Processing..."
+ : isGraphRunning
+ ? "Stop agent"
+ : "Run agent"}
{
const { saveGraph, isSaving } = useSaveGraph({
@@ -33,6 +34,29 @@ export const useRunGraph = () => {
useShallow((state) => state.clearAllNodeErrors),
);
+ // Tutorial integration - force open dialog when tutorial requests it
+ const forceOpenRunInputDialog = useTutorialStore(
+ (state) => state.forceOpenRunInputDialog,
+ );
+ const setForceOpenRunInputDialog = useTutorialStore(
+ (state) => state.setForceOpenRunInputDialog,
+ );
+
+ // Sync tutorial state with dialog state
+ useEffect(() => {
+ if (forceOpenRunInputDialog && !openRunInputDialog) {
+ setOpenRunInputDialog(true);
+ }
+ }, [forceOpenRunInputDialog, openRunInputDialog]);
+
+ // Reset tutorial state when dialog closes
+ const handleSetOpenRunInputDialog = (isOpen: boolean) => {
+ setOpenRunInputDialog(isOpen);
+ if (!isOpen && forceOpenRunInputDialog) {
+ setForceOpenRunInputDialog(false);
+ }
+ };
+
const [{ flowID, flowVersion, flowExecutionID }, setQueryStates] =
useQueryStates({
flowID: parseAsString,
@@ -138,6 +162,6 @@ export const useRunGraph = () => {
isExecutingGraph,
isTerminatingGraph,
openRunInputDialog,
- setOpenRunInputDialog,
+ setOpenRunInputDialog: handleSetOpenRunInputDialog,
};
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx
index 5e93f95525..51308a4a82 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog.tsx
@@ -8,6 +8,9 @@ import { Text } from "@/components/atoms/Text/Text";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
import { useRunInputDialog } from "./useRunInputDialog";
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
+import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
+import { useEffect } from "react";
+import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
export const RunInputDialog = ({
isOpen,
@@ -21,22 +24,35 @@ export const RunInputDialog = ({
const hasInputs = useGraphStore((state) => state.hasInputs);
const hasCredentials = useGraphStore((state) => state.hasCredentials);
const inputSchema = useGraphStore((state) => state.inputSchema);
- const credentialsSchema = useGraphStore(
- (state) => state.credentialsInputSchema,
- );
const {
- credentialsUiSchema,
+ credentialFields,
+ requiredCredentials,
handleManualRun,
handleInputChange,
openCronSchedulerDialog,
setOpenCronSchedulerDialog,
inputValues,
credentialValues,
- handleCredentialChange,
+ handleCredentialFieldChange,
isExecutingGraph,
} = useRunInputDialog({ setIsOpen });
+ // Tutorial integration - track input values for the tutorial
+ const setTutorialInputValues = useTutorialStore(
+ (state) => state.setTutorialInputValues,
+ );
+ const isTutorialRunning = useTutorialStore(
+ (state) => state.isTutorialRunning,
+ );
+
+ // Update tutorial store when input values change
+ useEffect(() => {
+ if (isTutorialRunning) {
+ setTutorialInputValues(inputValues);
+ }
+ }, [inputValues, isTutorialRunning, setTutorialInputValues]);
+
return (
<>
-
- {/* Credentials Section */}
- {hasCredentials() && (
-
-
-
- Credentials
-
+
+
+ {/* Credentials Section */}
+ {hasCredentials() && credentialFields.length > 0 && (
+
+
+
+ Credentials
+
+
+
+
+
-
- handleCredentialChange(v.formData)}
- uiSchema={credentialsUiSchema}
- initialValues={{}}
- formContext={{
- showHandles: false,
- size: "large",
- showOptionalToggle: false,
- }}
- />
-
-
- )}
+ )}
- {/* Inputs Section */}
- {hasInputs() && (
-
-
-
- Inputs
-
+ {/* Inputs Section */}
+ {hasInputs() && (
+
+
+
+ Inputs
+
+
+
+ handleInputChange(v.formData)}
+ uiSchema={uiSchema}
+ initialValues={{}}
+ formContext={{
+ showHandles: false,
+ size: "large",
+ }}
+ />
+
-
handleInputChange(v.formData)}
- uiSchema={uiSchema}
- initialValues={{}}
- formContext={{
- showHandles: false,
- size: "large",
- }}
- />
-
- )}
+ )}
+
- {/* Action Button */}
-
+
{purpose === "run" && (
{!isExecutingGraph && (
@@ -114,8 +136,9 @@ export const RunInputDialog = ({
setOpenCronSchedulerDialog(true)}
+ data-id="run-input-schedule-button"
>
Schedule Run
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts
index ddd77bae48..629d4662a9 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts
@@ -1,14 +1,17 @@
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { usePostV1ExecuteGraphAgent } from "@/app/api/__generated__/endpoints/graphs/graphs";
-import { useToast } from "@/components/molecules/Toast/use-toast";
+
import {
+ ApiError,
CredentialsMetaInput,
GraphExecutionMeta,
} from "@/lib/autogpt-server-api";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
-import { useMemo, useState } from "react";
-import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
-import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
+import { useCallback, useMemo, useState } from "react";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { useToast } from "@/components/molecules/Toast/use-toast";
+import { useReactFlow } from "@xyflow/react";
+import type { CredentialField } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/helpers";
export const useRunInputDialog = ({
setIsOpen,
@@ -31,6 +34,7 @@ export const useRunInputDialog = ({
flowVersion: parseAsInteger,
});
const { toast } = useToast();
+ const { setViewport } = useReactFlow();
const { mutateAsync: executeGraph, isPending: isExecutingGraph } =
usePostV1ExecuteGraphAgent({
@@ -42,38 +46,105 @@ export const useRunInputDialog = ({
});
},
onError: (error) => {
- // Reset running state on error
+ if (error instanceof ApiError && error.isGraphValidationError()) {
+ const errorData = error.response?.detail || {
+ node_errors: {},
+ message: undefined,
+ };
+ const nodeErrors = errorData.node_errors || {};
+
+ if (Object.keys(nodeErrors).length > 0) {
+ Object.entries(nodeErrors).forEach(
+ ([nodeId, nodeErrorsForNode]) => {
+ useNodeStore
+ .getState()
+ .updateNodeErrors(
+ nodeId,
+ nodeErrorsForNode as { [key: string]: string },
+ );
+ },
+ );
+ } else {
+ useNodeStore.getState().nodes.forEach((node) => {
+ useNodeStore.getState().updateNodeErrors(node.id, {});
+ });
+ }
+
+ toast({
+ title: errorData?.message || "Graph validation failed",
+ description:
+ "Please fix the validation errors on the highlighted nodes and try again.",
+ variant: "destructive",
+ });
+ setIsOpen(false);
+
+ const firstBackendId = Object.keys(nodeErrors)[0];
+
+ if (firstBackendId) {
+ const firstErrorNode = useNodeStore
+ .getState()
+ .nodes.find(
+ (n) =>
+ n.data.metadata?.backend_id === firstBackendId ||
+ n.id === firstBackendId,
+ );
+
+ if (firstErrorNode) {
+ setTimeout(() => {
+ setViewport(
+ {
+ x:
+ -firstErrorNode.position.x * 0.8 +
+ window.innerWidth / 2 -
+ 150,
+ y: -firstErrorNode.position.y * 0.8 + 50,
+ zoom: 0.8,
+ },
+ { duration: 500 },
+ );
+ }, 50);
+ }
+ }
+ } else {
+ toast({
+ title: "Error running graph",
+ description:
+ (error as Error).message || "An unexpected error occurred.",
+ variant: "destructive",
+ });
+ setIsOpen(false);
+ }
setIsGraphRunning(false);
- toast({
- title: (error.detail as string) ?? "An unexpected error occurred.",
- description: "An unexpected error occurred.",
- variant: "destructive",
- });
},
},
});
- // We are rendering the credentials field differently compared to other fields.
- // In the node, we have the field name as "credential" - so our library catches it and renders it differently.
- // But here we have a different name, something like `Firecrawl credentials`, so here we are telling the library that this field is a credential field type.
+ // Convert credentials schema to credential fields array for CredentialsGroupedView
+ const credentialFields: CredentialField[] = useMemo(() => {
+ if (!credentialsSchema?.properties) return [];
+ return Object.entries(credentialsSchema.properties);
+ }, [credentialsSchema]);
- const credentialsUiSchema = useMemo(() => {
- const dynamicUiSchema: any = { ...uiSchema };
+ // Get required credentials as a Set
+ const requiredCredentials = useMemo(() => {
+ return new Set(credentialsSchema?.required || []);
+ }, [credentialsSchema]);
- if (credentialsSchema?.properties) {
- Object.keys(credentialsSchema.properties).forEach((fieldName) => {
- const fieldSchema = credentialsSchema.properties[fieldName];
- if (isCredentialFieldSchema(fieldSchema)) {
- dynamicUiSchema[fieldName] = {
- ...dynamicUiSchema[fieldName],
- "ui:field": "custom/credential_field",
- };
+ // Handler for individual credential changes
+ const handleCredentialFieldChange = useCallback(
+ (key: string, value?: CredentialsMetaInput) => {
+ setCredentialValues((prev) => {
+ if (value) {
+ return { ...prev, [key]: value };
+ } else {
+ const next = { ...prev };
+ delete next[key];
+ return next;
}
});
- }
-
- return dynamicUiSchema;
- }, [credentialsSchema]);
+ },
+ [],
+ );
const handleManualRun = async () => {
// Filter out incomplete credentials (those without a valid id)
@@ -82,6 +153,9 @@ export const useRunInputDialog = ({
Object.entries(credentialValues).filter(([_, cred]) => cred && cred.id),
);
+ useNodeStore.getState().clearAllNodeExecutionResults();
+ useNodeStore.getState().cleanNodesStatuses();
+
await executeGraph({
graphId: flowID ?? "",
graphVersion: flowVersion || null,
@@ -106,12 +180,14 @@ export const useRunInputDialog = ({
};
return {
- credentialsUiSchema,
+ credentialFields,
+ requiredCredentials,
inputValues,
credentialValues,
isExecutingGraph,
handleInputChange,
handleCredentialChange,
+ handleCredentialFieldChange,
handleManualRun,
openCronSchedulerDialog,
setOpenCronSchedulerDialog,
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/ScheduleGraph/ScheduleGraph.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/ScheduleGraph/ScheduleGraph.tsx
index 5cc8538de1..854917154e 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/ScheduleGraph/ScheduleGraph.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/ScheduleGraph/ScheduleGraph.tsx
@@ -26,6 +26,7 @@ export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx
index c1a7ef3b35..227d892fff 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FloatingSafeModeToogle.tsx
@@ -18,69 +18,110 @@ interface Props {
fullWidth?: boolean;
}
+interface SafeModeButtonProps {
+ isEnabled: boolean;
+ label: string;
+ tooltipEnabled: string;
+ tooltipDisabled: string;
+ onToggle: () => void;
+ isPending: boolean;
+ fullWidth?: boolean;
+}
+
+function SafeModeButton({
+ isEnabled,
+ label,
+ tooltipEnabled,
+ tooltipDisabled,
+ onToggle,
+ isPending,
+ fullWidth = false,
+}: SafeModeButtonProps) {
+ return (
+
+
+
+ {isEnabled ? (
+ <>
+
+
+ {label}: ON
+
+ >
+ ) : (
+ <>
+
+
+ {label}: OFF
+
+ >
+ )}
+
+
+
+
+
+ {label}: {isEnabled ? "ON" : "OFF"}
+
+
+ {isEnabled ? tooltipEnabled : tooltipDisabled}
+
+
+
+
+ );
+}
+
export function FloatingSafeModeToggle({
graph,
className,
fullWidth = false,
}: Props) {
const {
- currentSafeMode,
+ currentHITLSafeMode,
+ showHITLToggle,
+ handleHITLToggle,
+ currentSensitiveActionSafeMode,
+ showSensitiveActionToggle,
+ handleSensitiveActionToggle,
isPending,
shouldShowToggle,
- isStateUndetermined,
- handleToggle,
} = useAgentSafeMode(graph);
- if (!shouldShowToggle || isStateUndetermined || isPending) {
+ if (!shouldShowToggle || isPending) {
return null;
}
return (
-
-
-
-
- {currentSafeMode! ? (
- <>
-
-
- Safe Mode: ON
-
- >
- ) : (
- <>
-
-
- Safe Mode: OFF
-
- >
- )}
-
-
-
-
-
- Safe Mode: {currentSafeMode! ? "ON" : "OFF"}
-
-
- {currentSafeMode!
- ? "Human in the loop blocks require manual review"
- : "Human in the loop blocks proceed automatically"}
-
-
-
-
+
+ {showHITLToggle && (
+
+ )}
+ {showSensitiveActionToggle && (
+
+ )}
);
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx
index 29fd984b1d..87ae4300b8 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx
@@ -55,14 +55,16 @@ export const Flow = () => {
const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []);
const onNodeDragStop = useCallback(() => {
+ const currentNodes = useNodeStore.getState().nodes;
setNodes(
- resolveCollisions(nodes, {
+ resolveCollisions(currentNodes, {
maxIterations: Infinity,
overlapThreshold: 0.5,
margin: 15,
}),
);
- }, [setNodes, nodes]);
+ }, [setNodes]);
+
const { edges, onConnect, onEdgesChange } = useCustomEdge();
// for loading purpose
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/components/CustomControl.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/components/CustomControl.tsx
index c6f8e8ded4..7b723d73b3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/components/CustomControl.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/components/CustomControl.tsx
@@ -6,12 +6,17 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import {
+ ChalkboardIcon,
+ CircleNotchIcon,
FrameCornersIcon,
MinusIcon,
PlusIcon,
} from "@phosphor-icons/react/dist/ssr";
import { LockIcon, LockOpenIcon } from "lucide-react";
-import { memo } from "react";
+import { memo, useEffect, useState } from "react";
+import { useSearchParams, useRouter } from "next/navigation";
+import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
+import { startTutorial, setTutorialLoadingCallback } from "../../tutorial";
export const CustomControls = memo(
({
@@ -22,31 +27,69 @@ export const CustomControls = memo(
setIsLocked: (isLocked: boolean) => void;
}) => {
const { zoomIn, zoomOut, fitView } = useReactFlow();
+ const { isTutorialRunning, setIsTutorialRunning } = useTutorialStore();
+ const [isTutorialLoading, setIsTutorialLoading] = useState(false);
+ const searchParams = useSearchParams();
+ const router = useRouter();
+
+ useEffect(() => {
+ setTutorialLoadingCallback(setIsTutorialLoading);
+ return () => setTutorialLoadingCallback(() => {});
+ }, []);
+
+ const handleTutorialClick = () => {
+ if (isTutorialLoading) return;
+
+ const flowId = searchParams.get("flowID");
+ if (flowId) {
+ router.push("/build?view=new");
+ return;
+ }
+
+ startTutorial();
+ setIsTutorialRunning(true);
+ };
const controls = [
{
- icon:
,
+ id: "zoom-in-button",
+ icon:
,
label: "Zoom In",
onClick: () => zoomIn(),
className: "h-10 w-10 border-none",
},
{
- icon:
,
+ id: "zoom-out-button",
+ icon:
,
label: "Zoom Out",
onClick: () => zoomOut(),
className: "h-10 w-10 border-none",
},
{
- icon:
,
+ id: "tutorial-button",
+ icon: isTutorialLoading ? (
+
+ ) : (
+
+ ),
+ label: isTutorialLoading ? "Loading Tutorial..." : "Start Tutorial",
+ onClick: handleTutorialClick,
+ className: `h-10 w-10 border-none ${isTutorialRunning || isTutorialLoading ? "bg-zinc-100" : "bg-white"}`,
+ disabled: isTutorialLoading,
+ },
+ {
+ id: "fit-view-button",
+ icon:
,
label: "Fit View",
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
className: "h-10 w-10 border-none",
},
{
+ id: "lock-button",
icon: !isLocked ? (
-
+
) : (
-
+
),
label: "Toggle Lock",
onClick: () => setIsLocked(!isLocked),
@@ -55,15 +98,20 @@ export const CustomControls = memo(
];
return (
-
- {controls.map((control, index) => (
-
+
+ {controls.map((control) => (
+
{control.icon}
{control.label}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts
index 694c1be81b..f5533848d2 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts
@@ -139,14 +139,6 @@ export const useFlow = () => {
useNodeStore.getState().setNodes([]);
useNodeStore.getState().clearResolutionState();
addNodes(customNodes);
-
- // Sync hardcoded values with handle IDs.
- // If a key–value field has a key without a value, the backend omits it from hardcoded values.
- // But if a handleId exists for that key, it causes inconsistency.
- // This ensures hardcoded values stay in sync with handle IDs.
- customNodes.forEach((node) => {
- useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
- });
}
}, [customNodes, addNodes]);
@@ -158,6 +150,14 @@ export const useFlow = () => {
}
}, [graph?.links, addLinks]);
+ useEffect(() => {
+ if (customNodes.length > 0 && graph?.links) {
+ customNodes.forEach((node) => {
+ useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
+ });
+ }
+ }, [customNodes, graph?.links]);
+
// update node execution status in nodes
useEffect(() => {
if (
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx
index eb221b5d34..3b6425a7c6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx
@@ -19,6 +19,8 @@ export type CustomEdgeData = {
beadUp?: number;
beadDown?: number;
beadData?: Map;
+ edgeColorClass?: string;
+ edgeHexColor?: string;
};
export type CustomEdge = XYEdge;
@@ -36,7 +38,6 @@ const CustomEdge = ({
selected,
}: EdgeProps) => {
const removeConnection = useEdgeStore((state) => state.removeEdge);
- // Subscribe to the brokenEdgeIDs map and check if this edge is broken across any node
const isBroken = useNodeStore((state) => state.isEdgeBroken(id));
const [isHovered, setIsHovered] = useState(false);
@@ -52,6 +53,7 @@ const CustomEdge = ({
const isStatic = data?.isStatic ?? false;
const beadUp = data?.beadUp ?? 0;
const beadDown = data?.beadDown ?? 0;
+ const edgeColorClass = data?.edgeColorClass;
const handleRemoveEdge = () => {
removeConnection(id);
@@ -70,7 +72,9 @@ const CustomEdge = ({
? "!stroke-red-500 !stroke-[2px] [stroke-dasharray:4]"
: selected
? "stroke-zinc-800"
- : "stroke-zinc-500/50 hover:stroke-zinc-500",
+ : edgeColorClass
+ ? cn(edgeColorClass, "opacity-70 hover:opacity-100")
+ : "stroke-zinc-500/50 hover:stroke-zinc-500",
)}
/>
{
const edges = useEdgeStore((s) => s.edges);
@@ -33,8 +35,13 @@ export const useCustomEdge = () => {
if (exists) return;
const nodes = useNodeStore.getState().nodes;
- const isStatic = nodes.find((n) => n.id === conn.source)?.data
- ?.staticOutput;
+ const sourceNode = nodes.find((n) => n.id === conn.source);
+ const isStatic = sourceNode?.data?.staticOutput;
+
+ const { colorClass, hexColor } = getEdgeColorFromOutputType(
+ sourceNode?.data?.outputSchema,
+ conn.sourceHandle,
+ );
addEdge({
source: conn.source,
@@ -43,6 +50,8 @@ export const useCustomEdge = () => {
targetHandle: conn.targetHandle,
data: {
isStatic,
+ edgeColorClass: colorClass,
+ edgeHexColor: hexColor,
},
});
},
@@ -51,7 +60,20 @@ export const useCustomEdge = () => {
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => {
+ const hasRemoval = changes.some((change) => change.type === "remove");
+
+ const prevState = hasRemoval
+ ? {
+ nodes: useNodeStore.getState().nodes,
+ edges: edges,
+ }
+ : null;
+
setEdges(applyEdgeChanges(changes, edges));
+
+ if (prevState) {
+ useHistoryStore.getState().pushState(prevState);
+ }
},
[edges, setEdges],
);
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx
index f933c86368..fdec177788 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/NodeHandle.tsx
@@ -26,6 +26,7 @@ const InputNodeHandle = ({
position={Position.Left}
id={cleanedHandleId}
className={"-ml-6 mr-2"}
+ data-tutorial-id={`input-handler-${nodeId}-${cleanedHandleId}`}
>
> = React.memo(
(value) => value !== null && value !== undefined && value !== "",
);
- const outputData = data.nodeExecutionResult?.output_data;
+ const latestResult =
+ data.nodeExecutionResults && data.nodeExecutionResults.length > 0
+ ? data.nodeExecutionResults[data.nodeExecutionResults.length - 1]
+ : undefined;
+ const outputData = latestResult?.output_data;
const hasOutputError =
typeof outputData === "object" &&
outputData !== null &&
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx
index da9c13335f..00004c3a8a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx
@@ -27,6 +27,7 @@ export const NodeContainer = ({
status && nodeStyleBasedOnStatus[status],
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",
)}
+ data-id={`custom-node-${nodeId}`}
>
{children}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx
index e13aa37a31..c4659b8dcf 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader.tsx
@@ -20,11 +20,13 @@ type Props = {
export const NodeHeader = ({ data, nodeId }: Props) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
- const title = (data.metadata?.customized_name as string) || data.title;
+ const title =
+ (data.metadata?.customized_name as string) ||
+ data.hardcodedValues?.agent_name ||
+ data.title;
+
const [isEditingTitle, setIsEditingTitle] = useState(false);
- const [editedTitle, setEditedTitle] = useState(
- beautifyString(title).replace("Block", "").trim(),
- );
+ const [editedTitle, setEditedTitle] = useState(title);
const handleTitleEdit = () => {
updateNodeData(nodeId, {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx
index 3f0ae6e350..c5df24e0e6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput.tsx
@@ -1,7 +1,13 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/molecules/Accordion/Accordion";
import { beautifyString, cn } from "@/lib/utils";
-import { CaretDownIcon, CopyIcon, CheckIcon } from "@phosphor-icons/react";
+import { CopyIcon, CheckIcon } from "@phosphor-icons/react";
import { NodeDataViewer } from "./components/NodeDataViewer/NodeDataViewer";
import { ContentRenderer } from "./components/ContentRenderer";
import { useNodeOutput } from "./useNodeOutput";
@@ -9,135 +15,134 @@ import { ViewMoreData } from "./components/ViewMoreData";
export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
const {
- outputData,
- isExpanded,
- setIsExpanded,
+ latestOutputData,
copiedKey,
handleCopy,
executionResultId,
- inputData,
+ latestInputData,
} = useNodeOutput(nodeId);
- if (Object.keys(outputData).length === 0) {
+ if (Object.keys(latestOutputData).length === 0) {
return null;
}
return (
-
-
-
- Node Output
-
- setIsExpanded(!isExpanded)}
- className="h-fit min-w-0 p-1 text-slate-600 hover:text-slate-900"
- >
-
-
-
+
+
+
+
+
+ Node Output
+
+
+
+
+
+
Input
- {isExpanded && (
- <>
-
-
-
Input
+
-
-
-
-
-
handleCopy("input", inputData)}
- className={cn(
- "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
- copiedKey === "input" &&
- "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
- )}
- >
- {copiedKey === "input" ? (
-
- ) : (
-
- )}
-
+
+
+ handleCopy("input", latestInputData)}
+ className={cn(
+ "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
+ copiedKey === "input" &&
+ "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
+ )}
+ >
+ {copiedKey === "input" ? (
+
+ ) : (
+
+ )}
+
+
-
- {Object.entries(outputData)
- .slice(0, 2)
- .map(([key, value]) => (
-
-
-
- Pin:
-
-
- {beautifyString(key)}
-
-
-
-
- Data:
-
-
- {value.map((item, index) => (
-
-
-
- ))}
-
-
-
-
handleCopy(key, value)}
- className={cn(
- "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
- copiedKey === key &&
- "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
- )}
+ {Object.entries(latestOutputData)
+ .slice(0, 2)
+ .map(([key, value]) => {
+ return (
+
+
+
- {copiedKey === key ? (
-
- ) : (
-
- )}
-
+ Pin:
+
+
+ {beautifyString(key)}
+
+
+
+
+ Data:
+
+
+ {value.map((item, index) => (
+
+
+
+ ))}
+
+
+
+ handleCopy(key, value)}
+ className={cn(
+ "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
+ copiedKey === key &&
+ "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
+ )}
+ >
+ {copiedKey === key ? (
+
+ ) : (
+
+ )}
+
+
+
-
-
- ))}
-
-
- {Object.keys(outputData).length > 2 && (
-
- )}
- >
- )}
+ );
+ })}
+
+
+
+
+
);
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx
index 9cb1a62e3d..6571bc7b6f 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ContentRenderer.tsx
@@ -1,7 +1,7 @@
"use client";
-import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
-import { globalRegistry } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
+import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
+import { globalRegistry } from "@/components/contextual/OutputRenderers";
export const TextRenderer: React.FC<{
value: any;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx
index 31b89315d6..680b6bc44a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/NodeDataViewer.tsx
@@ -1,7 +1,3 @@
-import {
- OutputActions,
- OutputItem,
-} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
import { ScrollArea } from "@/components/__legacy__/ui/scroll-area";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
@@ -11,6 +7,10 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
+import {
+ OutputActions,
+ OutputItem,
+} from "@/components/contextual/OutputRenderers";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { beautifyString } from "@/lib/utils";
import {
@@ -19,22 +19,51 @@ import {
CopyIcon,
DownloadIcon,
} from "@phosphor-icons/react";
-import { FC } from "react";
+import React, { FC } from "react";
import { useNodeDataViewer } from "./useNodeDataViewer";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { useShallow } from "zustand/react/shallow";
+import { NodeDataType } from "../../helpers";
-interface NodeDataViewerProps {
- data: any;
+export interface NodeDataViewerProps {
+ data?: any;
pinName: string;
+ nodeId?: string;
execId?: string;
isViewMoreData?: boolean;
+ dataType?: NodeDataType;
}
export const NodeDataViewer: FC
= ({
data,
pinName,
+ nodeId,
execId = "N/A",
isViewMoreData = false,
+ dataType = "output",
}) => {
+ const executionResults = useNodeStore(
+ useShallow((state) =>
+ nodeId ? state.getNodeExecutionResults(nodeId) : [],
+ ),
+ );
+ const latestInputData = useNodeStore(
+ useShallow((state) =>
+ nodeId ? state.getLatestNodeInputData(nodeId) : undefined,
+ ),
+ );
+ const accumulatedOutputData = useNodeStore(
+ useShallow((state) =>
+ nodeId ? state.getAccumulatedNodeOutputData(nodeId) : {},
+ ),
+ );
+
+ const resolvedData =
+ data ??
+ (dataType === "input"
+ ? (latestInputData ?? {})
+ : (accumulatedOutputData[pinName] ?? []));
+
const {
outputItems,
copyExecutionId,
@@ -42,7 +71,20 @@ export const NodeDataViewer: FC = ({
handleDownloadItem,
dataArray,
copiedIndex,
- } = useNodeDataViewer(data, pinName, execId);
+ groupedExecutions,
+ totalGroupedItems,
+ handleCopyGroupedItem,
+ handleDownloadGroupedItem,
+ copiedKey,
+ } = useNodeDataViewer(
+ resolvedData,
+ pinName,
+ execId,
+ executionResults,
+ dataType,
+ );
+
+ const shouldGroupExecutions = groupedExecutions.length > 0;
return (
@@ -68,44 +110,141 @@ export const NodeDataViewer: FC = ({
- Full Output Preview
+ Full {dataType === "input" ? "Input" : "Output"} Preview
- {dataArray.length} item{dataArray.length !== 1 ? "s" : ""} total
+ {shouldGroupExecutions ? totalGroupedItems : dataArray.length}{" "}
+ item
+ {shouldGroupExecutions
+ ? totalGroupedItems !== 1
+ ? "s"
+ : ""
+ : dataArray.length !== 1
+ ? "s"
+ : ""}{" "}
+ total
-
-
- Execution ID:
-
-
- {execId}
-
-
-
-
-
-
- Pin:{" "}
- {beautifyString(pinName)}
-
+ {shouldGroupExecutions ? (
+
+ Pin:{" "}
+ {beautifyString(pinName)}
+
+ ) : (
+ <>
+
+
+ Execution ID:
+
+
+ {execId}
+
+
+
+
+
+
+ Pin:{" "}
+
+ {beautifyString(pinName)}
+
+
+ >
+ )}
- {dataArray.length > 0 ? (
+ {shouldGroupExecutions ? (
+
+ {groupedExecutions.map((execution) => (
+
+
+
+ Execution ID:
+
+
+ {execution.execId}
+
+
+
+ {execution.outputItems.length > 0 ? (
+ execution.outputItems.map((item, index) => (
+
+
+
+
+
+
+
+ handleCopyGroupedItem(
+ execution.execId,
+ index,
+ item,
+ )
+ }
+ aria-label="Copy item"
+ >
+ {copiedKey ===
+ `${execution.execId}-${index}` ? (
+
+ ) : (
+
+ )}
+
+
+ handleDownloadGroupedItem(item)
+ }
+ aria-label="Download item"
+ >
+
+
+
+
+ ))
+ ) : (
+
+ No data available
+
+ )}
+
+
+ ))}
+
+ ) : dataArray.length > 0 ? (
{outputItems.map((item, index) => (
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts
index 1adec625a0..818d1266c1 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/NodeDataViewer/useNodeDataViewer.ts
@@ -1,82 +1,70 @@
-import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
-import { globalRegistry } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
-import { downloadOutputs } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers/utils/download";
+import { downloadOutputs } from "@/components/contextual/OutputRenderers/utils/download";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { beautifyString } from "@/lib/utils";
-import React, { useMemo, useState } from "react";
+import { useState } from "react";
+import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
+import {
+ NodeDataType,
+ createOutputItems,
+ getExecutionData,
+ normalizeToArray,
+ type OutputItem,
+} from "../../helpers";
+
+export type GroupedExecution = {
+ execId: string;
+ outputItems: Array
;
+};
export const useNodeDataViewer = (
data: any,
pinName: string,
execId: string,
+ executionResults?: NodeExecutionResult[],
+ dataType?: NodeDataType,
) => {
const { toast } = useToast();
const [copiedIndex, setCopiedIndex] = useState(null);
+ const [copiedKey, setCopiedKey] = useState(null);
- // Normalize data to array format
- const dataArray = useMemo(() => {
- return Array.isArray(data) ? data : [data];
- }, [data]);
+ const dataArray = Array.isArray(data) ? data : [data];
- // Prepare items for the enhanced renderer system
- const outputItems = useMemo(() => {
- if (!dataArray) return [];
-
- const items: Array<{
- key: string;
- label: string;
- value: unknown;
- metadata?: OutputMetadata;
- renderer: any;
- }> = [];
-
- dataArray.forEach((value, index) => {
- const metadata: OutputMetadata = {};
-
- // Extract metadata from the value if it's an object
- if (
- typeof value === "object" &&
- value !== null &&
- !React.isValidElement(value)
- ) {
- const objValue = value as any;
- if (objValue.type) metadata.type = objValue.type;
- if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
- if (objValue.filename) metadata.filename = objValue.filename;
- if (objValue.language) metadata.language = objValue.language;
- }
-
- const renderer = globalRegistry.getRenderer(value, metadata);
- if (renderer) {
- items.push({
- key: `item-${index}`,
+ const outputItems =
+ !dataArray || dataArray.length === 0
+ ? []
+ : createOutputItems(dataArray).map((item, index) => ({
+ ...item,
label: index === 0 ? beautifyString(pinName) : "",
- value,
- metadata,
- renderer,
- });
- } else {
- // Fallback to text renderer
- const textRenderer = globalRegistry
- .getAllRenderers()
- .find((r) => r.name === "TextRenderer");
- if (textRenderer) {
- items.push({
- key: `item-${index}`,
- label: index === 0 ? beautifyString(pinName) : "",
- value:
- typeof value === "string"
- ? value
- : JSON.stringify(value, null, 2),
- metadata,
- renderer: textRenderer,
- });
- }
- }
- });
+ }));
- return items;
- }, [dataArray, pinName]);
+ const groupedExecutions =
+ !executionResults || executionResults.length === 0
+ ? []
+ : [...executionResults].reverse().map((result) => {
+ const rawData = getExecutionData(
+ result,
+ dataType || "output",
+ pinName,
+ );
+ let dataArray: unknown[];
+ if (dataType === "input") {
+ dataArray =
+ rawData !== undefined && rawData !== null ? [rawData] : [];
+ } else {
+ dataArray = normalizeToArray(rawData);
+ }
+
+ const outputItems = createOutputItems(dataArray);
+ return {
+ execId: result.node_exec_id,
+ outputItems,
+ };
+ });
+
+ const totalGroupedItems = groupedExecutions.reduce(
+ (total, execution) => total + execution.outputItems.length,
+ 0,
+ );
const copyExecutionId = () => {
navigator.clipboard.writeText(execId).then(() => {
@@ -122,6 +110,45 @@ export const useNodeDataViewer = (
]);
};
+ const handleCopyGroupedItem = async (
+ execId: string,
+ index: number,
+ item: OutputItem,
+ ) => {
+ const copyContent = item.renderer.getCopyContent(item.value, item.metadata);
+
+ if (!copyContent) {
+ return;
+ }
+
+ try {
+ let text: string;
+ if (typeof copyContent.data === "string") {
+ text = copyContent.data;
+ } else if (copyContent.fallbackText) {
+ text = copyContent.fallbackText;
+ } else {
+ return;
+ }
+
+ await navigator.clipboard.writeText(text);
+ setCopiedKey(`${execId}-${index}`);
+ setTimeout(() => setCopiedKey(null), 2000);
+ } catch (error) {
+ console.error("Failed to copy:", error);
+ }
+ };
+
+ const handleDownloadGroupedItem = (item: OutputItem) => {
+ downloadOutputs([
+ {
+ value: item.value,
+ metadata: item.metadata,
+ renderer: item.renderer,
+ },
+ ]);
+ };
+
return {
outputItems,
dataArray,
@@ -129,5 +156,10 @@ export const useNodeDataViewer = (
handleCopyItem,
handleDownloadItem,
copiedIndex,
+ groupedExecutions,
+ totalGroupedItems,
+ handleCopyGroupedItem,
+ handleDownloadGroupedItem,
+ copiedKey,
};
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx
index 7bf026fe43..74d0da06c2 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/components/ViewMoreData.tsx
@@ -8,16 +8,28 @@ import { useState } from "react";
import { NodeDataViewer } from "./NodeDataViewer/NodeDataViewer";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { CheckIcon, CopyIcon } from "@phosphor-icons/react";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { useShallow } from "zustand/react/shallow";
+import {
+ NodeDataType,
+ getExecutionEntries,
+ normalizeToArray,
+} from "../helpers";
export const ViewMoreData = ({
- outputData,
- execId,
+ nodeId,
+ dataType = "output",
}: {
- outputData: Record>;
- execId?: string;
+ nodeId: string;
+ dataType?: NodeDataType;
}) => {
const [copiedKey, setCopiedKey] = useState(null);
const { toast } = useToast();
+ const executionResults = useNodeStore(
+ useShallow((state) => state.getNodeExecutionResults(nodeId)),
+ );
+
+ const reversedExecutionResults = [...executionResults].reverse();
const handleCopy = (key: string, value: any) => {
const textToCopy =
@@ -29,8 +41,8 @@ export const ViewMoreData = ({
setTimeout(() => setCopiedKey(null), 2000);
};
- const copyExecutionId = () => {
- navigator.clipboard.writeText(execId || "N/A").then(() => {
+ const copyExecutionId = (executionId: string) => {
+ navigator.clipboard.writeText(executionId || "N/A").then(() => {
toast({
title: "Execution ID copied to clipboard!",
duration: 2000,
@@ -42,7 +54,7 @@ export const ViewMoreData = ({
@@ -52,83 +64,114 @@ export const ViewMoreData = ({
- Complete Output Data
+ Complete {dataType === "input" ? "Input" : "Output"} Data
-
-
- Execution ID:
-
-
- {execId}
-
-
-
-
-
-
- {Object.entries(outputData).map(([key, value]) => (
-
+ {reversedExecutionResults.map((result) => (
+
+
+ Execution ID:
+
- Pin:
-
-
- {beautifyString(key)}
+ {result.node_exec_id}
+ copyExecutionId(result.node_exec_id)}
+ className="h-6 w-6 min-w-0 p-0"
+ >
+
+
-
-
- Data:
-
-
- {value.map((item, index) => (
-
-
-
- ))}
-
-
- handleCopy(key, value)}
- className={cn(
- "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
- copiedKey === key &&
- "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
- )}
- >
- {copiedKey === key ? (
-
- ) : (
-
- )}
-
-
-
+
+ {getExecutionEntries(result, dataType).map(
+ ([key, value]) => {
+ const normalizedValue = normalizeToArray(value);
+ return (
+
+
+
+ Pin:
+
+
+ {beautifyString(key)}
+
+
+
+
+ Data:
+
+
+ {normalizedValue.map((item, index) => (
+
+
+
+ ))}
+
+
+
+
+ handleCopy(
+ `${result.node_exec_id}-${key}`,
+ normalizedValue,
+ )
+ }
+ className={cn(
+ "h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
+ copiedKey ===
+ `${result.node_exec_id}-${key}` &&
+ "border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
+ )}
+ >
+ {copiedKey ===
+ `${result.node_exec_id}-${key}` ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+ },
+ )}
))}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts
new file mode 100644
index 0000000000..c75cd83cac
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/helpers.ts
@@ -0,0 +1,83 @@
+import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
+import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
+import { globalRegistry } from "@/components/contextual/OutputRenderers";
+import React from "react";
+
+export type NodeDataType = "input" | "output";
+
+export type OutputItem = {
+ key: string;
+ value: unknown;
+ metadata?: OutputMetadata;
+ renderer: any;
+};
+
+export const normalizeToArray = (value: unknown) => {
+ if (value === undefined) return [];
+ return Array.isArray(value) ? value : [value];
+};
+
+export const getExecutionData = (
+ result: NodeExecutionResult,
+ dataType: NodeDataType,
+ pinName: string,
+) => {
+ if (dataType === "input") {
+ return result.input_data;
+ }
+
+ return result.output_data?.[pinName];
+};
+
+export const createOutputItems = (dataArray: unknown[]): Array
=> {
+ const items: Array = [];
+
+ dataArray.forEach((value, index) => {
+ const metadata: OutputMetadata = {};
+
+ if (
+ typeof value === "object" &&
+ value !== null &&
+ !React.isValidElement(value)
+ ) {
+ const objValue = value as any;
+ if (objValue.type) metadata.type = objValue.type;
+ if (objValue.mimeType) metadata.mimeType = objValue.mimeType;
+ if (objValue.filename) metadata.filename = objValue.filename;
+ if (objValue.language) metadata.language = objValue.language;
+ }
+
+ const renderer = globalRegistry.getRenderer(value, metadata);
+ if (renderer) {
+ items.push({
+ key: `item-${index}`,
+ value,
+ metadata,
+ renderer,
+ });
+ } else {
+ const textRenderer = globalRegistry
+ .getAllRenderers()
+ .find((r) => r.name === "TextRenderer");
+ if (textRenderer) {
+ items.push({
+ key: `item-${index}`,
+ value:
+ typeof value === "string" ? value : JSON.stringify(value, null, 2),
+ metadata,
+ renderer: textRenderer,
+ });
+ }
+ }
+ });
+
+ return items;
+};
+
+export const getExecutionEntries = (
+ result: NodeExecutionResult,
+ dataType: NodeDataType,
+) => {
+ const data = dataType === "input" ? result.input_data : result.output_data;
+ return Object.entries(data || {});
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx
index ba8559a66c..8ebf1dfaf3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/useNodeOutput.tsx
@@ -4,19 +4,21 @@ import { useShallow } from "zustand/react/shallow";
import { useState } from "react";
export const useNodeOutput = (nodeId: string) => {
- const [isExpanded, setIsExpanded] = useState(true);
const [copiedKey, setCopiedKey] = useState(null);
const { toast } = useToast();
- const nodeExecutionResult = useNodeStore(
- useShallow((state) => state.getNodeExecutionResult(nodeId)),
+ const latestResult = useNodeStore(
+ useShallow((state) => state.getLatestNodeExecutionResult(nodeId)),
);
- const inputData = nodeExecutionResult?.input_data;
+ const latestInputData = useNodeStore(
+ useShallow((state) => state.getLatestNodeInputData(nodeId)),
+ );
+
+ const latestOutputData: Record> = useNodeStore(
+ useShallow((state) => state.getLatestNodeOutputData(nodeId) || {}),
+ );
- const outputData: Record> = {
- ...nodeExecutionResult?.output_data,
- };
const handleCopy = async (key: string, value: any) => {
try {
const text = JSON.stringify(value, null, 2);
@@ -36,14 +38,12 @@ export const useNodeOutput = (nodeId: string) => {
});
}
};
+
return {
- outputData: outputData,
- inputData: inputData,
- isExpanded: isExpanded,
- setIsExpanded: setIsExpanded,
- copiedKey: copiedKey,
- setCopiedKey: setCopiedKey,
- handleCopy: handleCopy,
- executionResultId: nodeExecutionResult?.node_exec_id,
+ latestOutputData,
+ latestInputData,
+ copiedKey,
+ handleCopy,
+ executionResultId: latestResult?.node_exec_id,
};
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts
index d4ba538172..143cd58509 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts
@@ -1,10 +1,7 @@
import { useState, useCallback, useEffect } from "react";
import { useShallow } from "zustand/react/shallow";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
-import {
- useNodeStore,
- NodeResolutionData,
-} from "@/app/(platform)/build/stores/nodeStore";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import {
useSubAgentUpdate,
@@ -13,6 +10,7 @@ import {
} from "@/app/(platform)/build/hooks/useSubAgentUpdate";
import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
import { CustomNodeData } from "../../CustomNode";
+import { NodeResolutionData } from "@/app/(platform)/build/stores/types";
// Stable empty set to avoid creating new references in selectors
const EMPTY_SET: Set = new Set();
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx
index 044bf994ad..1fdee05a2a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer.tsx
@@ -1,10 +1,10 @@
-import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
-import { Text } from "@/components/atoms/Text/Text";
-import Link from "next/link";
import { useGetV2GetLibraryAgentByGraphId } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
-import { useQueryStates, parseAsString } from "nuqs";
-import { isValidUUID } from "@/app/(platform)/chat/helpers";
+import { Text } from "@/components/atoms/Text/Text";
+import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
+import { isValidUUID } from "@/lib/utils";
+import Link from "next/link";
+import { parseAsString, useQueryStates } from "nuqs";
export const WebhookDisclaimer = ({ nodeId }: { nodeId: string }) => {
const [{ flowID }] = useQueryStates({
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts
index 54ddf2a61d..50326a03e6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/helpers.ts
@@ -1,5 +1,5 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
-import { NodeResolutionData } from "@/app/(platform)/build/stores/nodeStore";
+import { NodeResolutionData } from "@/app/(platform)/build/stores/types";
import { RJSFSchema } from "@rjsf/utils";
export const nodeStyleBasedOnStatus: Record = {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx
index 8df6989325..d6a3fabffa 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/FormCreator.tsx
@@ -44,7 +44,10 @@ export const FormCreator: React.FC = React.memo(
: hardcodedValues;
return (
-
+
+
{fieldSchema?.description && (
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts
index 46032a67ea..48f4fc19c7 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/helpers.ts
@@ -187,3 +187,38 @@ export const getTypeDisplayInfo = (schema: any) => {
hexColor,
};
};
+
+export function getEdgeColorFromOutputType(
+ outputSchema: RJSFSchema | undefined,
+ sourceHandle: string,
+): { colorClass: string; hexColor: string } {
+ const defaultColor = {
+ colorClass: "stroke-zinc-500/50",
+ hexColor: "#6b7280",
+ };
+
+ if (!outputSchema?.properties) return defaultColor;
+
+ const properties = outputSchema.properties as Record;
+ const handleParts = sourceHandle.split("_#_");
+ let currentSchema: Record = properties;
+
+ for (let i = 0; i < handleParts.length; i++) {
+ const part = handleParts[i];
+ const fieldSchema = currentSchema[part] as Record;
+ if (!fieldSchema) return defaultColor;
+
+ if (i === handleParts.length - 1) {
+ const { hexColor, colorClass } = getTypeDisplayInfo(fieldSchema);
+ return { colorClass: colorClass.replace("!text-", "stroke-"), hexColor };
+ }
+
+ if (fieldSchema.properties) {
+ currentSchema = fieldSchema.properties as Record;
+ } else {
+ return defaultColor;
+ }
+ }
+
+ return defaultColor;
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/constants.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/constants.ts
new file mode 100644
index 0000000000..1a7e4176c6
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/constants.ts
@@ -0,0 +1,129 @@
+// Block IDs for tutorial blocks
+export const BLOCK_IDS = {
+ CALCULATOR: "b1ab9b19-67a6-406d-abf5-2dba76d00c79",
+ AGENT_INPUT: "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b",
+ AGENT_OUTPUT: "363ae599-353e-4804-937e-b2ee3cef3da4",
+} as const;
+
+export const TUTORIAL_SELECTORS = {
+ // Custom nodes - These are all before saving
+ INPUT_NODE: '[data-id="custom-node-2"]',
+ OUTPUT_NODE: '[data-id="custom-node-3 "]',
+ CALCULATOR_NODE: '[data-id="custom-node-1"]',
+
+ // Paricular field selector
+ NAME_FIELD_OUTPUT_NODE: '[data-id="field-3-root_name"]',
+
+ // Output Handlers
+ SECOND_CALCULATOR_RESULT_OUTPUT_HANDLER:
+ '[data-tutorial-id="output-handler-2-result"]',
+ FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER:
+ '[data-tutorial-id="output-handler-1-result"]',
+
+ // Input Handler
+ SECOND_CALCULATOR_NUMBER_A_INPUT_HANDLER:
+ '[data-tutorial-id="input-handler-2-a"]',
+ OUTPUT_VALUE_INPUT_HANDLEER: '[data-tutorial-id="label-3-root_value"]',
+
+ // Block Menu
+ BLOCKS_TRIGGER: '[data-id="blocks-control-popover-trigger"]',
+ BLOCKS_CONTENT: '[data-id="blocks-control-popover-content"]',
+ BLOCKS_SEARCH_INPUT:
+ '[data-id="blocks-control-search-bar"] input[type="text"]',
+ BLOCKS_SEARCH_INPUT_BOX: '[data-id="blocks-control-search-bar"]',
+
+ // Add a new selector that checks within search results
+
+ // Block Menu Sidebar
+ MENU_ITEM_INPUT_BLOCKS: '[data-id="menu-item-input_blocks"]',
+ MENU_ITEM_ALL_BLOCKS: '[data-id="menu-item-all_blocks"]',
+ MENU_ITEM_ACTION_BLOCKS: '[data-id="menu-item-action_blocks"]',
+ MENU_ITEM_OUTPUT_BLOCKS: '[data-id="menu-item-output_blocks"]',
+ MENU_ITEM_INTEGRATIONS: '[data-id="menu-item-integrations"]',
+ MENU_ITEM_MY_AGENTS: '[data-id="menu-item-my_agents"]',
+ MENU_ITEM_MARKETPLACE: '[data-id="menu-item-marketplace_agents"]',
+ MENU_ITEM_SUGGESTION: '[data-id="menu-item-suggestion"]',
+
+ // Block Cards
+ BLOCK_CARD_PREFIX: '[data-id^="block-card-"]',
+ BLOCK_CARD_AGENT_INPUT: '[data-id="block-card-AgentInputBlock"]',
+ // Calculator block - legacy ID used in old tutorial
+ BLOCK_CARD_CALCULATOR:
+ '[data-id="block-card-b1ab9b1967a6406dabf52dba76d00c79"]',
+ BLOCK_CARD_CALCULATOR_IN_SEARCH:
+ '[data-id="blocks-control-search-results"] [data-id="block-card-b1ab9b1967a6406dabf52dba76d00c79"]',
+
+ // Save Control
+ SAVE_TRIGGER: '[data-id="save-control-popover-trigger"]',
+ SAVE_CONTENT: '[data-id="save-control-popover-content"]',
+ SAVE_AGENT_BUTTON: '[data-id="save-control-save-agent"]',
+ SAVE_NAME_INPUT: '[data-id="save-control-name-input"]',
+ SAVE_DESCRIPTION_INPUT: '[data-id="save-control-description-input"]',
+
+ // Builder Actions (Run, Schedule, Outputs)
+ BUILDER_ACTIONS: '[data-id="builder-actions"]',
+ RUN_BUTTON: '[data-id="run-graph-button"]',
+ STOP_BUTTON: '[data-id="stop-graph-button"]',
+ SCHEDULE_BUTTON: '[data-id="schedule-graph-button"]',
+ AGENT_OUTPUTS_BUTTON: '[data-id="agent-outputs-button"]',
+
+ // Run Input Dialog
+ RUN_INPUT_DIALOG_CONTENT: '[data-id="run-input-dialog-content"]',
+ RUN_INPUT_CREDENTIALS_SECTION: '[data-id="run-input-credentials-section"]',
+ RUN_INPUT_CREDENTIALS_FORM: '[data-id="run-input-credentials-form"]',
+ RUN_INPUT_INPUTS_SECTION: '[data-id="run-input-inputs-section"]',
+ RUN_INPUT_INPUTS_FORM: '[data-id="run-input-inputs-form"]',
+ RUN_INPUT_ACTIONS_SECTION: '[data-id="run-input-actions-section"]',
+ RUN_INPUT_MANUAL_RUN_BUTTON: '[data-id="run-input-manual-run-button"]',
+ RUN_INPUT_SCHEDULE_BUTTON: '[data-id="run-input-schedule-button"]',
+
+ // Custom Controls (bottom left)
+ CUSTOM_CONTROLS: '[data-id="custom-controls"]',
+ ZOOM_IN_BUTTON: '[data-id="zoom-in-button"]',
+ ZOOM_OUT_BUTTON: '[data-id="zoom-out-button"]',
+ FIT_VIEW_BUTTON: '[data-id="fit-view-button"]',
+ LOCK_BUTTON: '[data-id="lock-button"]',
+ TUTORIAL_BUTTON: '[data-id="tutorial-button"]',
+
+ // Canvas
+ REACT_FLOW_CANVAS: ".react-flow__pane",
+ REACT_FLOW_NODE: ".react-flow__node",
+ REACT_FLOW_NODE_FIRST: '[data-testid^="rf__node-"]:first-child',
+ REACT_FLOW_EDGE: '[data-testid^="rf__edge-"]',
+
+ // Node elements
+ NODE_CONTAINER: '[data-id^="custom-node-"]',
+ NODE_HEADER: '[data-id^="node-header-"]',
+ NODE_INPUT_HANDLES: '[data-tutorial-id="input-handles"]',
+ NODE_OUTPUT_HANDLE: '[data-handlepos="right"]',
+ NODE_INPUT_HANDLE: "[data-nodeid]",
+ FIRST_CALCULATOR_NODE_OUTPUT: '[data-tutorial-id="node-output"]',
+ // These are the Id's of the nodes before saving
+ CALCULATOR_NODE_FORM_CONTAINER: '[data-id^="form-creator-container-1-node"]', // <-- Add this line
+ AGENT_INPUT_NODE_FORM_CONTAINER: '[data-id^="form-creator-container-2-node"]', // <-- Add this line
+ AGENT_OUTPUT_NODE_FORM_CONTAINER:
+ '[data-id^="form-creator-container-3-node"]', // <-- Add this line
+
+ // Execution badges
+ BADGE_QUEUED: '[data-id^="badge-"][data-id$="-QUEUED"]',
+ BADGE_COMPLETED: '[data-id^="badge-"][data-id$="-COMPLETED"]',
+
+ // Undo/Redo
+ UNDO_BUTTON: '[data-id="undo-button"]',
+ REDO_BUTTON: '[data-id="redo-button"]',
+} as const;
+
+export const CSS_CLASSES = {
+ DISABLE: "new-builder-tutorial-disable",
+ HIGHLIGHT: "new-builder-tutorial-highlight",
+ PULSE: "new-builder-tutorial-pulse",
+} as const;
+
+export const TUTORIAL_CONFIG = {
+ ELEMENT_CHECK_INTERVAL: 50, // ms
+ INPUT_CHECK_INTERVAL: 100, // ms
+ USE_MODAL_OVERLAY: true,
+ SCROLL_BEHAVIOR: "smooth" as const,
+ SCROLL_BLOCK: "center" as const,
+ SEARCH_TERM_CALCULATOR: "Calculator",
+} as const;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/blocks.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/blocks.ts
new file mode 100644
index 0000000000..040991450f
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/blocks.ts
@@ -0,0 +1,89 @@
+import { BLOCK_IDS } from "../constants";
+import { useNodeStore } from "../../../../stores/nodeStore";
+import { getV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/default/default";
+import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
+
+const prefetchedBlocks: Map = new Map();
+
+export const prefetchTutorialBlocks = async (): Promise => {
+ try {
+ const blockIds = [BLOCK_IDS.CALCULATOR];
+ const response = await getV2GetSpecificBlocks({ block_ids: blockIds });
+
+ if (response.status === 200 && response.data) {
+ response.data.forEach((block) => {
+ prefetchedBlocks.set(block.id, block);
+ });
+ console.debug("Tutorial blocks prefetched:", prefetchedBlocks.size);
+ }
+ } catch (error) {
+ console.error("Failed to prefetch tutorial blocks:", error);
+ }
+};
+
+export const getPrefetchedBlock = (blockId: string): BlockInfo | undefined => {
+ return prefetchedBlocks.get(blockId);
+};
+
+export const clearPrefetchedBlocks = (): void => {
+ prefetchedBlocks.clear();
+};
+
+export const addPrefetchedBlock = (
+ blockId: string,
+ position?: { x: number; y: number },
+): void => {
+ const block = prefetchedBlocks.get(blockId);
+ if (block) {
+ useNodeStore.getState().addBlock(block, {}, position);
+ } else {
+ console.error(`Block ${blockId} not found in prefetched blocks`);
+ }
+};
+
+export const getNodeByBlockId = (blockId: string) => {
+ const nodes = useNodeStore.getState().nodes;
+ return nodes.find((n) => n.data?.block_id === blockId);
+};
+
+export const addSecondCalculatorBlock = (): void => {
+ const firstCalculatorNode = getNodeByBlockId(BLOCK_IDS.CALCULATOR);
+
+ if (firstCalculatorNode) {
+ const calcX = firstCalculatorNode.position.x;
+ const calcY = firstCalculatorNode.position.y;
+
+ addPrefetchedBlock(BLOCK_IDS.CALCULATOR, {
+ x: calcX + 500,
+ y: calcY,
+ });
+ } else {
+ addPrefetchedBlock(BLOCK_IDS.CALCULATOR);
+ }
+};
+
+export const getCalculatorNodes = () => {
+ const nodes = useNodeStore.getState().nodes;
+ return nodes.filter((n) => n.data?.block_id === BLOCK_IDS.CALCULATOR);
+};
+
+export const getSecondCalculatorNode = () => {
+ const calculatorNodes = getCalculatorNodes();
+ return calculatorNodes.length >= 2 ? calculatorNodes[1] : null;
+};
+
+export const getFormContainerSelector = (blockId: string): string | null => {
+ const node = getNodeByBlockId(blockId);
+ if (node) {
+ return `[data-id="form-creator-container-${node.id}"]`;
+ }
+ return null;
+};
+
+export const getFormContainerElement = (blockId: string): Element | null => {
+ const selector = getFormContainerSelector(blockId);
+ if (selector) {
+ return document.querySelector(selector);
+ }
+ return null;
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/canvas.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/canvas.ts
new file mode 100644
index 0000000000..dd3d8576d9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/canvas.ts
@@ -0,0 +1,83 @@
+import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants";
+import { useNodeStore } from "../../../../stores/nodeStore";
+
+export const waitForNodeOnCanvas = (
+ timeout = 10000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkNode = () => {
+ const storeNodes = useNodeStore.getState().nodes;
+ if (storeNodes.length > 0) {
+ const domNode = document.querySelector(
+ TUTORIAL_SELECTORS.REACT_FLOW_NODE,
+ );
+ if (domNode) {
+ resolve(domNode);
+ return;
+ }
+ }
+
+ if (Date.now() - startTime > timeout) {
+ resolve(null);
+ } else {
+ setTimeout(checkNode, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkNode();
+ });
+};
+
+export const waitForNodesCount = (
+ count: number,
+ timeout = 10000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkNodes = () => {
+ const currentCount = useNodeStore.getState().nodes.length;
+ if (currentCount >= count) {
+ resolve(true);
+ } else if (Date.now() - startTime > timeout) {
+ resolve(false);
+ } else {
+ setTimeout(checkNodes, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkNodes();
+ });
+};
+
+export const getNodesCount = (): number => {
+ return useNodeStore.getState().nodes.length;
+};
+
+export const getFirstNode = () => {
+ const nodes = useNodeStore.getState().nodes;
+ return nodes.length > 0 ? nodes[0] : null;
+};
+
+export const getNodeById = (nodeId: string) => {
+ const nodes = useNodeStore.getState().nodes;
+ return nodes.find((n) => n.id === nodeId);
+};
+
+export const nodeHasValues = (nodeId: string): boolean => {
+ const node = getNodeById(nodeId);
+ if (!node) return false;
+ const hardcodedValues = node.data?.hardcodedValues || {};
+ return Object.values(hardcodedValues).some(
+ (value) => value !== undefined && value !== null && value !== "",
+ );
+};
+
+export const fitViewToScreen = () => {
+ const fitViewButton = document.querySelector(
+ TUTORIAL_SELECTORS.FIT_VIEW_BUTTON,
+ ) as HTMLButtonElement;
+ if (fitViewButton) {
+ fitViewButton.click();
+ }
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/connections.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/connections.ts
new file mode 100644
index 0000000000..4ec8c4df74
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/connections.ts
@@ -0,0 +1,19 @@
+import { useNodeStore } from "../../../../stores/nodeStore";
+import { useEdgeStore } from "../../../../stores/edgeStore";
+
+export const isConnectionMade = (
+ sourceBlockId: string,
+ targetBlockId: string,
+): boolean => {
+ const edges = useEdgeStore.getState().edges;
+ const nodes = useNodeStore.getState().nodes;
+
+ const sourceNode = nodes.find((n) => n.data?.block_id === sourceBlockId);
+ const targetNode = nodes.find((n) => n.data?.block_id === targetBlockId);
+
+ if (!sourceNode || !targetNode) return false;
+
+ return edges.some((edge) => {
+ return edge.source === sourceNode.id && edge.target === targetNode.id;
+ });
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/dom.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/dom.ts
new file mode 100644
index 0000000000..3ae9359cd9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/dom.ts
@@ -0,0 +1,180 @@
+import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants";
+
+export const waitForElement = (
+ selector: string,
+ timeout = 10000,
+): Promise => {
+ return new Promise((resolve, reject) => {
+ const startTime = Date.now();
+
+ const checkElement = () => {
+ const element = document.querySelector(selector);
+ if (element) {
+ resolve(element);
+ } else if (Date.now() - startTime > timeout) {
+ reject(new Error(`Element ${selector} not found within ${timeout}ms`));
+ } else {
+ setTimeout(checkElement, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkElement();
+ });
+};
+
+export const waitForInputValue = (
+ selector: string,
+ targetValue: string,
+ timeout = 30000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkInput = () => {
+ const input = document.querySelector(selector) as HTMLInputElement;
+ if (input) {
+ const currentValue = input.value.toLowerCase().trim();
+ const target = targetValue.toLowerCase().trim();
+
+ if (currentValue.includes(target) || target.includes(currentValue)) {
+ if (currentValue.length >= 4 || currentValue === target) {
+ resolve();
+ return;
+ }
+ }
+ }
+
+ if (Date.now() - startTime > timeout) {
+ resolve();
+ } else {
+ setTimeout(checkInput, TUTORIAL_CONFIG.INPUT_CHECK_INTERVAL);
+ }
+ };
+ checkInput();
+ });
+};
+
+export const waitForSearchResult = (
+ selector: string,
+ timeout = 15000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkResult = () => {
+ const element = document.querySelector(selector);
+ if (element) {
+ resolve(element);
+ } else if (Date.now() - startTime > timeout) {
+ resolve(null);
+ } else {
+ setTimeout(checkResult, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkResult();
+ });
+};
+
+export const waitForAnyBlockCard = (
+ timeout = 10000,
+): Promise => {
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const checkBlock = () => {
+ const block = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX,
+ );
+ if (block) {
+ resolve(block);
+ } else if (Date.now() - startTime > timeout) {
+ resolve(null);
+ } else {
+ setTimeout(checkBlock, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+ }
+ };
+ checkBlock();
+ });
+};
+
+export const focusElement = (selector: string): void => {
+ const element = document.querySelector(selector) as HTMLElement;
+ if (element) {
+ element.focus();
+ }
+};
+
+export const scrollIntoView = (selector: string): void => {
+ const element = document.querySelector(selector);
+ if (element) {
+ element.scrollIntoView({
+ behavior: "smooth",
+ block: "center",
+ });
+ }
+};
+
+export const typeIntoInput = (selector: string, text: string) => {
+ const input = document.querySelector(selector) as HTMLInputElement;
+ if (input) {
+ input.focus();
+ input.value = text;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+};
+
+export const observeElement = (
+ selector: string,
+ callback: (element: Element) => void,
+): MutationObserver => {
+ const observer = new MutationObserver((mutations, obs) => {
+ const element = document.querySelector(selector);
+ if (element) {
+ callback(element);
+ obs.disconnect();
+ }
+ });
+
+ observer.observe(document.body, {
+ childList: true,
+ subtree: true,
+ });
+
+ const element = document.querySelector(selector);
+ if (element) {
+ callback(element);
+ observer.disconnect();
+ }
+
+ return observer;
+};
+
+export const watchSearchInput = (
+ targetValue: string,
+ onMatch: () => void,
+): (() => void) => {
+ const input = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT,
+ ) as HTMLInputElement;
+ if (!input) return () => {};
+
+ let hasMatched = false;
+
+ const handler = () => {
+ if (hasMatched) return;
+
+ const currentValue = input.value.toLowerCase().trim();
+ const target = targetValue.toLowerCase().trim();
+
+ if (currentValue.length >= 4 && target.startsWith(currentValue)) {
+ hasMatched = true;
+ onMatch();
+ }
+ };
+
+ input.addEventListener("input", handler);
+
+ return () => {
+ input.removeEventListener("input", handler);
+ };
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/highlights.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/highlights.ts
new file mode 100644
index 0000000000..4f425f5c9c
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/highlights.ts
@@ -0,0 +1,56 @@
+import { CSS_CLASSES, TUTORIAL_SELECTORS } from "../constants";
+
+export const disableOtherBlocks = (targetBlockSelector: string) => {
+ document
+ .querySelectorAll(TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX)
+ .forEach((block) => {
+ const isTarget = block.matches(targetBlockSelector);
+ block.classList.toggle(CSS_CLASSES.DISABLE, !isTarget);
+ block.classList.toggle(CSS_CLASSES.HIGHLIGHT, isTarget);
+ });
+};
+
+export const enableAllBlocks = () => {
+ document
+ .querySelectorAll(TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX)
+ .forEach((block) => {
+ block.classList.remove(
+ CSS_CLASSES.DISABLE,
+ CSS_CLASSES.HIGHLIGHT,
+ CSS_CLASSES.PULSE,
+ );
+ });
+};
+
+export const highlightElement = (selector: string) => {
+ const element = document.querySelector(selector);
+ if (element) {
+ element.classList.add(CSS_CLASSES.HIGHLIGHT);
+ }
+};
+
+export const removeAllHighlights = () => {
+ document.querySelectorAll(`.${CSS_CLASSES.HIGHLIGHT}`).forEach((el) => {
+ el.classList.remove(CSS_CLASSES.HIGHLIGHT);
+ });
+ document.querySelectorAll(`.${CSS_CLASSES.PULSE}`).forEach((el) => {
+ el.classList.remove(CSS_CLASSES.PULSE);
+ });
+};
+
+export const pulseElement = (selector: string) => {
+ const element = document.querySelector(selector);
+ if (element) {
+ element.classList.add(CSS_CLASSES.PULSE);
+ }
+};
+
+export const highlightFirstBlockInSearch = () => {
+ const firstBlock = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCK_CARD_PREFIX,
+ );
+ if (firstBlock) {
+ firstBlock.classList.add(CSS_CLASSES.PULSE);
+ firstBlock.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/index.ts
new file mode 100644
index 0000000000..bd60ef06bb
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/index.ts
@@ -0,0 +1,66 @@
+export {
+ waitForElement,
+ waitForInputValue,
+ waitForSearchResult,
+ waitForAnyBlockCard,
+ focusElement,
+ scrollIntoView,
+ typeIntoInput,
+ observeElement,
+ watchSearchInput,
+} from "./dom";
+
+export {
+ disableOtherBlocks,
+ enableAllBlocks,
+ highlightElement,
+ removeAllHighlights,
+ pulseElement,
+ highlightFirstBlockInSearch,
+} from "./highlights";
+
+export {
+ prefetchTutorialBlocks,
+ getPrefetchedBlock,
+ clearPrefetchedBlocks,
+ addPrefetchedBlock,
+ getNodeByBlockId,
+ addSecondCalculatorBlock,
+ getCalculatorNodes,
+ getSecondCalculatorNode,
+ getFormContainerSelector,
+ getFormContainerElement,
+} from "./blocks";
+
+export {
+ waitForNodeOnCanvas,
+ waitForNodesCount,
+ getNodesCount,
+ getFirstNode,
+ getNodeById,
+ nodeHasValues,
+ fitViewToScreen,
+} from "./canvas";
+
+export { isConnectionMade } from "./connections";
+
+export {
+ forceBlockMenuOpen,
+ openBlockMenu,
+ closeBlockMenu,
+ clearBlockMenuSearch,
+} from "./menu";
+
+export {
+ openSaveControl,
+ closeSaveControl,
+ forceSaveOpen,
+ clickSaveButton,
+ isAgentSaved,
+} from "./save";
+
+export {
+ handleTutorialCancel,
+ handleTutorialSkip,
+ handleTutorialComplete,
+} from "./state";
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/menu.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/menu.ts
new file mode 100644
index 0000000000..905e34dfb9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/menu.ts
@@ -0,0 +1,25 @@
+import { TUTORIAL_SELECTORS } from "../constants";
+import { useControlPanelStore } from "../../../../stores/controlPanelStore";
+
+export const forceBlockMenuOpen = (force: boolean) => {
+ useControlPanelStore.getState().setForceOpenBlockMenu(force);
+};
+
+export const openBlockMenu = () => {
+ useControlPanelStore.getState().setBlockMenuOpen(true);
+};
+
+export const closeBlockMenu = () => {
+ useControlPanelStore.getState().setBlockMenuOpen(false);
+ useControlPanelStore.getState().setForceOpenBlockMenu(false);
+};
+
+export const clearBlockMenuSearch = () => {
+ const input = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT,
+ ) as HTMLInputElement;
+ if (input) {
+ input.value = "";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/save.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/save.ts
new file mode 100644
index 0000000000..e932ee1b84
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/save.ts
@@ -0,0 +1,31 @@
+import { TUTORIAL_SELECTORS } from "../constants";
+import { useControlPanelStore } from "../../../../stores/controlPanelStore";
+
+export const openSaveControl = () => {
+ useControlPanelStore.getState().setSaveControlOpen(true);
+};
+
+export const closeSaveControl = () => {
+ useControlPanelStore.getState().setSaveControlOpen(false);
+ useControlPanelStore.getState().setForceOpenSave(false);
+};
+
+export const forceSaveOpen = (force: boolean) => {
+ useControlPanelStore.getState().setForceOpenSave(force);
+};
+
+export const clickSaveButton = () => {
+ const saveButton = document.querySelector(
+ TUTORIAL_SELECTORS.SAVE_AGENT_BUTTON,
+ ) as HTMLButtonElement;
+ if (saveButton && !saveButton.disabled) {
+ saveButton.click();
+ }
+};
+
+export const isAgentSaved = (): boolean => {
+ const versionInput = document.querySelector(
+ '[data-tutorial-id="save-control-version-output"]',
+ ) as HTMLInputElement;
+ return !!(versionInput && versionInput.value && versionInput.value !== "-");
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/state.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/state.ts
new file mode 100644
index 0000000000..43e19c40d4
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/helpers/state.ts
@@ -0,0 +1,49 @@
+import { Key, storage } from "@/services/storage/local-storage";
+import { closeBlockMenu } from "./menu";
+import { closeSaveControl, forceSaveOpen } from "./save";
+import { removeAllHighlights, enableAllBlocks } from "./highlights";
+
+const clearTutorialIntervals = () => {
+ const intervalKeys = [
+ "__tutorialCalcInterval",
+ "__tutorialCheckInterval",
+ "__tutorialSecondCalcInterval",
+ ];
+
+ intervalKeys.forEach((key) => {
+ if ((window as any)[key]) {
+ clearInterval((window as any)[key]);
+ delete (window as any)[key];
+ }
+ });
+};
+
+export const handleTutorialCancel = (_tour?: any) => {
+ clearTutorialIntervals();
+ closeBlockMenu();
+ closeSaveControl();
+ forceSaveOpen(false);
+ removeAllHighlights();
+ enableAllBlocks();
+ storage.set(Key.SHEPHERD_TOUR, "canceled");
+};
+
+export const handleTutorialSkip = (_tour?: any) => {
+ clearTutorialIntervals();
+ closeBlockMenu();
+ closeSaveControl();
+ forceSaveOpen(false);
+ removeAllHighlights();
+ enableAllBlocks();
+ storage.set(Key.SHEPHERD_TOUR, "skipped");
+};
+
+export const handleTutorialComplete = () => {
+ clearTutorialIntervals();
+ closeBlockMenu();
+ closeSaveControl();
+ forceSaveOpen(false);
+ removeAllHighlights();
+ enableAllBlocks();
+ storage.set(Key.SHEPHERD_TOUR, "completed");
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/icons.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/icons.ts
new file mode 100644
index 0000000000..2c7a22b423
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/icons.ts
@@ -0,0 +1,32 @@
+type IconOptions = {
+ size?: number;
+ color?: string;
+};
+
+const DEFAULT_SIZE = 16;
+const DEFAULT_COLOR = "#52525b"; // zinc-600
+
+const iconPaths = {
+ ClickIcon: `M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z`,
+ Keyboard: `M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z`,
+ Drag: `M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z`,
+};
+
+function createIcon(path: string, options: IconOptions = {}): string {
+ const size = options.size ?? DEFAULT_SIZE;
+ const color = options.color ?? DEFAULT_COLOR;
+ return ` `;
+}
+
+export const ICONS = {
+ ClickIcon: createIcon(iconPaths.ClickIcon),
+ Keyboard: createIcon(iconPaths.Keyboard),
+ Drag: createIcon(iconPaths.Drag),
+};
+
+export function getIcon(
+ name: keyof typeof iconPaths,
+ options?: IconOptions,
+): string {
+ return createIcon(iconPaths[name], options);
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/index.ts
new file mode 100644
index 0000000000..49f505054b
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/index.ts
@@ -0,0 +1,84 @@
+import Shepherd from "shepherd.js";
+import { analytics } from "@/services/analytics";
+import { TUTORIAL_CONFIG } from "./constants";
+import { createTutorialSteps } from "./steps";
+import { injectTutorialStyles, removeTutorialStyles } from "./styles";
+import {
+ handleTutorialComplete,
+ handleTutorialCancel,
+ prefetchTutorialBlocks,
+ clearPrefetchedBlocks,
+} from "./helpers";
+import { useNodeStore } from "../../../stores/nodeStore";
+import { useEdgeStore } from "../../../stores/edgeStore";
+import { useTutorialStore } from "../../../stores/tutorialStore";
+
+let isTutorialLoading = false;
+let tutorialLoadingCallback: ((loading: boolean) => void) | null = null;
+
+export const setTutorialLoadingCallback = (
+ callback: (loading: boolean) => void,
+) => {
+ tutorialLoadingCallback = callback;
+};
+
+export const getTutorialLoadingState = () => isTutorialLoading;
+
+export const startTutorial = async () => {
+ isTutorialLoading = true;
+ tutorialLoadingCallback?.(true);
+
+ useNodeStore.getState().setNodes([]);
+ useEdgeStore.getState().setEdges([]);
+ useNodeStore.getState().setNodeCounter(0);
+
+ try {
+ await prefetchTutorialBlocks();
+ } finally {
+ isTutorialLoading = false;
+ tutorialLoadingCallback?.(false);
+ }
+
+ const tour = new Shepherd.Tour({
+ useModalOverlay: TUTORIAL_CONFIG.USE_MODAL_OVERLAY,
+ defaultStepOptions: {
+ cancelIcon: { enabled: true },
+ scrollTo: {
+ behavior: TUTORIAL_CONFIG.SCROLL_BEHAVIOR,
+ block: TUTORIAL_CONFIG.SCROLL_BLOCK,
+ },
+ classes: "new-builder-tour",
+ modalOverlayOpeningRadius: 4,
+ },
+ });
+
+ injectTutorialStyles();
+
+ const steps = createTutorialSteps(tour);
+ steps.forEach((step) => tour.addStep(step));
+
+ tour.on("complete", () => {
+ handleTutorialComplete();
+ removeTutorialStyles();
+ clearPrefetchedBlocks();
+ useTutorialStore.getState().setIsTutorialRunning(false);
+ });
+
+ tour.on("cancel", () => {
+ handleTutorialCancel(tour);
+ removeTutorialStyles();
+ clearPrefetchedBlocks();
+ useTutorialStore.getState().setIsTutorialRunning(false);
+ });
+
+ for (const step of tour.steps) {
+ step.on("show", () => {
+ console.debug("sendTutorialStep", step.id);
+ analytics.sendGAEvent("event", "tutorial_step_shown", {
+ value: step.id,
+ });
+ });
+ }
+
+ tour.start();
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-basics.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-basics.ts
new file mode 100644
index 0000000000..89be102a74
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-basics.ts
@@ -0,0 +1,114 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_SELECTORS } from "../constants";
+import {
+ waitForElement,
+ waitForNodeOnCanvas,
+ closeBlockMenu,
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+
+export const createBlockBasicsSteps = (tour: any): StepOptions[] => [
+ {
+ id: "focus-new-block",
+ title: "Your First Block!",
+ text: `
+
+
Excellent! This is your Calculator Block .
+
Let's explore how blocks work.
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.REACT_FLOW_NODE,
+ on: "right",
+ },
+ beforeShowPromise: async () => {
+ closeBlockMenu();
+ await waitForNodeOnCanvas(5000);
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ fitViewToScreen();
+ },
+ when: {
+ show: () => {
+ const node = document.querySelector(TUTORIAL_SELECTORS.REACT_FLOW_NODE);
+ if (node) {
+ highlightElement(TUTORIAL_SELECTORS.REACT_FLOW_NODE);
+ }
+ },
+ hide: () => {
+ removeAllHighlights();
+ },
+ },
+ buttons: [
+ {
+ text: "Show me",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "input-handles",
+ title: "Input Handles",
+ text: `
+
+
On the left side of the block are input handles .
+
These are where data flows into the block from other blocks.
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.NODE_INPUT_HANDLE,
+ on: "bottom",
+ },
+ classes: "new-builder-tour input-handles-step",
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.NODE_INPUT_HANDLE, 3000).catch(
+ () => {},
+ ),
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Next",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "output-handles",
+ title: "Output Handles",
+ text: `
+
+
On the right side is the output handle .
+
This is where the result flows out to connect to other blocks.
+ ${banner(ICONS.Drag, "You can drag from output to input handler to connect blocks", "info")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.NODE_OUTPUT_HANDLE,
+ on: "right",
+ },
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.NODE_OUTPUT_HANDLE, 3000).catch(
+ () => {},
+ ),
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Next →",
+ action: () => tour.next(),
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-menu.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-menu.ts
new file mode 100644
index 0000000000..6a1eaf33d5
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/block-menu.ts
@@ -0,0 +1,198 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS, BLOCK_IDS } from "../constants";
+import {
+ waitForElement,
+ forceBlockMenuOpen,
+ focusElement,
+ highlightElement,
+ removeAllHighlights,
+ disableOtherBlocks,
+ enableAllBlocks,
+ pulseElement,
+ highlightFirstBlockInSearch,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+import { useNodeStore } from "../../../../stores/nodeStore";
+
+export const createBlockMenuSteps = (tour: any): StepOptions[] => [
+ {
+ id: "open-block-menu",
+ title: "Open the Block Menu",
+ text: `
+
+
Let's start by opening the Block Menu.
+ ${banner(ICONS.ClickIcon, "Click this button to open the menu", "action")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.BLOCKS_TRIGGER,
+ on: "right",
+ },
+ advanceOn: {
+ selector: TUTORIAL_SELECTORS.BLOCKS_TRIGGER,
+ event: "click",
+ },
+ buttons: [],
+ when: {
+ show: () => {
+ highlightElement(TUTORIAL_SELECTORS.BLOCKS_TRIGGER);
+ },
+ hide: () => {
+ removeAllHighlights();
+ },
+ },
+ },
+
+ {
+ id: "block-menu-overview",
+ title: "The Block Menu",
+ text: `
+
+
This is the Block Menu — your toolbox for building agents.
+
Here you'll find:
+
+ Input Blocks — Entry points for data
+ Action Blocks — Processing and AI operations
+ Output Blocks — Results and responses
+ Integrations — Third-party service blocks
+ Library Agents — Your personal agents
+ Marketplace Agents — Community agents
+
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.BLOCKS_CONTENT,
+ on: "left",
+ },
+ beforeShowPromise: () => waitForElement(TUTORIAL_SELECTORS.BLOCKS_CONTENT),
+ when: {
+ show: () => forceBlockMenuOpen(true),
+ },
+ buttons: [
+ {
+ text: "Next",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "search-calculator",
+ title: "Search for a Block",
+ text: `
+
+
Let's add a Calculator block to start.
+ ${banner(ICONS.Keyboard, "Type Calculator in the search bar", "action")}
+
The search will filter blocks as you type.
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT_BOX,
+ on: "bottom",
+ },
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT_BOX),
+ when: {
+ show: () => {
+ forceBlockMenuOpen(true);
+ setTimeout(() => {
+ focusElement(TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT_BOX);
+ }, 100);
+
+ const checkForCalculator = setInterval(() => {
+ const calcBlock = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR_IN_SEARCH,
+ );
+ if (calcBlock) {
+ clearInterval(checkForCalculator);
+
+ const searchInput = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCKS_SEARCH_INPUT,
+ ) as HTMLInputElement;
+ if (searchInput) {
+ searchInput.blur();
+ }
+
+ disableOtherBlocks(
+ TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR_IN_SEARCH,
+ );
+ pulseElement(TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR_IN_SEARCH);
+ calcBlock.scrollIntoView({ behavior: "smooth", block: "center" });
+ setTimeout(() => {
+ tour.next();
+ }, 300);
+ }
+ }, TUTORIAL_CONFIG.ELEMENT_CHECK_INTERVAL);
+
+ (window as any).__tutorialCalcInterval = checkForCalculator;
+ },
+ hide: () => {
+ if ((window as any).__tutorialCalcInterval) {
+ clearInterval((window as any).__tutorialCalcInterval);
+ delete (window as any).__tutorialCalcInterval;
+ }
+ enableAllBlocks();
+ },
+ },
+ buttons: [],
+ },
+
+ {
+ id: "select-calculator",
+ title: "Add the Calculator Block",
+ text: `
+
+
You should see the Calculator block in the results.
+ ${banner(ICONS.ClickIcon, "Click on the Calculator block to add it", "action")}
+
+
+ ${ICONS.Drag}
+ You can also drag blocks onto the canvas
+
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR,
+ on: "left",
+ },
+ beforeShowPromise: async () => {
+ forceBlockMenuOpen(true);
+ await waitForElement(TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR, 5000);
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ },
+ when: {
+ show: () => {
+ const calcBlock = document.querySelector(
+ TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR,
+ );
+ if (calcBlock) {
+ disableOtherBlocks(TUTORIAL_SELECTORS.BLOCK_CARD_CALCULATOR);
+ } else {
+ highlightFirstBlockInSearch();
+ }
+
+ const CALCULATOR_BLOCK_ID = BLOCK_IDS.CALCULATOR;
+
+ const initialNodeCount = useNodeStore.getState().nodes.length;
+
+ const unsubscribe = useNodeStore.subscribe((state) => {
+ if (state.nodes.length > initialNodeCount) {
+ const calculatorNode = state.nodes.find(
+ (node) => node.data?.block_id === CALCULATOR_BLOCK_ID,
+ );
+
+ if (calculatorNode) {
+ unsubscribe();
+ enableAllBlocks();
+ forceBlockMenuOpen(false);
+ tour.next();
+ }
+ }
+ });
+
+ (tour.getCurrentStep() as any)._nodeUnsubscribe = unsubscribe;
+ },
+ },
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/completion.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/completion.ts
new file mode 100644
index 0000000000..12d3f3fd92
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/completion.ts
@@ -0,0 +1,51 @@
+import { StepOptions } from "shepherd.js";
+
+export const createCompletionSteps = (tour: any): StepOptions[] => [
+ {
+ id: "congratulations",
+ title: "Congratulations! 🎉",
+ text: `
+
+
You have successfully created and run your first agent flow!
+
+
+
You learned how to:
+
+ • Add blocks from the Block Menu
+ • Understand input and output handles
+ • Configure block values
+ • Connect blocks together
+ • Save and run your agent
+ • View execution status and output
+
+
+
+
Happy building! 🚀
+
+ `,
+ when: {
+ show: () => {
+ const modal = document.querySelector(
+ ".shepherd-modal-overlay-container",
+ );
+ if (modal) {
+ (modal as HTMLElement).style.opacity = "0.3";
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Restart Tutorial",
+ action: () => {
+ tour.cancel();
+ setTimeout(() => tour.start(), 100);
+ },
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Finish",
+ action: () => tour.complete(),
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/configure-calculator.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/configure-calculator.ts
new file mode 100644
index 0000000000..9a4a542437
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/configure-calculator.ts
@@ -0,0 +1,197 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_SELECTORS } from "../constants";
+import {
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+ getFirstNode,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+
+const getRequirementsHtml = () => `
+
+
⚠️ Required to continue:
+
+
+ ○ Enter a number in field A (e.g., 10)
+
+
+ ○ Enter a number in field B (e.g., 5)
+
+
+ ○ Select an Operation (Add, Multiply, etc.)
+
+
+
+`;
+
+const updateToSuccessState = () => {
+ const reqBox = document.querySelector("#requirements-box");
+ const reqTitle = document.querySelector("#requirements-title");
+ const reqList = document.querySelector("#requirements-list");
+
+ if (reqBox && reqTitle) {
+ reqBox.classList.remove("bg-amber-50", "ring-amber-200");
+ reqBox.classList.add("bg-green-50", "ring-green-200");
+ reqTitle.classList.remove("text-amber-600");
+ reqTitle.classList.add("text-green-600");
+ reqTitle.innerHTML = "🎉 Hurray! All values are completed!";
+ if (reqList) {
+ reqList.classList.add("hidden");
+ }
+ }
+};
+
+const updateToWarningState = () => {
+ const reqBox = document.querySelector("#requirements-box");
+ const reqTitle = document.querySelector("#requirements-title");
+ const reqList = document.querySelector("#requirements-list");
+
+ if (reqBox && reqTitle) {
+ reqBox.classList.remove("bg-green-50", "ring-green-200");
+ reqBox.classList.add("bg-amber-50", "ring-amber-200");
+ reqTitle.classList.remove("text-green-600");
+ reqTitle.classList.add("text-amber-600");
+ reqTitle.innerHTML = "⚠️ Required to continue:";
+ if (reqList) {
+ reqList.classList.remove("hidden");
+ }
+ }
+};
+
+export const createConfigureCalculatorSteps = (tour: any): StepOptions[] => [
+ {
+ id: "enter-values",
+ title: "Enter Values",
+ text: `
+
+
Now let's configure the block with actual values.
+ ${getRequirementsHtml()}
+ ${banner(ICONS.ClickIcon, "Fill in all the required fields above", "action")}
+
+ `,
+ beforeShowPromise: () => {
+ fitViewToScreen();
+ return Promise.resolve();
+ },
+ attachTo: {
+ element: TUTORIAL_SELECTORS.CALCULATOR_NODE_FORM_CONTAINER,
+ on: "right",
+ },
+ when: {
+ show: () => {
+ const node = getFirstNode();
+ if (node) {
+ highlightElement(`[data-id="custom-node-${node.id}"]`);
+ }
+
+ let wasComplete = false;
+
+ const checkInterval = setInterval(() => {
+ const node = getFirstNode();
+ if (!node) return;
+
+ const hardcodedValues = node.data?.hardcodedValues || {};
+ const hasA =
+ hardcodedValues.a !== undefined &&
+ hardcodedValues.a !== null &&
+ hardcodedValues.a !== "";
+ const hasB =
+ hardcodedValues.b !== undefined &&
+ hardcodedValues.b !== null &&
+ hardcodedValues.b !== "";
+ const hasOp =
+ hardcodedValues.operation !== undefined &&
+ hardcodedValues.operation !== null &&
+ hardcodedValues.operation !== "";
+
+ const allComplete = hasA && hasB && hasOp;
+
+ const reqA = document.querySelector("#req-a .req-icon");
+ const reqB = document.querySelector("#req-b .req-icon");
+ const reqOp = document.querySelector("#req-op .req-icon");
+
+ if (reqA) reqA.textContent = hasA ? "✓" : "○";
+ if (reqB) reqB.textContent = hasB ? "✓" : "○";
+ if (reqOp) reqOp.textContent = hasOp ? "✓" : "○";
+
+ const reqAEl = document.querySelector("#req-a");
+ const reqBEl = document.querySelector("#req-b");
+ const reqOpEl = document.querySelector("#req-op");
+
+ if (reqAEl) {
+ reqAEl.classList.toggle("text-green-600", hasA);
+ reqAEl.classList.toggle("text-amber-600", !hasA);
+ }
+ if (reqBEl) {
+ reqBEl.classList.toggle("text-green-600", hasB);
+ reqBEl.classList.toggle("text-amber-600", !hasB);
+ }
+ if (reqOpEl) {
+ reqOpEl.classList.toggle("text-green-600", hasOp);
+ reqOpEl.classList.toggle("text-amber-600", !hasOp);
+ }
+
+ if (allComplete && !wasComplete) {
+ updateToSuccessState();
+ wasComplete = true;
+ } else if (!allComplete && wasComplete) {
+ updateToWarningState();
+ wasComplete = false;
+ }
+
+ const nextBtn = document.querySelector(
+ ".shepherd-button-primary",
+ ) as HTMLButtonElement;
+ if (nextBtn) {
+ nextBtn.style.opacity = allComplete ? "1" : "0.5";
+ nextBtn.style.pointerEvents = allComplete ? "auto" : "none";
+ }
+ }, 300);
+
+ (window as any).__tutorialCheckInterval = checkInterval;
+ },
+ hide: () => {
+ removeAllHighlights();
+ if ((window as any).__tutorialCheckInterval) {
+ clearInterval((window as any).__tutorialCheckInterval);
+ delete (window as any).__tutorialCheckInterval;
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Continue",
+ action: () => {
+ const node = getFirstNode();
+ if (!node) return;
+
+ const hardcodedValues = node.data?.hardcodedValues || {};
+ const hasA =
+ hardcodedValues.a !== undefined &&
+ hardcodedValues.a !== null &&
+ hardcodedValues.a !== "";
+ const hasB =
+ hardcodedValues.b !== undefined &&
+ hardcodedValues.b !== null &&
+ hardcodedValues.b !== "";
+ const hasOp =
+ hardcodedValues.operation !== undefined &&
+ hardcodedValues.operation !== null &&
+ hardcodedValues.operation !== "";
+
+ if (hasA && hasB && hasOp) {
+ tour.next();
+ }
+ },
+ classes: "shepherd-button-primary",
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/connections.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/connections.ts
new file mode 100644
index 0000000000..7d0ebc9116
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/connections.ts
@@ -0,0 +1,276 @@
+import { StepOptions } from "shepherd.js";
+import {
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+import { useEdgeStore } from "../../../../stores/edgeStore";
+import { TUTORIAL_SELECTORS } from "../constants";
+
+const getConnectionStatusHtml = (id: string, isConnected: boolean = false) => `
+
+ ${isConnected ? "✅ Connected!" : "Waiting for connection..."}
+
+`;
+
+const updateConnectionStatus = (
+ id: string,
+ isConnected: boolean,
+ message?: string,
+) => {
+ const statusEl = document.querySelector(`#${id}`);
+ if (statusEl) {
+ statusEl.innerHTML =
+ message || (isConnected ? "✅ Connected!" : "Waiting for connection...");
+ statusEl.classList.remove(
+ "bg-amber-50",
+ "ring-amber-200",
+ "text-amber-600",
+ "bg-green-50",
+ "ring-green-200",
+ "text-green-600",
+ );
+ if (isConnected) {
+ statusEl.classList.add("bg-green-50", "ring-green-200", "text-green-600");
+ } else {
+ statusEl.classList.add("bg-amber-50", "ring-amber-200", "text-amber-600");
+ }
+ }
+};
+
+const hasAnyEdge = (): boolean => {
+ return useEdgeStore.getState().edges.length > 0;
+};
+
+export const createConnectionSteps = (tour: any): StepOptions[] => {
+ let isConnecting = false;
+
+ const handleMouseDown = () => {
+ isConnecting = true;
+
+ const inputSelector =
+ TUTORIAL_SELECTORS.FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER;
+ if (inputSelector) {
+ highlightElement(inputSelector);
+ }
+
+ setTimeout(() => {
+ if (isConnecting) {
+ tour.next();
+ }
+ }, 100);
+ };
+
+ const resetConnectionState = () => {
+ isConnecting = false;
+ };
+
+ return [
+ {
+ id: "connect-blocks-output",
+ title: "Connect the Blocks: Output",
+ text: `
+
+
Now, let's connect the Result output of the first Calculator to the input (A) of the second Calculator.
+
+
+
Drag from the Result output:
+
Click and drag from the Result output pin (right side) of the first Calculator block .
+
+ ${getConnectionStatusHtml("connection-status-output", false)}
+ ${banner(ICONS.Drag, "Drag from the Result output pin", "action")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER,
+ on: "left",
+ },
+
+ when: {
+ show: () => {
+ resetConnectionState();
+
+ if (hasAnyEdge()) {
+ updateConnectionStatus(
+ "connection-status-output",
+ true,
+ "✅ Connection already exists!",
+ );
+ setTimeout(() => {
+ tour.next();
+ }, 1000);
+ return;
+ }
+
+ const outputSelector =
+ TUTORIAL_SELECTORS.FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER;
+ if (outputSelector) {
+ const outputHandle = document.querySelector(outputSelector);
+ if (outputHandle) {
+ highlightElement(outputSelector);
+ outputHandle.addEventListener("mousedown", handleMouseDown);
+ }
+ }
+
+ const unsubscribe = useEdgeStore.subscribe(() => {
+ if (hasAnyEdge()) {
+ updateConnectionStatus("connection-status-output", true);
+ setTimeout(() => {
+ unsubscribe();
+ tour.next();
+ }, 500);
+ }
+ });
+
+ (tour.getCurrentStep() as any)._edgeUnsubscribe = unsubscribe;
+ },
+ hide: () => {
+ removeAllHighlights();
+ const step = tour.getCurrentStep() as any;
+ if (step?._edgeUnsubscribe) {
+ step._edgeUnsubscribe();
+ }
+ const outputSelector =
+ TUTORIAL_SELECTORS.FIRST_CALCULATOR_RESULT_OUTPUT_HANDLER;
+ if (outputSelector) {
+ const outputHandle = document.querySelector(outputSelector);
+ if (outputHandle) {
+ outputHandle.removeEventListener("mousedown", handleMouseDown);
+ }
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Skip (already connected)",
+ action: () => tour.show("connection-complete"),
+ classes: "shepherd-button-secondary",
+ },
+ ],
+ },
+
+ {
+ id: "connect-blocks-input",
+ title: "Connect the Blocks: Input",
+ text: `
+
+
Now, connect to the input (A) of the second Calculator block.
+
+
+
Drop on the A input:
+
Drag to the A input handle (left side) of the second Calculator block .
+
+ ${getConnectionStatusHtml("connection-status-input", false)}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.SECOND_CALCULATOR_NUMBER_A_INPUT_HANDLER,
+ on: "right",
+ },
+ when: {
+ show: () => {
+ const inputSelector =
+ TUTORIAL_SELECTORS.SECOND_CALCULATOR_NUMBER_A_INPUT_HANDLER;
+ if (inputSelector) {
+ highlightElement(inputSelector);
+ }
+
+ if (hasAnyEdge()) {
+ updateConnectionStatus(
+ "connection-status-input",
+ true,
+ "✅ Connected!",
+ );
+ setTimeout(() => {
+ tour.next();
+ }, 500);
+ return;
+ }
+
+ const unsubscribe = useEdgeStore.subscribe(() => {
+ if (hasAnyEdge()) {
+ updateConnectionStatus("connection-status-input", true);
+ setTimeout(() => {
+ unsubscribe();
+ tour.next();
+ }, 500);
+ }
+ });
+
+ (tour.getCurrentStep() as any)._edgeUnsubscribe = unsubscribe;
+
+ const handleMouseUp = () => {
+ setTimeout(() => {
+ if (!hasAnyEdge()) {
+ isConnecting = false;
+ tour.show("connect-blocks-output");
+ }
+ }, 200);
+ };
+ document.addEventListener("mouseup", handleMouseUp, true);
+ (tour.getCurrentStep() as any)._mouseUpHandler = handleMouseUp;
+ },
+ hide: () => {
+ removeAllHighlights();
+ const step = tour.getCurrentStep() as any;
+ if (step?._edgeUnsubscribe) {
+ step._edgeUnsubscribe();
+ }
+ if (step?._mouseUpHandler) {
+ document.removeEventListener("mouseup", step._mouseUpHandler, true);
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.show("connect-blocks-output"),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Skip (already connected)",
+ action: () => tour.next(),
+ classes: "shepherd-button-secondary",
+ },
+ ],
+ },
+
+ {
+ id: "connection-complete",
+ title: "Blocks Connected! 🎉",
+ text: `
+
+
Excellent! Your Calculator blocks are now connected:
+
+
+
+ Calculator 1
+ →
+ Calculator 2
+
+
The result of Calculator 1 flows into Calculator 2's input A
+
+
+
Now let's save and run your agent!
+
+ `,
+ beforeShowPromise: async () => {
+ fitViewToScreen();
+ return Promise.resolve();
+ },
+ buttons: [
+ {
+ text: "Save My Agent",
+ action: () => tour.next(),
+ },
+ ],
+ },
+ ];
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/index.ts
new file mode 100644
index 0000000000..657dbf76bb
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/index.ts
@@ -0,0 +1,22 @@
+import { StepOptions } from "shepherd.js";
+import { createWelcomeSteps } from "./welcome";
+import { createBlockMenuSteps } from "./block-menu";
+import { createBlockBasicsSteps } from "./block-basics";
+import { createConfigureCalculatorSteps } from "./configure-calculator";
+import { createSecondCalculatorSteps } from "./second-calculator";
+import { createConnectionSteps } from "./connections";
+import { createSaveSteps } from "./save";
+import { createRunSteps } from "./run";
+import { createCompletionSteps } from "./completion";
+
+export const createTutorialSteps = (tour: any): StepOptions[] => [
+ ...createWelcomeSteps(tour),
+ ...createBlockMenuSteps(tour),
+ ...createBlockBasicsSteps(tour),
+ ...createConfigureCalculatorSteps(tour),
+ ...createSecondCalculatorSteps(tour),
+ ...createConnectionSteps(tour),
+ ...createSaveSteps(),
+ ...createRunSteps(tour),
+ ...createCompletionSteps(tour),
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/run.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/run.ts
new file mode 100644
index 0000000000..bebe69480d
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/run.ts
@@ -0,0 +1,97 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_SELECTORS } from "../constants";
+import {
+ waitForElement,
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+
+export const createRunSteps = (tour: any): StepOptions[] => [
+ {
+ id: "press-run",
+ title: "Run Your Agent",
+ text: `
+
+
Your agent is saved and ready! Now let's run it to see it in action.
+ ${banner(ICONS.ClickIcon, "Click the Run button", "action")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.RUN_BUTTON,
+ on: "top",
+ },
+ advanceOn: {
+ selector: TUTORIAL_SELECTORS.RUN_BUTTON,
+ event: "click",
+ },
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.RUN_BUTTON, 3000).catch(() => {}),
+ when: {
+ show: () => {
+ highlightElement(TUTORIAL_SELECTORS.RUN_BUTTON);
+ },
+ hide: () => {
+ removeAllHighlights();
+ setTimeout(() => {
+ fitViewToScreen();
+ }, 500);
+ },
+ },
+ buttons: [],
+ },
+
+ {
+ id: "show-output",
+ title: "View the Output",
+ text: `
+
+
Here's the output of your block!
+
+
+
Latest Output:
+
After each run, you can see the result of each block at the bottom of the block.
+
+
+
+
The output shows:
+
+ • The calculated result
+ • Execution timestamp
+
+
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.FIRST_CALCULATOR_NODE_OUTPUT,
+ on: "top",
+ },
+ beforeShowPromise: () =>
+ new Promise((resolve) => {
+ setTimeout(() => {
+ waitForElement(TUTORIAL_SELECTORS.FIRST_CALCULATOR_NODE_OUTPUT, 5000)
+ .then(() => {
+ fitViewToScreen();
+ resolve(undefined);
+ })
+ .catch(resolve);
+ }, 300);
+ }),
+ when: {
+ show: () => {
+ highlightElement(TUTORIAL_SELECTORS.FIRST_CALCULATOR_NODE_OUTPUT);
+ },
+ hide: () => {
+ removeAllHighlights();
+ },
+ },
+ buttons: [
+ {
+ text: "Finish Tutorial",
+ action: () => tour.next(),
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/save.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/save.ts
new file mode 100644
index 0000000000..32c13844ff
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/save.ts
@@ -0,0 +1,71 @@
+import { StepOptions } from "shepherd.js";
+import { TUTORIAL_SELECTORS } from "../constants";
+import {
+ waitForElement,
+ highlightElement,
+ removeAllHighlights,
+ forceSaveOpen,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+export const createSaveSteps = (): StepOptions[] => [
+ {
+ id: "open-save",
+ title: "Save Your Agent",
+ text: `
+
+
Before running, we need to save your agent.
+ ${banner(ICONS.ClickIcon, "Click the Save button", "action")}
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.SAVE_TRIGGER,
+ on: "right",
+ },
+ advanceOn: {
+ selector: TUTORIAL_SELECTORS.SAVE_TRIGGER,
+ event: "click",
+ },
+ beforeShowPromise: () =>
+ waitForElement(TUTORIAL_SELECTORS.SAVE_TRIGGER, 3000).catch(() => {}),
+ buttons: [],
+ when: {
+ show: () => {
+ highlightElement(TUTORIAL_SELECTORS.SAVE_TRIGGER);
+ },
+ hide: () => {
+ removeAllHighlights();
+ },
+ },
+ },
+
+ {
+ id: "save-details",
+ title: "Name Your Agent",
+ text: `
+
+
Give your agent a name and optional description.
+ ${banner(ICONS.ClickIcon, 'Enter a name and click "Save Agent"', "action")}
+
Example: "My Calculator Agent"
+
+ `,
+ attachTo: {
+ element: TUTORIAL_SELECTORS.SAVE_CONTENT,
+ on: "right",
+ },
+ advanceOn: {
+ selector: TUTORIAL_SELECTORS.SAVE_AGENT_BUTTON,
+ event: "click",
+ },
+ beforeShowPromise: () => waitForElement(TUTORIAL_SELECTORS.SAVE_CONTENT),
+ when: {
+ show: () => {
+ forceSaveOpen(true);
+ },
+ hide: () => {
+ forceSaveOpen(false);
+ },
+ },
+ buttons: [],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/second-calculator.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/second-calculator.ts
new file mode 100644
index 0000000000..276b2f88b9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/second-calculator.ts
@@ -0,0 +1,272 @@
+import { StepOptions } from "shepherd.js";
+import {
+ waitForNodesCount,
+ fitViewToScreen,
+ highlightElement,
+ removeAllHighlights,
+ addSecondCalculatorBlock,
+ getSecondCalculatorNode,
+} from "../helpers";
+import { ICONS } from "../icons";
+import { banner } from "../styles";
+
+const getSecondCalculatorFormSelector = (): string | HTMLElement => {
+ const secondNode = getSecondCalculatorNode();
+ if (secondNode) {
+ const selector = `[data-id="form-creator-container-${secondNode.id}-node"]`;
+ const element = document.querySelector(selector);
+ if (element) {
+ return element as HTMLElement;
+ }
+ return selector;
+ }
+ const formContainers = document.querySelectorAll(
+ '[data-id^="form-creator-container-"]',
+ );
+ if (formContainers.length >= 2) {
+ return formContainers[1] as HTMLElement;
+ }
+ return '[data-id^="form-creator-container-"]';
+};
+
+const getSecondCalcRequirementsHtml = () => `
+
+
⚠️ Required to continue:
+
+
+ ○ Enter a number in field B (e.g., 2)
+
+
+ ○ Select an Operation (e.g., Multiply)
+
+
+
Note: Field A will be connected from the first Calculator's output
+
+`;
+
+const updateSecondCalcToSuccessState = () => {
+ const reqBox = document.querySelector("#second-calc-requirements-box");
+ const reqTitle = document.querySelector("#second-calc-requirements-title");
+ const reqList = document.querySelector("#second-calc-requirements-list");
+
+ if (reqBox && reqTitle) {
+ reqBox.classList.remove("bg-amber-50", "ring-amber-200");
+ reqBox.classList.add("bg-green-50", "ring-green-200");
+ reqTitle.classList.remove("text-amber-600");
+ reqTitle.classList.add("text-green-600");
+ reqTitle.innerHTML = "🎉 Hurray! All values are completed!";
+ if (reqList) {
+ reqList.classList.add("hidden");
+ }
+ }
+};
+
+const updateSecondCalcToWarningState = () => {
+ const reqBox = document.querySelector("#second-calc-requirements-box");
+ const reqTitle = document.querySelector("#second-calc-requirements-title");
+ const reqList = document.querySelector("#second-calc-requirements-list");
+
+ if (reqBox && reqTitle) {
+ reqBox.classList.remove("bg-green-50", "ring-green-200");
+ reqBox.classList.add("bg-amber-50", "ring-amber-200");
+ reqTitle.classList.remove("text-green-600");
+ reqTitle.classList.add("text-amber-600");
+ reqTitle.innerHTML = "⚠️ Required to continue:";
+ if (reqList) {
+ reqList.classList.remove("hidden");
+ }
+ }
+};
+
+export const createSecondCalculatorSteps = (tour: any): StepOptions[] => [
+ {
+ id: "adding-second-calculator",
+ title: "Adding Second Calculator",
+ text: `
+
+
Great job configuring the first Calculator!
+
Now let's add a second Calculator block and connect them together.
+
+
+
We'll create a chain:
+
Calculator 1 → Calculator 2
+
The output of the first will feed into the second!
+
+
+ `,
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Add Second Calculator",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "second-calculator-added",
+ title: "Second Calculator Added! ✅",
+ text: `
+
+
I've added a second Calculator block to your canvas.
+
Now let's configure it and connect them together.
+
+
+
You now have 2 Calculator blocks!
+
+
+ `,
+ beforeShowPromise: async () => {
+ addSecondCalculatorBlock();
+ await waitForNodesCount(2, 5000);
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ fitViewToScreen();
+ },
+ buttons: [
+ {
+ text: "Let's configure it",
+ action: () => tour.next(),
+ },
+ ],
+ },
+
+ {
+ id: "configure-second-calculator",
+ title: "Configure Second Calculator",
+ text: `
+
+
Now configure the second Calculator block .
+ ${getSecondCalcRequirementsHtml()}
+ ${banner(ICONS.ClickIcon, "Fill in field B and select an Operation", "action")}
+
+ `,
+ beforeShowPromise: async () => {
+ fitViewToScreen();
+ await new Promise((resolve) => {
+ const checkNode = () => {
+ const secondNode = getSecondCalculatorNode();
+ if (secondNode) {
+ const formContainer = document.querySelector(
+ `[data-id="form-creator-container-${secondNode.id}-node"]`,
+ );
+ if (formContainer) {
+ resolve();
+ } else {
+ setTimeout(checkNode, 100);
+ }
+ } else {
+ setTimeout(checkNode, 100);
+ }
+ };
+ checkNode();
+ });
+ },
+ attachTo: {
+ element: getSecondCalculatorFormSelector,
+ on: "right",
+ },
+ when: {
+ show: () => {
+ const secondNode = getSecondCalculatorNode();
+ if (secondNode) {
+ highlightElement(`[data-id="custom-node-${secondNode.id}"]`);
+ }
+
+ let wasComplete = false;
+
+ const checkInterval = setInterval(() => {
+ const secondNode = getSecondCalculatorNode();
+ if (!secondNode) return;
+
+ const hardcodedValues = secondNode.data?.hardcodedValues || {};
+ const hasB =
+ hardcodedValues.b !== undefined &&
+ hardcodedValues.b !== null &&
+ hardcodedValues.b !== "";
+ const hasOp =
+ hardcodedValues.operation !== undefined &&
+ hardcodedValues.operation !== null &&
+ hardcodedValues.operation !== "";
+
+ const allComplete = hasB && hasOp;
+
+ const reqB = document.querySelector("#req2-b .req-icon");
+ const reqOp = document.querySelector("#req2-op .req-icon");
+
+ if (reqB) reqB.textContent = hasB ? "✓" : "○";
+ if (reqOp) reqOp.textContent = hasOp ? "✓" : "○";
+
+ const reqBEl = document.querySelector("#req2-b");
+ const reqOpEl = document.querySelector("#req2-op");
+
+ if (reqBEl) {
+ reqBEl.classList.toggle("text-green-600", hasB);
+ reqBEl.classList.toggle("text-amber-600", !hasB);
+ }
+ if (reqOpEl) {
+ reqOpEl.classList.toggle("text-green-600", hasOp);
+ reqOpEl.classList.toggle("text-amber-600", !hasOp);
+ }
+
+ if (allComplete && !wasComplete) {
+ updateSecondCalcToSuccessState();
+ wasComplete = true;
+ } else if (!allComplete && wasComplete) {
+ updateSecondCalcToWarningState();
+ wasComplete = false;
+ }
+
+ const nextBtn = document.querySelector(
+ ".shepherd-button-primary",
+ ) as HTMLButtonElement;
+ if (nextBtn) {
+ nextBtn.style.opacity = allComplete ? "1" : "0.5";
+ nextBtn.style.pointerEvents = allComplete ? "auto" : "none";
+ }
+ }, 300);
+
+ (window as any).__tutorialSecondCalcInterval = checkInterval;
+ },
+ hide: () => {
+ removeAllHighlights();
+ if ((window as any).__tutorialSecondCalcInterval) {
+ clearInterval((window as any).__tutorialSecondCalcInterval);
+ delete (window as any).__tutorialSecondCalcInterval;
+ }
+ },
+ },
+ buttons: [
+ {
+ text: "Back",
+ action: () => tour.back(),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Continue",
+ action: () => {
+ const secondNode = getSecondCalculatorNode();
+ if (!secondNode) return;
+
+ const hardcodedValues = secondNode.data?.hardcodedValues || {};
+ const hasB =
+ hardcodedValues.b !== undefined &&
+ hardcodedValues.b !== null &&
+ hardcodedValues.b !== "";
+ const hasOp =
+ hardcodedValues.operation !== undefined &&
+ hardcodedValues.operation !== null &&
+ hardcodedValues.operation !== "";
+
+ if (hasB && hasOp) {
+ tour.next();
+ }
+ },
+ classes: "shepherd-button-primary",
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/welcome.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/welcome.ts
new file mode 100644
index 0000000000..78c7d078b9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/steps/welcome.ts
@@ -0,0 +1,33 @@
+import { StepOptions } from "shepherd.js";
+import { handleTutorialSkip } from "../helpers";
+
+export const createWelcomeSteps = (tour: any): StepOptions[] => [
+ {
+ id: "welcome",
+ title: "Welcome to AutoGPT Builder! 👋🏻",
+ text: `
+
+
This interactive tutorial will teach you how to build your first AI agent.
+
You'll learn how to:
+
+ - Add blocks to your workflow
+ - Understand block inputs and outputs
+ - Save and run your agent
+ - and much more...
+
+
Estimated time: 3-4 minutes
+
+ `,
+ buttons: [
+ {
+ text: "Skip Tutorial",
+ action: () => handleTutorialSkip(tour),
+ classes: "shepherd-button-secondary",
+ },
+ {
+ text: "Let's Begin",
+ action: () => tour.next(),
+ },
+ ],
+ },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/styles.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/styles.ts
new file mode 100644
index 0000000000..1b0cc5359e
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/styles.ts
@@ -0,0 +1,101 @@
+/**
+ * Tutorial Styles for New Builder
+ *
+ * CSS file contains:
+ * - Dynamic classes: .new-builder-tutorial-disable, .new-builder-tutorial-highlight, .new-builder-tutorial-pulse
+ * - Shepherd.js overrides
+ *
+ * Typography (body, small, action, info, tip, warning) uses Tailwind utilities directly in steps.ts
+ */
+import "./tutorial.css";
+
+export const injectTutorialStyles = () => {
+ if (typeof window !== "undefined") {
+ document.documentElement.setAttribute("data-tutorial-styles", "loaded");
+ }
+};
+
+export const removeTutorialStyles = () => {
+ if (typeof window !== "undefined") {
+ document.documentElement.removeAttribute("data-tutorial-styles");
+ }
+};
+
+// Reusable banner components with consistent styling
+
+type BannerVariant = "action" | "info" | "warning" | "success";
+
+const bannerStyles: Record<
+ BannerVariant,
+ { bg: string; ring: string; text: string }
+> = {
+ action: {
+ bg: "bg-violet-50",
+ ring: "ring-violet-200",
+ text: "text-violet-800",
+ },
+ info: {
+ bg: "bg-blue-50",
+ ring: "ring-blue-200",
+ text: "text-blue-800",
+ },
+ warning: {
+ bg: "bg-amber-50",
+ ring: "ring-amber-200",
+ text: "text-amber-800",
+ },
+ success: {
+ bg: "bg-green-50",
+ ring: "ring-green-200",
+ text: "text-green-800",
+ },
+};
+
+export const banner = (
+ icon: string,
+ content: string,
+ variant: BannerVariant = "action",
+ className?: string,
+) => {
+ const styles = bannerStyles[variant];
+ return `
+
+ ${icon}
+ ${content}
+
+`;
+};
+
+// Requirement box components
+export const requirementBox = (
+ title: string,
+ items: string,
+ variant: "warning" | "success" = "warning",
+) => {
+ const isSuccess = variant === "success";
+ return `
+
+`;
+};
+
+export const requirementItem = (id: string, content: string) => `
+
+ ○ ${content}
+
+`;
+
+// Connection status box
+export const connectionStatusBox = (
+ id: string,
+ variant: "waiting" | "connected" = "waiting",
+) => {
+ const isConnected = variant === "connected";
+ return `
+
+ ${isConnected ? "✅ Connection already exists!" : "Waiting for connection..."}
+
+`;
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/tutorial.css b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/tutorial.css
new file mode 100644
index 0000000000..6564a61888
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/tutorial.css
@@ -0,0 +1,149 @@
+.new-builder-tutorial-highlight * {
+ opacity: 1 !important;
+ filter: none !important;
+}
+
+.new-builder-tutorial-pulse {
+ animation: new-builder-tutorial-pulse 2s ease-in-out infinite;
+}
+
+@keyframes new-builder-tutorial-pulse {
+ 0%,
+ 100% {
+ box-shadow:
+ 0 0 0 4px white,
+ 0 0 0 6px #7c3aed,
+ 0 0 30px 8px rgba(124, 58, 237, 0.4);
+ }
+ 50% {
+ box-shadow:
+ 0 0 0 4px white,
+ 0 0 0 8px #8b5cf6,
+ 0 0 40px 12px rgba(124, 58, 237, 0.55);
+ }
+}
+
+.shepherd-element.new-builder-tour {
+ max-width: 420px !important;
+ border-radius: 1rem !important;
+ box-shadow:
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
+ 0px 4px 6px -1px rgba(0, 0, 0, 0.1),
+ 0 20px 25px -5px rgba(0, 0, 0, 0.1) !important;
+ background: white !important;
+ font-family: var(--font-geist-sans), system-ui, sans-serif !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-header {
+ padding: 1rem 1.25rem 0.5rem !important;
+ border-radius: 1rem 1rem 0 0 !important;
+ background: transparent !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-title {
+ font-family: var(--font-poppins), system-ui, sans-serif !important;
+ font-size: 1rem !important;
+ font-weight: 600 !important;
+ line-height: 1.5rem !important;
+ color: #18181b !important; /* zinc-900 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-text {
+ padding: 0 1.25rem 1rem !important;
+ color: #52525b !important; /* zinc-600 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-footer {
+ padding: 0.75rem 1.25rem 1rem !important;
+ border-top: 1px solid #e4e4e7 !important; /* zinc-200 */
+ gap: 0.5rem !important;
+ display: flex !important;
+ justify-content: flex-end !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-button {
+ display: inline-flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ white-space: nowrap !important;
+ font-family: var(--font-geist-sans), system-ui, sans-serif !important;
+ font-weight: 500 !important;
+ font-size: 0.875rem !important;
+ line-height: 1.25rem !important;
+ transition: all 150ms ease !important;
+ border-radius: 9999px !important; /* rounded-full */
+ min-width: 5rem !important;
+ padding: 0.5rem 1rem !important;
+ height: 2.25rem !important;
+ gap: 0.375rem !important;
+ cursor: pointer !important;
+}
+
+.shepherd-element.new-builder-tour
+ .shepherd-button:not(.shepherd-button-secondary) {
+ background-color: #27272a !important; /* zinc-800 */
+ border: 1px solid #27272a !important;
+ color: white !important;
+}
+
+.shepherd-element.new-builder-tour
+ .shepherd-button:not(.shepherd-button-secondary):hover {
+ background-color: #18181b !important; /* zinc-900 */
+ border-color: #18181b !important;
+}
+
+.shepherd-element.new-builder-tour
+ .shepherd-button:not(.shepherd-button-secondary):active {
+ transform: scale(0.98);
+}
+
+.shepherd-element.new-builder-tour .shepherd-button-secondary {
+ background-color: #f4f4f5 !important; /* zinc-100 */
+ border: 1px solid #f4f4f5 !important;
+ color: #52525b !important; /* zinc-600 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-button-secondary:hover {
+ background-color: #e4e4e7 !important; /* zinc-200 */
+ border-color: #e4e4e7 !important;
+ color: #27272a !important; /* zinc-800 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-button-secondary:active {
+ transform: scale(0.98);
+}
+
+.shepherd-element.new-builder-tour .shepherd-cancel-icon {
+ color: #a1a1aa !important; /* zinc-400 */
+ transition: color 150ms ease !important;
+ width: 1.5rem !important;
+ height: 1.5rem !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-cancel-icon:hover {
+ color: #52525b !important; /* zinc-600 */
+}
+
+.shepherd-element.new-builder-tour .shepherd-arrow {
+ transform: scale(1.2) !important;
+}
+
+.shepherd-element.new-builder-tour .shepherd-arrow:before {
+ background: white !important;
+}
+
+.shepherd-element.new-builder-tour[data-popper-placement^="top"] {
+ margin-bottom: 40px !important;
+}
+
+.shepherd-element.new-builder-tour[data-popper-placement^="bottom"] {
+ margin-top: 40px !important;
+}
+
+.shepherd-element.new-builder-tour[data-popper-placement^="left"] {
+ margin-right: 30px !important;
+}
+
+.shepherd-element.new-builder-tour[data-popper-placement^="right"] {
+ margin-left: 30px !important;
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Block.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Block.tsx
index 435aa62c61..10f4fc8a44 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Block.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/Block.tsx
@@ -65,9 +65,15 @@ export const Block: BlockComponent = ({
setTimeout(() => document.body.removeChild(dragPreview), 0);
};
+ // Generate a data-id from the block id (e.g., "AgentInputBlock" -> "block-card-AgentInputBlock")
+ const blockDataId = blockData.id
+ ? `block-card-${blockData.id.replace(/[^a-zA-Z0-9]/g, "")}`
+ : undefined;
+
return (
{
- const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
+ const { blockMenuOpen, setBlockMenuOpen, forceOpenBlockMenu } =
+ useControlPanelStore();
return (
- // pinBlocksPopover ? true : open
-
+ {
+ if (!forceOpenBlockMenu || open) {
+ setBlockMenuOpen(open);
+ }
+ }}
+ open={forceOpenBlockMenu ? true : blockMenuOpen}
+ >
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch.tsx
index 26723eebcc..70055b77a9 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch.tsx
@@ -5,7 +5,10 @@ import { BlockMenuSearchContent } from "../BlockMenuSearchContent/BlockMenuSearc
export const BlockMenuSearch = () => {
return (
-
+
Search results
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/BlockMenuSearchBar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/BlockMenuSearchBar.tsx
index 165a3f05ea..57a7b711b0 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/BlockMenuSearchBar.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchBar/BlockMenuSearchBar.tsx
@@ -22,6 +22,7 @@ export const BlockMenuSearchBar: React.FC
= ({
return (
{
key={item.type}
name={item.name}
number={item.number}
+ menuItemType={item.type}
selected={defaultState === item.type}
onClick={() => setDefaultState(item.type as DefaultStateType)}
/>
@@ -111,6 +112,7 @@ export const BlockMenuSidebar = () => {
key={item.type}
name={item.name}
number={item.number}
+ menuItemType={item.type}
className="max-w-[11.5339rem]"
selected={defaultState === item.type}
onClick={() => setDefaultState(item.type as DefaultStateType)}
@@ -122,6 +124,7 @@ export const BlockMenuSidebar = () => {
key={item.type}
name={item.name}
number={item.number}
+ menuItemType={item.type}
selected={defaultState === item.type}
onClick={
item.onClick ||
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/MenuItem.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/MenuItem.tsx
index a1dbbb4c6a..b846c02cb7 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/MenuItem.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/MenuItem.tsx
@@ -8,6 +8,7 @@ interface Props extends ButtonHTMLAttributes
{
selected?: boolean;
number?: number;
name?: string;
+ menuItemType?: string;
}
export const MenuItem: React.FC = ({
@@ -15,10 +16,12 @@ export const MenuItem: React.FC = ({
number,
name,
className,
+ menuItemType,
...rest
}) => {
return (
{
const { form, isSaving, graphVersion, handleSave } = useNewSaveControl();
- const { saveControlOpen, setSaveControlOpen } = useControlPanelStore();
+ const { saveControlOpen, setSaveControlOpen, forceOpenSave } =
+ useControlPanelStore();
return (
-
+ {
+ if (!forceOpenSave || open) {
+ setSaveControlOpen(open);
+ }
+ }}
+ open={forceOpenSave ? true : saveControlOpen}
+ >
@@ -94,6 +102,7 @@ export const NewSaveControl = () => {
value={graphVersion || "-"}
disabled
data-testid="save-control-version-output"
+ data-tutorial-id="save-control-version-output"
label="Version"
wrapperClassName="!mb-0"
/>
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/UndoRedoButtons.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/UndoRedoButtons.tsx
index 5510335104..379f2743f8 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/UndoRedoButtons.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/UndoRedoButtons.tsx
@@ -42,7 +42,12 @@ export const UndoRedoButtons = () => {
<>
-
+
@@ -51,7 +56,12 @@ export const UndoRedoButtons = () => {
-
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts
index 7b3c5b1d01..00c151d35b 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/helper.ts
@@ -61,12 +61,18 @@ export const convertNodesPlusBlockInfoIntoCustomNodes = (
return customNode;
};
+const isToolSourceName = (sourceName: string): boolean =>
+ sourceName.startsWith("tools_^_");
+
+const cleanupSourceName = (sourceName: string): string =>
+ isToolSourceName(sourceName) ? "tools" : sourceName;
+
export const linkToCustomEdge = (link: Link): CustomEdge => ({
id: link.id ?? "",
type: "custom" as const,
source: link.source_id,
target: link.sink_id,
- sourceHandle: link.source_name,
+ sourceHandle: cleanupSourceName(link.source_name),
targetHandle: link.sink_name,
data: {
isStatic: link.is_static,
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BuildActionBar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BuildActionBar.tsx
index 36173a6ed8..9d12439d8d 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BuildActionBar.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BuildActionBar.tsx
@@ -83,6 +83,7 @@ export const BuildActionBar: React.FC = ({
title="Run the agent"
aria-label="Run the agent"
data-testid="primary-action-run-agent"
+ data-tutorial-id="primary-action-run-agent"
>
Run
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx
index 94e917a4ac..834603cc4a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode.tsx
@@ -857,7 +857,7 @@ export const CustomNode = React.memo(
})();
const hasAdvancedFields =
- data.inputSchema &&
+ data.inputSchema?.properties &&
Object.entries(data.inputSchema.properties).some(([key, value]) => {
return (
value.advanced === true && !data.inputSchema.required?.includes(key)
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx
index 98edbca2fb..1ccb3d1261 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/ExpandableOutputDialog.tsx
@@ -1,9 +1,9 @@
-import type { OutputMetadata } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
+import type { OutputMetadata } from "@/components/contextual/OutputRenderers";
import {
globalRegistry,
OutputActions,
OutputItem,
-} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
+} from "@/components/contextual/OutputRenderers";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { beautifyString } from "@/lib/utils";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx
index ab6ea8b94b..51fed5bef1 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeInputs.tsx
@@ -3,7 +3,6 @@ import {
CustomNodeData,
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
-import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Button } from "@/components/__legacy__/ui/button";
import { Calendar } from "@/components/__legacy__/ui/calendar";
import { LocalValuedInput } from "@/components/__legacy__/ui/input";
@@ -28,6 +27,7 @@ import {
SelectValue,
} from "@/components/__legacy__/ui/select";
import { Switch } from "@/components/atoms/Switch/Switch";
+import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
import {
BlockIOArraySubSchema,
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/tutorial.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/tutorial.ts
index 7adcfd9c1e..418d18782a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/tutorial.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/tutorial.ts
@@ -328,16 +328,16 @@ export const startTutorial = (
title: "Press Run",
text: "Start your first flow by pressing the Run button!",
attachTo: {
- element: '[data-testid="primary-action-run-agent"]',
+ element: '[data-tutorial-id="primary-action-run-agent"]',
on: "top",
},
advanceOn: {
- selector: '[data-testid="primary-action-run-agent"]',
+ selector: '[data-tutorial-id="primary-action-run-agent"]',
event: "click",
},
buttons: [],
beforeShowPromise: () =>
- waitForElement('[data-testid="primary-action-run-agent"]'),
+ waitForElement('[data-tutorial-id="primary-action-run-agent"]'),
when: {
hide: () => {
setTimeout(() => {
@@ -508,16 +508,16 @@ export const startTutorial = (
title: "Press Run Again",
text: "Now, press the Run button again to execute the flow with the new Calculator Block added!",
attachTo: {
- element: '[data-testid="primary-action-run-agent"]',
+ element: '[data-tutorial-id="primary-action-run-agent"]',
on: "top",
},
advanceOn: {
- selector: '[data-testid="primary-action-run-agent"]',
+ selector: '[data-tutorial-id="primary-action-run-agent"]',
event: "click",
},
buttons: [],
beforeShowPromise: () =>
- waitForElement('[data-testid="primary-action-run-agent"]'),
+ waitForElement('[data-tutorial-id="primary-action-run-agent"]'),
when: {
hide: () => {
setTimeout(() => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/controlPanelStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/controlPanelStore.ts
index 6847d769bd..5dcb11c121 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/controlPanelStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/controlPanelStore.ts
@@ -3,20 +3,32 @@ import { create } from "zustand";
type ControlPanelStore = {
blockMenuOpen: boolean;
saveControlOpen: boolean;
+ forceOpenBlockMenu: boolean;
+ forceOpenSave: boolean;
+
setBlockMenuOpen: (open: boolean) => void;
setSaveControlOpen: (open: boolean) => void;
+ setForceOpenBlockMenu: (force: boolean) => void;
+ setForceOpenSave: (force: boolean) => void;
+
reset: () => void;
};
export const useControlPanelStore = create((set) => ({
blockMenuOpen: false,
saveControlOpen: false,
+ forceOpenBlockMenu: false,
+ forceOpenSave: false,
+ setForceOpenBlockMenu: (force) => set({ forceOpenBlockMenu: force }),
+ setForceOpenSave: (force) => set({ forceOpenSave: force }),
setBlockMenuOpen: (open) => set({ blockMenuOpen: open }),
setSaveControlOpen: (open) => set({ saveControlOpen: open }),
reset: () =>
set({
blockMenuOpen: false,
saveControlOpen: false,
+ forceOpenBlockMenu: false,
+ forceOpenSave: false,
}),
}));
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts
index 7b17eecfb3..6a45b9e1e2 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts
@@ -5,6 +5,8 @@ import { customEdgeToLink, linkToCustomEdge } from "../components/helper";
import { MarkerType } from "@xyflow/react";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
+import { useHistoryStore } from "./historyStore";
+import { useNodeStore } from "./nodeStore";
type EdgeStore = {
edges: CustomEdge[];
@@ -53,25 +55,36 @@ export const useEdgeStore = create((set, get) => ({
id,
};
- set((state) => {
- const exists = state.edges.some(
- (e) =>
- e.source === newEdge.source &&
- e.target === newEdge.target &&
- e.sourceHandle === newEdge.sourceHandle &&
- e.targetHandle === newEdge.targetHandle,
- );
- if (exists) return state;
- return { edges: [...state.edges, newEdge] };
- });
+ const exists = get().edges.some(
+ (e) =>
+ e.source === newEdge.source &&
+ e.target === newEdge.target &&
+ e.sourceHandle === newEdge.sourceHandle &&
+ e.targetHandle === newEdge.targetHandle,
+ );
+ if (exists) return newEdge;
+ const prevState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: get().edges,
+ };
+
+ set((state) => ({ edges: [...state.edges, newEdge] }));
+ useHistoryStore.getState().pushState(prevState);
return newEdge;
},
- removeEdge: (edgeId) =>
+ removeEdge: (edgeId) => {
+ const prevState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: get().edges,
+ };
+
set((state) => ({
edges: state.edges.filter((e) => e.id !== edgeId),
- })),
+ }));
+ useHistoryStore.getState().pushState(prevState);
+ },
upsertMany: (edges) =>
set((state) => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts
new file mode 100644
index 0000000000..bcdfd4c313
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/helpers.ts
@@ -0,0 +1,16 @@
+export const accumulateExecutionData = (
+ accumulated: Record,
+ data: Record | undefined,
+) => {
+ if (!data) return { ...accumulated };
+ const next = { ...accumulated };
+ Object.entries(data).forEach(([key, values]) => {
+ const nextValues = Array.isArray(values) ? values : [values];
+ if (next[key]) {
+ next[key] = [...next[key], ...nextValues];
+ } else {
+ next[key] = [...nextValues];
+ }
+ });
+ return next;
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts
index 4eea5741a4..3a67bb8dcd 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/historyStore.ts
@@ -37,6 +37,15 @@ export const useHistoryStore = create((set, get) => ({
return;
}
+ const actualCurrentState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
+ if (isEqual(state, actualCurrentState)) {
+ return;
+ }
+
set((prev) => ({
past: [...prev.past.slice(-MAX_HISTORY + 1), state],
future: [],
@@ -55,18 +64,25 @@ export const useHistoryStore = create((set, get) => ({
undo: () => {
const { past, future } = get();
- if (past.length <= 1) return;
+ if (past.length === 0) return;
- const currentState = past[past.length - 1];
+ const actualCurrentState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
- const previousState = past[past.length - 2];
+ const previousState = past[past.length - 1];
+
+ if (isEqual(actualCurrentState, previousState)) {
+ return;
+ }
useNodeStore.getState().setNodes(previousState.nodes);
useEdgeStore.getState().setEdges(previousState.edges);
set({
- past: past.slice(0, -1),
- future: [currentState, ...future],
+ past: past.length > 1 ? past.slice(0, -1) : past,
+ future: [actualCurrentState, ...future],
});
},
@@ -74,18 +90,36 @@ export const useHistoryStore = create((set, get) => ({
const { past, future } = get();
if (future.length === 0) return;
+ const actualCurrentState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
const nextState = future[0];
useNodeStore.getState().setNodes(nextState.nodes);
useEdgeStore.getState().setEdges(nextState.edges);
+ const lastPast = past[past.length - 1];
+ const shouldPushToPast =
+ !lastPast || !isEqual(actualCurrentState, lastPast);
+
set({
- past: [...past, nextState],
+ past: shouldPushToPast ? [...past, actualCurrentState] : past,
future: future.slice(1),
});
},
- canUndo: () => get().past.length > 1,
+ canUndo: () => {
+ const { past } = get();
+ if (past.length === 0) return false;
+
+ const actualCurrentState = {
+ nodes: useNodeStore.getState().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+ return !isEqual(actualCurrentState, past[past.length - 1]);
+ },
canRedo: () => get().future.length > 0,
clear: () => set({ past: [{ nodes: [], edges: [] }], future: [] }),
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts
index 7f9deaa993..f7a52636f3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { NodeChange, XYPosition, applyNodeChanges } from "@xyflow/react";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
+import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import {
convertBlockInfoIntoCustomNodeData,
@@ -9,6 +10,8 @@ import {
import { Node } from "@/app/api/__generated__/models/node";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
+import { NodeExecutionResultInputData } from "@/app/api/__generated__/models/nodeExecutionResultInputData";
+import { NodeExecutionResultOutputData } from "@/app/api/__generated__/models/nodeExecutionResultOutputData";
import { useHistoryStore } from "./historyStore";
import { useEdgeStore } from "./edgeStore";
import { BlockUIType } from "../components/types";
@@ -17,37 +20,28 @@ import {
ensurePathExists,
parseHandleIdToPath,
} from "@/components/renderers/InputRenderer/helpers";
-import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/types";
+import { accumulateExecutionData } from "./helpers";
+import { NodeResolutionData } from "./types";
-// Resolution mode data stored per node
-export type NodeResolutionData = {
- incompatibilities: IncompatibilityInfo;
- // The NEW schema from the update (what we're updating TO)
- pendingUpdate: {
- input_schema: Record;
- output_schema: Record;
- };
- // The OLD schema before the update (what we're updating FROM)
- // Needed to merge and show removed inputs during resolution
- currentSchema: {
- input_schema: Record;
- output_schema: Record;
- };
- // The full updated hardcoded values to apply when resolution completes
- pendingHardcodedValues: Record;
-};
-
-// Minimum movement (in pixels) required before logging position change to history
-// Prevents spamming history with small movements when clicking on inputs inside blocks
const MINIMUM_MOVE_BEFORE_LOG = 50;
-
-// Track initial positions when drag starts (outside store to avoid re-renders)
const dragStartPositions: Record = {};
+let dragStartState: { nodes: CustomNode[]; edges: CustomEdge[] } | null = null;
+
type NodeStore = {
nodes: CustomNode[];
nodeCounter: number;
+ setNodeCounter: (nodeCounter: number) => void;
nodeAdvancedStates: Record;
+
+ latestNodeInputData: Record;
+ latestNodeOutputData: Record<
+ string,
+ NodeExecutionResultOutputData | undefined
+ >;
+ accumulatedNodeInputData: Record>;
+ accumulatedNodeOutputData: Record>;
+
setNodes: (nodes: CustomNode[]) => void;
onNodesChange: (changes: NodeChange[]) => void;
addNode: (node: CustomNode) => void;
@@ -68,12 +62,26 @@ type NodeStore = {
updateNodeStatus: (nodeId: string, status: AgentExecutionStatus) => void;
getNodeStatus: (nodeId: string) => AgentExecutionStatus | undefined;
+ cleanNodesStatuses: () => void;
updateNodeExecutionResult: (
nodeId: string,
result: NodeExecutionResult,
) => void;
- getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined;
+ getNodeExecutionResults: (nodeId: string) => NodeExecutionResult[];
+ getLatestNodeInputData: (
+ nodeId: string,
+ ) => NodeExecutionResultInputData | undefined;
+ getLatestNodeOutputData: (
+ nodeId: string,
+ ) => NodeExecutionResultOutputData | undefined;
+ getAccumulatedNodeInputData: (nodeId: string) => Record;
+ getAccumulatedNodeOutputData: (nodeId: string) => Record;
+ getLatestNodeExecutionResult: (
+ nodeId: string,
+ ) => NodeExecutionResult | undefined;
+ clearAllNodeExecutionResults: () => void;
+
getNodeBlockUIType: (nodeId: string) => BlockUIType;
hasWebhookNodes: () => boolean;
@@ -116,20 +124,31 @@ export const useNodeStore = create((set, get) => ({
nodes: [],
setNodes: (nodes) => set({ nodes }),
nodeCounter: 0,
+ setNodeCounter: (nodeCounter) => set({ nodeCounter }),
nodeAdvancedStates: {},
+ latestNodeInputData: {},
+ latestNodeOutputData: {},
+ accumulatedNodeInputData: {},
+ accumulatedNodeOutputData: {},
incrementNodeCounter: () =>
set((state) => ({
nodeCounter: state.nodeCounter + 1,
})),
onNodesChange: (changes) => {
- const prevState = {
- nodes: get().nodes,
- edges: useEdgeStore.getState().edges,
- };
-
- // Track initial positions when drag starts
changes.forEach((change) => {
if (change.type === "position" && change.dragging === true) {
+ if (!dragStartState) {
+ const currentNodes = get().nodes;
+ const currentEdges = useEdgeStore.getState().edges;
+ dragStartState = {
+ nodes: currentNodes.map((n) => ({
+ ...n,
+ position: { ...n.position },
+ data: { ...n.data },
+ })),
+ edges: currentEdges.map((e) => ({ ...e })),
+ };
+ }
if (!dragStartPositions[change.id]) {
const node = get().nodes.find((n) => n.id === change.id);
if (node) {
@@ -139,12 +158,17 @@ export const useNodeStore = create((set, get) => ({
}
});
- // Check if we should track this change in history
- let shouldTrack = changes.some(
- (change) => change.type === "remove" || change.type === "add",
- );
+ let shouldTrack = changes.some((change) => change.type === "remove");
+ let stateToTrack: { nodes: CustomNode[]; edges: CustomEdge[] } | null =
+ null;
+
+ if (shouldTrack) {
+ stateToTrack = {
+ nodes: get().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+ }
- // For position changes, only track if movement exceeds threshold
if (!shouldTrack) {
changes.forEach((change) => {
if (change.type === "position" && change.dragging === false) {
@@ -156,20 +180,23 @@ export const useNodeStore = create((set, get) => ({
);
if (distanceMoved > MINIMUM_MOVE_BEFORE_LOG) {
shouldTrack = true;
+ stateToTrack = dragStartState;
}
}
- // Clean up tracked position after drag ends
delete dragStartPositions[change.id];
}
});
+ if (Object.keys(dragStartPositions).length === 0) {
+ dragStartState = null;
+ }
}
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
}));
- if (shouldTrack) {
- useHistoryStore.getState().pushState(prevState);
+ if (shouldTrack && stateToTrack) {
+ useHistoryStore.getState().pushState(stateToTrack);
}
},
@@ -183,6 +210,11 @@ export const useNodeStore = create((set, get) => ({
hardcodedValues?: Record,
position?: XYPosition,
) => {
+ const prevState = {
+ nodes: get().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
const customNodeData = convertBlockInfoIntoCustomNodeData(
block,
hardcodedValues,
@@ -216,21 +248,24 @@ export const useNodeStore = create((set, get) => ({
set((state) => ({
nodes: [...state.nodes, customNode],
}));
+
+ useHistoryStore.getState().pushState(prevState);
+
return customNode;
},
updateNodeData: (nodeId, data) => {
+ const prevState = {
+ nodes: get().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n,
),
}));
- const newState = {
- nodes: get().nodes,
- edges: useEdgeStore.getState().edges,
- };
-
- useHistoryStore.getState().pushState(newState);
+ useHistoryStore.getState().pushState(prevState);
},
toggleAdvanced: (nodeId: string) =>
set((state) => ({
@@ -290,17 +325,162 @@ export const useNodeStore = create((set, get) => ({
return get().nodes.find((n) => n.id === nodeId)?.data?.status;
},
- updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => {
+ cleanNodesStatuses: () => {
set((state) => ({
- nodes: state.nodes.map((n) =>
- n.id === nodeId
- ? { ...n, data: { ...n.data, nodeExecutionResult: result } }
- : n,
- ),
+ nodes: state.nodes.map((n) => ({
+ ...n,
+ data: { ...n.data, status: undefined },
+ })),
}));
},
- getNodeExecutionResult: (nodeId: string) => {
- return get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResult;
+
+ updateNodeExecutionResult: (nodeId: string, result: NodeExecutionResult) => {
+ set((state) => {
+ let latestNodeInputData = state.latestNodeInputData;
+ let latestNodeOutputData = state.latestNodeOutputData;
+ let accumulatedNodeInputData = state.accumulatedNodeInputData;
+ let accumulatedNodeOutputData = state.accumulatedNodeOutputData;
+
+ const nodes = state.nodes.map((n) => {
+ if (n.id !== nodeId) return n;
+
+ const existingResults = n.data.nodeExecutionResults || [];
+ const duplicateIndex = existingResults.findIndex(
+ (r) => r.node_exec_id === result.node_exec_id,
+ );
+
+ if (duplicateIndex !== -1) {
+ const oldResult = existingResults[duplicateIndex];
+ const inputDataChanged =
+ JSON.stringify(oldResult.input_data) !==
+ JSON.stringify(result.input_data);
+ const outputDataChanged =
+ JSON.stringify(oldResult.output_data) !==
+ JSON.stringify(result.output_data);
+
+ if (!inputDataChanged && !outputDataChanged) {
+ return n;
+ }
+
+ const updatedResults = [...existingResults];
+ updatedResults[duplicateIndex] = result;
+
+ const recomputedAccumulatedInput = updatedResults.reduce(
+ (acc, r) => accumulateExecutionData(acc, r.input_data),
+ {} as Record,
+ );
+ const recomputedAccumulatedOutput = updatedResults.reduce(
+ (acc, r) => accumulateExecutionData(acc, r.output_data),
+ {} as Record,
+ );
+
+ const mostRecentResult = updatedResults[updatedResults.length - 1];
+ latestNodeInputData = {
+ ...latestNodeInputData,
+ [nodeId]: mostRecentResult.input_data,
+ };
+ latestNodeOutputData = {
+ ...latestNodeOutputData,
+ [nodeId]: mostRecentResult.output_data,
+ };
+
+ accumulatedNodeInputData = {
+ ...accumulatedNodeInputData,
+ [nodeId]: recomputedAccumulatedInput,
+ };
+ accumulatedNodeOutputData = {
+ ...accumulatedNodeOutputData,
+ [nodeId]: recomputedAccumulatedOutput,
+ };
+
+ return {
+ ...n,
+ data: {
+ ...n.data,
+ nodeExecutionResults: updatedResults,
+ },
+ };
+ }
+
+ accumulatedNodeInputData = {
+ ...accumulatedNodeInputData,
+ [nodeId]: accumulateExecutionData(
+ accumulatedNodeInputData[nodeId] || {},
+ result.input_data,
+ ),
+ };
+ accumulatedNodeOutputData = {
+ ...accumulatedNodeOutputData,
+ [nodeId]: accumulateExecutionData(
+ accumulatedNodeOutputData[nodeId] || {},
+ result.output_data,
+ ),
+ };
+
+ latestNodeInputData = {
+ ...latestNodeInputData,
+ [nodeId]: result.input_data,
+ };
+ latestNodeOutputData = {
+ ...latestNodeOutputData,
+ [nodeId]: result.output_data,
+ };
+
+ return {
+ ...n,
+ data: {
+ ...n.data,
+ nodeExecutionResults: [...existingResults, result],
+ },
+ };
+ });
+
+ return {
+ nodes,
+ latestNodeInputData,
+ latestNodeOutputData,
+ accumulatedNodeInputData,
+ accumulatedNodeOutputData,
+ };
+ });
+ },
+ getNodeExecutionResults: (nodeId: string) => {
+ return (
+ get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResults || []
+ );
+ },
+ getLatestNodeInputData: (nodeId: string) => {
+ return get().latestNodeInputData[nodeId];
+ },
+ getLatestNodeOutputData: (nodeId: string) => {
+ return get().latestNodeOutputData[nodeId];
+ },
+ getAccumulatedNodeInputData: (nodeId: string) => {
+ return get().accumulatedNodeInputData[nodeId] || {};
+ },
+ getAccumulatedNodeOutputData: (nodeId: string) => {
+ return get().accumulatedNodeOutputData[nodeId] || {};
+ },
+ getLatestNodeExecutionResult: (nodeId: string) => {
+ const results =
+ get().nodes.find((n) => n.id === nodeId)?.data?.nodeExecutionResults ||
+ [];
+ return results.length > 0 ? results[results.length - 1] : undefined;
+ },
+ clearAllNodeExecutionResults: () => {
+ set((state) => ({
+ nodes: state.nodes.map((n) => ({
+ ...n,
+ data: {
+ ...n.data,
+ nodeExecutionResults: [],
+ },
+ })),
+ latestNodeInputData: {},
+ latestNodeOutputData: {},
+ accumulatedNodeInputData: {},
+ accumulatedNodeOutputData: {},
+ }));
},
getNodeBlockUIType: (nodeId: string) => {
return (
@@ -389,6 +569,11 @@ export const useNodeStore = create((set, get) => ({
},
setCredentialsOptional: (nodeId: string, optional: boolean) => {
+ const prevState = {
+ nodes: get().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId
@@ -406,12 +591,7 @@ export const useNodeStore = create((set, get) => ({
),
}));
- const newState = {
- nodes: get().nodes,
- edges: useEdgeStore.getState().edges,
- };
-
- useHistoryStore.getState().pushState(newState);
+ useHistoryStore.getState().pushState(prevState);
},
// Sub-agent resolution mode state
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/tutorialStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/tutorialStore.ts
new file mode 100644
index 0000000000..581dda44c9
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/tutorialStore.ts
@@ -0,0 +1,32 @@
+import { create } from "zustand";
+
+type TutorialStore = {
+ isTutorialRunning: boolean;
+ setIsTutorialRunning: (isTutorialRunning: boolean) => void;
+
+ currentStep: number;
+ setCurrentStep: (currentStep: number) => void;
+
+ // Force open the run input dialog from the tutorial
+ forceOpenRunInputDialog: boolean;
+ setForceOpenRunInputDialog: (forceOpen: boolean) => void;
+
+ // Track input values filled in the dialog
+ tutorialInputValues: Record;
+ setTutorialInputValues: (values: Record) => void;
+};
+
+export const useTutorialStore = create((set) => ({
+ isTutorialRunning: false,
+ setIsTutorialRunning: (isTutorialRunning) => set({ isTutorialRunning }),
+
+ currentStep: 0,
+ setCurrentStep: (currentStep) => set({ currentStep }),
+
+ forceOpenRunInputDialog: false,
+ setForceOpenRunInputDialog: (forceOpen) =>
+ set({ forceOpenRunInputDialog: forceOpen }),
+
+ tutorialInputValues: {},
+ setTutorialInputValues: (values) => set({ tutorialInputValues: values }),
+}));
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts
new file mode 100644
index 0000000000..f0ec7e6c1c
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/types.ts
@@ -0,0 +1,14 @@
+import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/types";
+
+export type NodeResolutionData = {
+ incompatibilities: IncompatibilityInfo;
+ pendingUpdate: {
+ input_schema: Record;
+ output_schema: Record;
+ };
+ currentSchema: {
+ input_schema: Record;
+ output_schema: Record;
+ };
+ pendingHardcodedValues: Record;
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/ChatContainer.tsx
deleted file mode 100644
index 32f9d6c6eb..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/ChatContainer.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { cn } from "@/lib/utils";
-import { ChatInput } from "@/app/(platform)/chat/components/ChatInput/ChatInput";
-import { MessageList } from "@/app/(platform)/chat/components/MessageList/MessageList";
-import { QuickActionsWelcome } from "@/app/(platform)/chat/components/QuickActionsWelcome/QuickActionsWelcome";
-import { useChatContainer } from "./useChatContainer";
-import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
-
-export interface ChatContainerProps {
- sessionId: string | null;
- initialMessages: SessionDetailResponse["messages"];
- onRefreshSession: () => Promise;
- className?: string;
-}
-
-export function ChatContainer({
- sessionId,
- initialMessages,
- onRefreshSession,
- className,
-}: ChatContainerProps) {
- const { messages, streamingChunks, isStreaming, sendMessage } =
- useChatContainer({
- sessionId,
- initialMessages,
- onRefreshSession,
- });
-
- const quickActions = [
- "Find agents for social media management",
- "Show me agents for content creation",
- "Help me automate my business",
- "What can you help me with?",
- ];
-
- return (
-
- {/* Messages or Welcome Screen */}
- {messages.length === 0 ? (
-
- ) : (
-
- )}
-
- {/* Input - Always visible */}
-
-
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts
deleted file mode 100644
index 3a94dab1ea..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts
+++ /dev/null
@@ -1,279 +0,0 @@
-import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
-import type { ToolResult } from "@/types/chat";
-
-export function createUserMessage(content: string): ChatMessageData {
- return {
- type: "message",
- role: "user",
- content,
- timestamp: new Date(),
- };
-}
-
-export function filterAuthMessages(
- messages: ChatMessageData[],
-): ChatMessageData[] {
- return messages.filter(
- (msg) => msg.type !== "credentials_needed" && msg.type !== "login_needed",
- );
-}
-
-export function isValidMessage(msg: unknown): msg is Record {
- if (typeof msg !== "object" || msg === null) {
- return false;
- }
- const m = msg as Record;
- if (typeof m.role !== "string") {
- return false;
- }
- if (m.content !== undefined && typeof m.content !== "string") {
- return false;
- }
- return true;
-}
-
-export function isToolCallArray(value: unknown): value is Array<{
- id: string;
- type: string;
- function: { name: string; arguments: string };
-}> {
- if (!Array.isArray(value)) {
- return false;
- }
- return value.every(
- (item) =>
- typeof item === "object" &&
- item !== null &&
- "id" in item &&
- typeof item.id === "string" &&
- "type" in item &&
- typeof item.type === "string" &&
- "function" in item &&
- typeof item.function === "object" &&
- item.function !== null &&
- "name" in item.function &&
- typeof item.function.name === "string" &&
- "arguments" in item.function &&
- typeof item.function.arguments === "string",
- );
-}
-
-export function isAgentArray(value: unknown): value is Array<{
- id: string;
- name: string;
- description: string;
- version?: number;
-}> {
- if (!Array.isArray(value)) {
- return false;
- }
- return value.every(
- (item) =>
- typeof item === "object" &&
- item !== null &&
- "id" in item &&
- typeof item.id === "string" &&
- "name" in item &&
- typeof item.name === "string" &&
- "description" in item &&
- typeof item.description === "string" &&
- (!("version" in item) || typeof item.version === "number"),
- );
-}
-
-export function extractJsonFromErrorMessage(
- message: string,
-): Record | null {
- try {
- const start = message.indexOf("{");
- if (start === -1) {
- return null;
- }
- let depth = 0;
- let end = -1;
- for (let i = start; i < message.length; i++) {
- const ch = message[i];
- if (ch === "{") {
- depth++;
- } else if (ch === "}") {
- depth--;
- if (depth === 0) {
- end = i;
- break;
- }
- }
- }
- if (end === -1) {
- return null;
- }
- const jsonStr = message.slice(start, end + 1);
- return JSON.parse(jsonStr) as Record;
- } catch {
- return null;
- }
-}
-
-export function parseToolResponse(
- result: ToolResult,
- toolId: string,
- toolName: string,
- timestamp?: Date,
-): ChatMessageData | null {
- let parsedResult: Record | null = null;
- try {
- parsedResult =
- typeof result === "string"
- ? JSON.parse(result)
- : (result as Record);
- } catch {
- parsedResult = null;
- }
- if (parsedResult && typeof parsedResult === "object") {
- const responseType = parsedResult.type as string | undefined;
- if (responseType === "no_results") {
- return {
- type: "tool_response",
- toolId,
- toolName,
- result: (parsedResult.message as string) || "No results found",
- success: true,
- timestamp: timestamp || new Date(),
- };
- }
- if (responseType === "agent_carousel") {
- const agentsData = parsedResult.agents;
- if (isAgentArray(agentsData)) {
- return {
- type: "agent_carousel",
- toolName: "agent_carousel",
- agents: agentsData,
- totalCount: parsedResult.total_count as number | undefined,
- timestamp: timestamp || new Date(),
- };
- } else {
- console.warn("Invalid agents array in agent_carousel response");
- }
- }
- if (responseType === "execution_started") {
- return {
- type: "execution_started",
- toolName: "execution_started",
- executionId: (parsedResult.execution_id as string) || "",
- agentName: (parsedResult.graph_name as string) || undefined,
- message: parsedResult.message as string | undefined,
- libraryAgentLink: parsedResult.library_agent_link as string | undefined,
- timestamp: timestamp || new Date(),
- };
- }
- if (responseType === "need_login") {
- return {
- type: "login_needed",
- toolName: "login_needed",
- message:
- (parsedResult.message as string) ||
- "Please sign in to use chat and agent features",
- sessionId: (parsedResult.session_id as string) || "",
- agentInfo: parsedResult.agent_info as
- | {
- graph_id: string;
- name: string;
- trigger_type: string;
- }
- | undefined,
- timestamp: timestamp || new Date(),
- };
- }
- if (responseType === "setup_requirements") {
- return null;
- }
- }
- return {
- type: "tool_response",
- toolId,
- toolName,
- result,
- success: true,
- timestamp: timestamp || new Date(),
- };
-}
-
-export function isUserReadiness(
- value: unknown,
-): value is { missing_credentials?: Record } {
- return (
- typeof value === "object" &&
- value !== null &&
- (!("missing_credentials" in value) ||
- typeof (value as any).missing_credentials === "object")
- );
-}
-
-export function isMissingCredentials(
- value: unknown,
-): value is Record> {
- if (typeof value !== "object" || value === null) {
- return false;
- }
- return Object.values(value).every((v) => typeof v === "object" && v !== null);
-}
-
-export function isSetupInfo(value: unknown): value is {
- user_readiness?: Record;
- agent_name?: string;
-} {
- return (
- typeof value === "object" &&
- value !== null &&
- (!("user_readiness" in value) ||
- typeof (value as any).user_readiness === "object") &&
- (!("agent_name" in value) || typeof (value as any).agent_name === "string")
- );
-}
-
-export function extractCredentialsNeeded(
- parsedResult: Record,
-): ChatMessageData | null {
- try {
- const setupInfo = parsedResult?.setup_info as
- | Record
- | undefined;
- const userReadiness = setupInfo?.user_readiness as
- | Record
- | undefined;
- const missingCreds = userReadiness?.missing_credentials as
- | Record>
- | undefined;
- if (missingCreds && Object.keys(missingCreds).length > 0) {
- const agentName = (setupInfo?.agent_name as string) || "this agent";
- const credentials = Object.values(missingCreds).map((credInfo) => ({
- provider: (credInfo.provider as string) || "unknown",
- providerName:
- (credInfo.provider_name as string) ||
- (credInfo.provider as string) ||
- "Unknown Provider",
- credentialType:
- (credInfo.type as
- | "api_key"
- | "oauth2"
- | "user_password"
- | "host_scoped") || "api_key",
- title:
- (credInfo.title as string) ||
- `${(credInfo.provider_name as string) || (credInfo.provider as string)} credentials`,
- scopes: credInfo.scopes as string[] | undefined,
- }));
- return {
- type: "credentials_needed",
- toolName: "run_agent",
- credentials,
- message: `To run ${agentName}, you need to add ${credentials.length === 1 ? "credentials" : `${credentials.length} credentials`}.`,
- agentName,
- timestamp: new Date(),
- };
- }
- return null;
- } catch (err) {
- console.error("Failed to extract credentials from setup info:", err);
- return null;
- }
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts
deleted file mode 100644
index fdbecb5d61..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-import type { Dispatch, SetStateAction, MutableRefObject } from "react";
-import type { StreamChunk } from "@/app/(platform)/chat/useChatStream";
-import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
-import { parseToolResponse, extractCredentialsNeeded } from "./helpers";
-
-export interface HandlerDependencies {
- setHasTextChunks: Dispatch>;
- setStreamingChunks: Dispatch>;
- streamingChunksRef: MutableRefObject;
- setMessages: Dispatch>;
- sessionId: string;
-}
-
-export function handleTextChunk(chunk: StreamChunk, deps: HandlerDependencies) {
- if (!chunk.content) return;
- deps.setHasTextChunks(true);
- deps.setStreamingChunks((prev) => {
- const updated = [...prev, chunk.content!];
- deps.streamingChunksRef.current = updated;
- return updated;
- });
-}
-
-export function handleTextEnded(
- _chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- console.log("[Text Ended] Saving streamed text as assistant message");
- const completedText = deps.streamingChunksRef.current.join("");
- if (completedText.trim()) {
- const assistantMessage: ChatMessageData = {
- type: "message",
- role: "assistant",
- content: completedText,
- timestamp: new Date(),
- };
- deps.setMessages((prev) => [...prev, assistantMessage]);
- }
- deps.setStreamingChunks([]);
- deps.streamingChunksRef.current = [];
- deps.setHasTextChunks(false);
-}
-
-export function handleToolCallStart(
- chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- const toolCallMessage: ChatMessageData = {
- type: "tool_call",
- toolId: chunk.tool_id || `tool-${Date.now()}-${chunk.idx || 0}`,
- toolName: chunk.tool_name || "Executing...",
- arguments: chunk.arguments || {},
- timestamp: new Date(),
- };
- deps.setMessages((prev) => [...prev, toolCallMessage]);
- console.log("[Tool Call Start]", {
- toolId: toolCallMessage.toolId,
- toolName: toolCallMessage.toolName,
- timestamp: new Date().toISOString(),
- });
-}
-
-export function handleToolResponse(
- chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- console.log("[Tool Response] Received:", {
- toolId: chunk.tool_id,
- toolName: chunk.tool_name,
- timestamp: new Date().toISOString(),
- });
- let toolName = chunk.tool_name || "unknown";
- if (!chunk.tool_name || chunk.tool_name === "unknown") {
- deps.setMessages((prev) => {
- const matchingToolCall = [...prev]
- .reverse()
- .find(
- (msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
- );
- if (matchingToolCall && matchingToolCall.type === "tool_call") {
- toolName = matchingToolCall.toolName;
- }
- return prev;
- });
- }
- const responseMessage = parseToolResponse(
- chunk.result!,
- chunk.tool_id!,
- toolName,
- new Date(),
- );
- if (!responseMessage) {
- let parsedResult: Record | null = null;
- try {
- parsedResult =
- typeof chunk.result === "string"
- ? JSON.parse(chunk.result)
- : (chunk.result as Record);
- } catch {
- parsedResult = null;
- }
- if (
- chunk.tool_name === "run_agent" &&
- chunk.success &&
- parsedResult?.type === "setup_requirements"
- ) {
- const credentialsMessage = extractCredentialsNeeded(parsedResult);
- if (credentialsMessage) {
- deps.setMessages((prev) => [...prev, credentialsMessage]);
- }
- }
- return;
- }
- deps.setMessages((prev) => {
- const toolCallIndex = prev.findIndex(
- (msg) => msg.type === "tool_call" && msg.toolId === chunk.tool_id,
- );
- if (toolCallIndex !== -1) {
- const newMessages = [...prev];
- newMessages[toolCallIndex] = responseMessage;
- console.log(
- "[Tool Response] Replaced tool_call with matching tool_id:",
- chunk.tool_id,
- "at index:",
- toolCallIndex,
- );
- return newMessages;
- }
- console.warn(
- "[Tool Response] No tool_call found with tool_id:",
- chunk.tool_id,
- "appending instead",
- );
- return [...prev, responseMessage];
- });
-}
-
-export function handleLoginNeeded(
- chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- const loginNeededMessage: ChatMessageData = {
- type: "login_needed",
- toolName: "login_needed",
- message: chunk.message || "Please sign in to use chat and agent features",
- sessionId: chunk.session_id || deps.sessionId,
- agentInfo: chunk.agent_info,
- timestamp: new Date(),
- };
- deps.setMessages((prev) => [...prev, loginNeededMessage]);
-}
-
-export function handleStreamEnd(
- _chunk: StreamChunk,
- deps: HandlerDependencies,
-) {
- const completedContent = deps.streamingChunksRef.current.join("");
- // Only save message if there are uncommitted chunks
- // (text_ended already saved if there were tool calls)
- if (completedContent.trim()) {
- console.log(
- "[Stream End] Saving remaining streamed text as assistant message",
- );
- const assistantMessage: ChatMessageData = {
- type: "message",
- role: "assistant",
- content: completedContent,
- timestamp: new Date(),
- };
- deps.setMessages((prev) => {
- const updated = [...prev, assistantMessage];
- console.log("[Stream End] Final state:", {
- localMessages: updated.map((m) => ({
- type: m.type,
- ...(m.type === "message" && {
- role: m.role,
- contentLength: m.content.length,
- }),
- ...(m.type === "tool_call" && {
- toolId: m.toolId,
- toolName: m.toolName,
- }),
- ...(m.type === "tool_response" && {
- toolId: m.toolId,
- toolName: m.toolName,
- success: m.success,
- }),
- })),
- streamingChunks: deps.streamingChunksRef.current,
- timestamp: new Date().toISOString(),
- });
- return updated;
- });
- } else {
- console.log("[Stream End] No uncommitted chunks, message already saved");
- }
- deps.setStreamingChunks([]);
- deps.streamingChunksRef.current = [];
- deps.setHasTextChunks(false);
- console.log("[Stream End] Stream complete, messages in local state");
-}
-
-export function handleError(chunk: StreamChunk, _deps: HandlerDependencies) {
- const errorMessage = chunk.message || chunk.content || "An error occurred";
- console.error("Stream error:", errorMessage);
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.ts
deleted file mode 100644
index c75ad587a5..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-import { useState, useCallback, useRef, useMemo } from "react";
-import { toast } from "sonner";
-import { useChatStream } from "@/app/(platform)/chat/useChatStream";
-import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
-import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
-import {
- parseToolResponse,
- isValidMessage,
- isToolCallArray,
- createUserMessage,
- filterAuthMessages,
-} from "./helpers";
-import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
-
-interface UseChatContainerArgs {
- sessionId: string | null;
- initialMessages: SessionDetailResponse["messages"];
- onRefreshSession: () => Promise;
-}
-
-export function useChatContainer({
- sessionId,
- initialMessages,
-}: UseChatContainerArgs) {
- const [messages, setMessages] = useState([]);
- const [streamingChunks, setStreamingChunks] = useState([]);
- const [hasTextChunks, setHasTextChunks] = useState(false);
- const streamingChunksRef = useRef([]);
- const { error, sendMessage: sendStreamMessage } = useChatStream();
- const isStreaming = hasTextChunks;
-
- const allMessages = useMemo(() => {
- const processedInitialMessages = initialMessages
- .filter((msg: Record) => {
- if (!isValidMessage(msg)) {
- console.warn("Invalid message structure from backend:", msg);
- return false;
- }
- const content = String(msg.content || "").trim();
- const toolCalls = msg.tool_calls;
- return (
- content.length > 0 ||
- (toolCalls && Array.isArray(toolCalls) && toolCalls.length > 0)
- );
- })
- .map((msg: Record) => {
- const content = String(msg.content || "");
- const role = String(msg.role || "assistant").toLowerCase();
- const toolCalls = msg.tool_calls;
- if (
- role === "assistant" &&
- toolCalls &&
- isToolCallArray(toolCalls) &&
- toolCalls.length > 0
- ) {
- return null;
- }
- if (role === "tool") {
- const timestamp = msg.timestamp
- ? new Date(msg.timestamp as string)
- : undefined;
- const toolResponse = parseToolResponse(
- content,
- (msg.tool_call_id as string) || "",
- "unknown",
- timestamp,
- );
- if (!toolResponse) {
- return null;
- }
- return toolResponse;
- }
- return {
- type: "message",
- role: role as "user" | "assistant" | "system",
- content,
- timestamp: msg.timestamp
- ? new Date(msg.timestamp as string)
- : undefined,
- };
- })
- .filter((msg): msg is ChatMessageData => msg !== null);
-
- return [...processedInitialMessages, ...messages];
- }, [initialMessages, messages]);
-
- const sendMessage = useCallback(
- async function sendMessage(content: string, isUserMessage: boolean = true) {
- if (!sessionId) {
- console.error("Cannot send message: no session ID");
- return;
- }
- if (isUserMessage) {
- const userMessage = createUserMessage(content);
- setMessages((prev) => [...filterAuthMessages(prev), userMessage]);
- } else {
- setMessages((prev) => filterAuthMessages(prev));
- }
- setStreamingChunks([]);
- streamingChunksRef.current = [];
- setHasTextChunks(false);
- const dispatcher = createStreamEventDispatcher({
- setHasTextChunks,
- setStreamingChunks,
- streamingChunksRef,
- setMessages,
- sessionId,
- });
- try {
- await sendStreamMessage(sessionId, content, dispatcher, isUserMessage);
- } catch (err) {
- console.error("Failed to send message:", err);
- const errorMessage =
- err instanceof Error ? err.message : "Failed to send message";
- toast.error("Failed to send message", {
- description: errorMessage,
- });
- }
- },
- [sessionId, sendStreamMessage],
- );
-
- return {
- messages: allMessages,
- streamingChunks,
- isStreaming,
- error,
- sendMessage,
- };
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx
deleted file mode 100644
index 1f70f7740f..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
-import { Card } from "@/components/atoms/Card/Card";
-import { Text } from "@/components/atoms/Text/Text";
-import type { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
-import { cn } from "@/lib/utils";
-import { CheckIcon, KeyIcon, WarningIcon } from "@phosphor-icons/react";
-import { useEffect, useRef } from "react";
-import { useChatCredentialsSetup } from "./useChatCredentialsSetup";
-
-export interface CredentialInfo {
- provider: string;
- providerName: string;
- credentialType: "api_key" | "oauth2" | "user_password" | "host_scoped";
- title: string;
- scopes?: string[];
-}
-
-interface Props {
- credentials: CredentialInfo[];
- agentName?: string;
- message: string;
- onAllCredentialsComplete: () => void;
- onCancel: () => void;
- className?: string;
-}
-
-function createSchemaFromCredentialInfo(
- credential: CredentialInfo,
-): BlockIOCredentialsSubSchema {
- return {
- type: "object",
- properties: {},
- credentials_provider: [credential.provider],
- credentials_types: [credential.credentialType],
- credentials_scopes: credential.scopes,
- discriminator: undefined,
- discriminator_mapping: undefined,
- discriminator_values: undefined,
- };
-}
-
-export function ChatCredentialsSetup({
- credentials,
- agentName: _agentName,
- message,
- onAllCredentialsComplete,
- onCancel: _onCancel,
- className,
-}: Props) {
- const { selectedCredentials, isAllComplete, handleCredentialSelect } =
- useChatCredentialsSetup(credentials);
-
- // Track if we've already called completion to prevent double calls
- const hasCalledCompleteRef = useRef(false);
-
- // Reset the completion flag when credentials change (new credential setup flow)
- useEffect(
- function resetCompletionFlag() {
- hasCalledCompleteRef.current = false;
- },
- [credentials],
- );
-
- // Auto-call completion when all credentials are configured
- useEffect(
- function autoCompleteWhenReady() {
- if (isAllComplete && !hasCalledCompleteRef.current) {
- hasCalledCompleteRef.current = true;
- onAllCredentialsComplete();
- }
- },
- [isAllComplete, onAllCredentialsComplete],
- );
-
- return (
-
-
-
-
-
-
-
- Credentials Required
-
-
- {message}
-
-
-
- {credentials.map((cred, index) => {
- const schema = createSchemaFromCredentialInfo(cred);
- const isSelected = !!selectedCredentials[cred.provider];
-
- return (
-
-
-
- {isSelected ? (
-
- ) : (
-
- )}
-
- {cred.providerName}
-
-
-
-
-
- handleCredentialSelect(cred.provider, credMeta)
- }
- />
-
- );
- })}
-
-
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatInput/ChatInput.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatInput/ChatInput.tsx
deleted file mode 100644
index f1caceef70..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatInput/ChatInput.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { cn } from "@/lib/utils";
-import { PaperPlaneRightIcon } from "@phosphor-icons/react";
-import { Button } from "@/components/atoms/Button/Button";
-import { useChatInput } from "./useChatInput";
-
-export interface ChatInputProps {
- onSend: (message: string) => void;
- disabled?: boolean;
- placeholder?: string;
- className?: string;
-}
-
-export function ChatInput({
- onSend,
- disabled = false,
- placeholder = "Type your message...",
- className,
-}: ChatInputProps) {
- const { value, setValue, handleKeyDown, handleSend, textareaRef } =
- useChatInput({
- onSend,
- disabled,
- maxRows: 5,
- });
-
- return (
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatInput/useChatInput.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatInput/useChatInput.ts
deleted file mode 100644
index 2efae95483..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatInput/useChatInput.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { KeyboardEvent, useCallback, useState, useRef, useEffect } from "react";
-
-interface UseChatInputArgs {
- onSend: (message: string) => void;
- disabled?: boolean;
- maxRows?: number;
-}
-
-export function useChatInput({
- onSend,
- disabled = false,
- maxRows = 5,
-}: UseChatInputArgs) {
- const [value, setValue] = useState("");
- const textareaRef = useRef(null);
-
- useEffect(() => {
- const textarea = textareaRef.current;
- if (!textarea) return;
- textarea.style.height = "auto";
- const lineHeight = parseInt(
- window.getComputedStyle(textarea).lineHeight,
- 10,
- );
- const maxHeight = lineHeight * maxRows;
- const newHeight = Math.min(textarea.scrollHeight, maxHeight);
- textarea.style.height = `${newHeight}px`;
- textarea.style.overflowY =
- textarea.scrollHeight > maxHeight ? "auto" : "hidden";
- }, [value, maxRows]);
-
- const handleSend = useCallback(() => {
- if (disabled || !value.trim()) return;
- onSend(value.trim());
- setValue("");
- if (textareaRef.current) {
- textareaRef.current.style.height = "auto";
- }
- }, [value, onSend, disabled]);
-
- const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
- if (event.key === "Enter" && !event.shiftKey) {
- event.preventDefault();
- handleSend();
- }
- },
- [handleSend],
- );
-
- return {
- value,
- setValue,
- handleKeyDown,
- handleSend,
- textareaRef,
- };
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatLoadingState/ChatLoadingState.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatLoadingState/ChatLoadingState.tsx
deleted file mode 100644
index 20f2a980f0..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatLoadingState/ChatLoadingState.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from "react";
-import { Text } from "@/components/atoms/Text/Text";
-import { ArrowClockwiseIcon } from "@phosphor-icons/react";
-import { cn } from "@/lib/utils";
-
-export interface ChatLoadingStateProps {
- message?: string;
- className?: string;
-}
-
-export function ChatLoadingState({
- message = "Loading...",
- className,
-}: ChatLoadingStateProps) {
- return (
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/ChatMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/ChatMessage.tsx
deleted file mode 100644
index 6d8fdacb9a..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/ChatMessage.tsx
+++ /dev/null
@@ -1,194 +0,0 @@
-"use client";
-
-import { cn } from "@/lib/utils";
-import { RobotIcon, UserIcon, CheckCircleIcon } from "@phosphor-icons/react";
-import { useCallback } from "react";
-import { MessageBubble } from "@/app/(platform)/chat/components/MessageBubble/MessageBubble";
-import { MarkdownContent } from "@/app/(platform)/chat/components/MarkdownContent/MarkdownContent";
-import { ToolCallMessage } from "@/app/(platform)/chat/components/ToolCallMessage/ToolCallMessage";
-import { ToolResponseMessage } from "@/app/(platform)/chat/components/ToolResponseMessage/ToolResponseMessage";
-import { AuthPromptWidget } from "@/app/(platform)/chat/components/AuthPromptWidget/AuthPromptWidget";
-import { ChatCredentialsSetup } from "@/app/(platform)/chat/components/ChatCredentialsSetup/ChatCredentialsSetup";
-import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
-import { useChatMessage, type ChatMessageData } from "./useChatMessage";
-import { getToolActionPhrase } from "@/app/(platform)/chat/helpers";
-export interface ChatMessageProps {
- message: ChatMessageData;
- className?: string;
- onDismissLogin?: () => void;
- onDismissCredentials?: () => void;
- onSendMessage?: (content: string, isUserMessage?: boolean) => void;
-}
-
-export function ChatMessage({
- message,
- className,
- onDismissCredentials,
- onSendMessage,
-}: ChatMessageProps) {
- const { user } = useSupabase();
- const {
- formattedTimestamp,
- isUser,
- isAssistant,
- isToolCall,
- isToolResponse,
- isLoginNeeded,
- isCredentialsNeeded,
- } = useChatMessage(message);
-
- const handleAllCredentialsComplete = useCallback(
- function handleAllCredentialsComplete() {
- // Send a user message that explicitly asks to retry the setup
- // This ensures the LLM calls get_required_setup_info again and proceeds with execution
- if (onSendMessage) {
- onSendMessage(
- "I've configured the required credentials. Please check if everything is ready and proceed with setting up the agent.",
- );
- }
- // Optionally dismiss the credentials prompt
- if (onDismissCredentials) {
- onDismissCredentials();
- }
- },
- [onSendMessage, onDismissCredentials],
- );
-
- function handleCancelCredentials() {
- // Dismiss the credentials prompt
- if (onDismissCredentials) {
- onDismissCredentials();
- }
- }
-
- // Render credentials needed messages
- if (isCredentialsNeeded && message.type === "credentials_needed") {
- return (
-
- );
- }
-
- // Render login needed messages
- if (isLoginNeeded && message.type === "login_needed") {
- // If user is already logged in, show success message instead of auth prompt
- if (user) {
- return (
-
-
-
-
-
-
-
-
-
- Successfully Authenticated
-
-
- You're now signed in and ready to continue
-
-
-
-
-
-
- );
- }
-
- // Show auth prompt if not logged in
- return (
-
- );
- }
-
- // Render tool call messages
- if (isToolCall && message.type === "tool_call") {
- return (
-
-
-
- );
- }
-
- // Render tool response messages
- if (
- (isToolResponse && message.type === "tool_response") ||
- message.type === "no_results" ||
- message.type === "agent_carousel" ||
- message.type === "execution_started"
- ) {
- return (
-
-
-
- );
- }
-
- // Render regular chat messages
- if (message.type === "message") {
- return (
-
- {/* Avatar */}
-
-
- {isUser ? (
-
- ) : (
-
- )}
-
-
-
- {/* Message Content */}
-
-
-
-
-
- {/* Timestamp */}
-
- {formattedTimestamp}
-
-
-
- );
- }
-
- // Fallback for unknown message types
- return null;
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/MessageBubble/MessageBubble.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/MessageBubble/MessageBubble.tsx
deleted file mode 100644
index 96798493a1..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/MessageBubble/MessageBubble.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import { cn } from "@/lib/utils";
-import { ReactNode } from "react";
-
-export interface MessageBubbleProps {
- children: ReactNode;
- variant: "user" | "assistant";
- className?: string;
-}
-
-export function MessageBubble({
- children,
- variant,
- className,
-}: MessageBubbleProps) {
- return (
-
- {children}
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/MessageList/MessageList.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/MessageList/MessageList.tsx
deleted file mode 100644
index aa8c4d9772..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/MessageList/MessageList.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { cn } from "@/lib/utils";
-import { ChatMessage } from "../ChatMessage/ChatMessage";
-import type { ChatMessageData } from "../ChatMessage/useChatMessage";
-import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
-import { useMessageList } from "./useMessageList";
-
-export interface MessageListProps {
- messages: ChatMessageData[];
- streamingChunks?: string[];
- isStreaming?: boolean;
- className?: string;
- onStreamComplete?: () => void;
- onSendMessage?: (content: string) => void;
-}
-
-export function MessageList({
- messages,
- streamingChunks = [],
- isStreaming = false,
- className,
- onStreamComplete,
- onSendMessage,
-}: MessageListProps) {
- const { messagesEndRef, messagesContainerRef } = useMessageList({
- messageCount: messages.length,
- isStreaming,
- });
-
- return (
-
-
- {/* Render all persisted messages */}
- {messages.map((message, index) => (
-
- ))}
-
- {/* Render streaming message if active */}
- {isStreaming && streamingChunks.length > 0 && (
-
- )}
-
- {/* Invisible div to scroll to */}
-
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx
deleted file mode 100644
index 0cf637542a..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from "react";
-import { Text } from "@/components/atoms/Text/Text";
-import { cn } from "@/lib/utils";
-
-export interface QuickActionsWelcomeProps {
- title: string;
- description: string;
- actions: string[];
- onActionClick: (action: string) => void;
- disabled?: boolean;
- className?: string;
-}
-
-export function QuickActionsWelcome({
- title,
- description,
- actions,
- onActionClick,
- disabled = false,
- className,
-}: QuickActionsWelcomeProps) {
- return (
-
-
-
- {title}
-
-
- {description}
-
-
- {actions.map((action) => (
- onActionClick(action)}
- disabled={disabled}
- className="rounded-lg border border-zinc-200 bg-white p-4 text-left text-sm hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:bg-zinc-900 dark:hover:bg-zinc-800"
- >
- {action}
-
- ))}
-
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/StreamingMessage/StreamingMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/StreamingMessage/StreamingMessage.tsx
deleted file mode 100644
index 73b0419740..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/StreamingMessage/StreamingMessage.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { cn } from "@/lib/utils";
-import { Robot } from "@phosphor-icons/react";
-import { MessageBubble } from "@/app/(platform)/chat/components/MessageBubble/MessageBubble";
-import { MarkdownContent } from "@/app/(platform)/chat/components/MarkdownContent/MarkdownContent";
-import { useStreamingMessage } from "./useStreamingMessage";
-
-export interface StreamingMessageProps {
- chunks: string[];
- className?: string;
- onComplete?: () => void;
-}
-
-export function StreamingMessage({
- chunks,
- className,
- onComplete,
-}: StreamingMessageProps) {
- const { displayText } = useStreamingMessage({ chunks, onComplete });
-
- return (
-
- {/* Avatar */}
-
-
- {/* Message Content */}
-
-
-
-
-
- {/* Timestamp */}
-
- Typing...
-
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ToolCallMessage/ToolCallMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/ToolCallMessage/ToolCallMessage.tsx
deleted file mode 100644
index c1babc587c..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ToolCallMessage/ToolCallMessage.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import React from "react";
-import { WrenchIcon } from "@phosphor-icons/react";
-import { cn } from "@/lib/utils";
-import { getToolActionPhrase } from "@/app/(platform)/chat/helpers";
-
-export interface ToolCallMessageProps {
- toolName: string;
- className?: string;
-}
-
-export function ToolCallMessage({ toolName, className }: ToolCallMessageProps) {
- return (
-
- {/* Header */}
-
-
-
-
- {getToolActionPhrase(toolName)}...
-
-
-
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ToolResponseMessage/ToolResponseMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/ToolResponseMessage/ToolResponseMessage.tsx
deleted file mode 100644
index fe5d858461..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ToolResponseMessage/ToolResponseMessage.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from "react";
-import { WrenchIcon } from "@phosphor-icons/react";
-import { cn } from "@/lib/utils";
-import { getToolActionPhrase } from "@/app/(platform)/chat/helpers";
-
-export interface ToolResponseMessageProps {
- toolName: string;
- success?: boolean;
- className?: string;
-}
-
-export function ToolResponseMessage({
- toolName,
- success = true,
- className,
-}: ToolResponseMessageProps) {
- return (
-
- {/* Header */}
-
-
-
-
- {getToolActionPhrase(toolName)}...
-
-
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/chat/helpers.ts
deleted file mode 100644
index 5a1e5eb93f..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/helpers.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-/**
- * Maps internal tool names to user-friendly display names with emojis.
- * @deprecated Use getToolActionPhrase or getToolCompletionPhrase for status messages
- *
- * @param toolName - The internal tool name from the backend
- * @returns A user-friendly display name with an emoji prefix
- */
-export function getToolDisplayName(toolName: string): string {
- const toolDisplayNames: Record = {
- find_agent: "🔍 Search Marketplace",
- get_agent_details: "📋 Get Agent Details",
- check_credentials: "🔑 Check Credentials",
- setup_agent: "⚙️ Setup Agent",
- run_agent: "▶️ Run Agent",
- get_required_setup_info: "📝 Get Setup Requirements",
- };
- return toolDisplayNames[toolName] || toolName;
-}
-
-/**
- * Maps internal tool names to human-friendly action phrases (present continuous).
- * Used for tool call messages to indicate what action is currently happening.
- *
- * @param toolName - The internal tool name from the backend
- * @returns A human-friendly action phrase in present continuous tense
- */
-export function getToolActionPhrase(toolName: string): string {
- const toolActionPhrases: Record = {
- find_agent: "Looking for agents in the marketplace",
- agent_carousel: "Looking for agents in the marketplace",
- get_agent_details: "Learning about the agent",
- check_credentials: "Checking your credentials",
- setup_agent: "Setting up the agent",
- execution_started: "Running the agent",
- run_agent: "Running the agent",
- get_required_setup_info: "Getting setup requirements",
- schedule_agent: "Scheduling the agent to run",
- };
-
- // Return mapped phrase or generate human-friendly fallback
- return toolActionPhrases[toolName] || toolName;
-}
-
-/**
- * Maps internal tool names to human-friendly completion phrases (past tense).
- * Used for tool response messages to indicate what action was completed.
- *
- * @param toolName - The internal tool name from the backend
- * @returns A human-friendly completion phrase in past tense
- */
-export function getToolCompletionPhrase(toolName: string): string {
- const toolCompletionPhrases: Record = {
- find_agent: "Finished searching the marketplace",
- get_agent_details: "Got agent details",
- check_credentials: "Checked credentials",
- setup_agent: "Agent setup complete",
- run_agent: "Agent execution started",
- get_required_setup_info: "Got setup requirements",
- };
-
- // Return mapped phrase or generate human-friendly fallback
- return (
- toolCompletionPhrases[toolName] ||
- `Finished ${toolName.replace(/_/g, " ").replace("...", "")}`
- );
-}
-
-/** Validate UUID v4 format */
-export function isValidUUID(value: string): boolean {
- const uuidRegex =
- /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
- return uuidRegex.test(value);
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/page.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/page.tsx
deleted file mode 100644
index 09e530c296..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/page.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-"use client";
-
-import { useChatPage } from "./useChatPage";
-import { ChatContainer } from "./components/ChatContainer/ChatContainer";
-import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
-import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState";
-import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag";
-import { useRouter } from "next/navigation";
-import { useEffect } from "react";
-
-export default function ChatPage() {
- const isChatEnabled = useGetFlag(Flag.CHAT);
- const router = useRouter();
- const {
- messages,
- isLoading,
- isCreating,
- error,
- sessionId,
- createSession,
- clearSession,
- refreshSession,
- } = useChatPage();
-
- useEffect(() => {
- if (isChatEnabled === false) {
- router.push("/404");
- }
- }, [isChatEnabled, router]);
-
- if (isChatEnabled === null || isChatEnabled === false) {
- return null;
- }
-
- return (
-
- {/* Header */}
-
-
- {/* Main Content */}
-
- {/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */}
- {(isLoading || isCreating || (!sessionId && !error)) && (
-
- )}
-
- {/* Error State */}
- {error && !isLoading && (
-
- )}
-
- {/* Session Content */}
- {sessionId && !isLoading && !error && (
-
- )}
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/useChatPage.ts b/autogpt_platform/frontend/src/app/(platform)/chat/useChatPage.ts
deleted file mode 100644
index 4f1db5471a..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/useChatPage.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import { useRouter, useSearchParams } from "next/navigation";
-import { toast } from "sonner";
-import { useChatSession } from "@/app/(platform)/chat/useChatSession";
-import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
-import { useChatStream } from "@/app/(platform)/chat/useChatStream";
-
-export function useChatPage() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const urlSessionId =
- searchParams.get("session_id") || searchParams.get("session");
- const hasCreatedSessionRef = useRef(false);
- const hasClaimedSessionRef = useRef(false);
- const { user } = useSupabase();
- const { sendMessage: sendStreamMessage } = useChatStream();
-
- const {
- session,
- sessionId: sessionIdFromHook,
- messages,
- isLoading,
- isCreating,
- error,
- createSession,
- refreshSession,
- claimSession,
- clearSession: clearSessionBase,
- } = useChatSession({
- urlSessionId,
- autoCreate: false,
- });
-
- useEffect(
- function autoCreateSession() {
- if (
- !urlSessionId &&
- !hasCreatedSessionRef.current &&
- !isCreating &&
- !sessionIdFromHook
- ) {
- hasCreatedSessionRef.current = true;
- createSession().catch((_err) => {
- hasCreatedSessionRef.current = false;
- });
- }
- },
- [urlSessionId, isCreating, sessionIdFromHook, createSession],
- );
-
- useEffect(
- function autoClaimSession() {
- if (
- session &&
- !session.user_id &&
- user &&
- !hasClaimedSessionRef.current &&
- !isLoading &&
- sessionIdFromHook
- ) {
- hasClaimedSessionRef.current = true;
- claimSession(sessionIdFromHook)
- .then(() => {
- sendStreamMessage(
- sessionIdFromHook,
- "User has successfully logged in.",
- () => {},
- false,
- ).catch(() => {});
- })
- .catch(() => {
- hasClaimedSessionRef.current = false;
- });
- }
- },
- [
- session,
- user,
- isLoading,
- sessionIdFromHook,
- claimSession,
- sendStreamMessage,
- ],
- );
-
- useEffect(function monitorNetworkStatus() {
- function handleOnline() {
- toast.success("Connection restored", {
- description: "You're back online",
- });
- }
-
- function handleOffline() {
- toast.error("You're offline", {
- description: "Check your internet connection",
- });
- }
-
- window.addEventListener("online", handleOnline);
- window.addEventListener("offline", handleOffline);
-
- return () => {
- window.removeEventListener("online", handleOnline);
- window.removeEventListener("offline", handleOffline);
- };
- }, []);
-
- function clearSession() {
- clearSessionBase();
- hasCreatedSessionRef.current = false;
- hasClaimedSessionRef.current = false;
- router.push("/chat");
- }
-
- return {
- session,
- messages,
- isLoading,
- isCreating,
- error,
- createSession,
- refreshSession,
- clearSession,
- sessionId: sessionIdFromHook,
- };
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/useChatSession.ts b/autogpt_platform/frontend/src/app/(platform)/chat/useChatSession.ts
deleted file mode 100644
index 99f4efc093..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/useChatSession.ts
+++ /dev/null
@@ -1,258 +0,0 @@
-import { useCallback, useEffect, useState, useRef, useMemo } from "react";
-import { useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
-import {
- usePostV2CreateSession,
- postV2CreateSession,
- useGetV2GetSession,
- usePatchV2SessionAssignUser,
- getGetV2GetSessionQueryKey,
-} from "@/app/api/__generated__/endpoints/chat/chat";
-import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
-import { storage, Key } from "@/services/storage/local-storage";
-import { isValidUUID } from "@/app/(platform)/chat/helpers";
-import { okData } from "@/app/api/helpers";
-
-interface UseChatSessionArgs {
- urlSessionId?: string | null;
- autoCreate?: boolean;
-}
-
-export function useChatSession({
- urlSessionId,
- autoCreate = false,
-}: UseChatSessionArgs = {}) {
- const queryClient = useQueryClient();
- const [sessionId, setSessionId] = useState(null);
- const [error, setError] = useState(null);
- const justCreatedSessionIdRef = useRef(null);
-
- useEffect(() => {
- if (urlSessionId) {
- if (!isValidUUID(urlSessionId)) {
- console.error("Invalid session ID format:", urlSessionId);
- toast.error("Invalid session ID", {
- description:
- "The session ID in the URL is not valid. Starting a new session...",
- });
- setSessionId(null);
- storage.clean(Key.CHAT_SESSION_ID);
- return;
- }
- setSessionId(urlSessionId);
- storage.set(Key.CHAT_SESSION_ID, urlSessionId);
- } else {
- const storedSessionId = storage.get(Key.CHAT_SESSION_ID);
- if (storedSessionId) {
- if (!isValidUUID(storedSessionId)) {
- console.error("Invalid stored session ID:", storedSessionId);
- storage.clean(Key.CHAT_SESSION_ID);
- setSessionId(null);
- } else {
- setSessionId(storedSessionId);
- }
- } else if (autoCreate) {
- setSessionId(null);
- }
- }
- }, [urlSessionId, autoCreate]);
-
- const {
- mutateAsync: createSessionMutation,
- isPending: isCreating,
- error: createError,
- } = usePostV2CreateSession();
-
- const {
- data: sessionData,
- isLoading: isLoadingSession,
- error: loadError,
- refetch,
- } = useGetV2GetSession(sessionId || "", {
- query: {
- enabled: !!sessionId,
- select: okData,
- staleTime: Infinity, // Never mark as stale
- refetchOnMount: false, // Don't refetch on component mount
- refetchOnWindowFocus: false, // Don't refetch when window regains focus
- refetchOnReconnect: false, // Don't refetch when network reconnects
- retry: 1,
- },
- });
-
- const { mutateAsync: claimSessionMutation } = usePatchV2SessionAssignUser();
-
- const session = useMemo(() => {
- if (sessionData) return sessionData;
-
- if (sessionId && justCreatedSessionIdRef.current === sessionId) {
- return {
- id: sessionId,
- user_id: null,
- messages: [],
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- } as SessionDetailResponse;
- }
- return null;
- }, [sessionData, sessionId]);
-
- const messages = session?.messages || [];
- const isLoading = isCreating || isLoadingSession;
-
- useEffect(() => {
- if (createError) {
- setError(
- createError instanceof Error
- ? createError
- : new Error("Failed to create session"),
- );
- } else if (loadError) {
- setError(
- loadError instanceof Error
- ? loadError
- : new Error("Failed to load session"),
- );
- } else {
- setError(null);
- }
- }, [createError, loadError]);
-
- const createSession = useCallback(
- async function createSession() {
- try {
- setError(null);
- const response = await postV2CreateSession({
- body: JSON.stringify({}),
- });
- if (response.status !== 200) {
- throw new Error("Failed to create session");
- }
- const newSessionId = response.data.id;
- setSessionId(newSessionId);
- storage.set(Key.CHAT_SESSION_ID, newSessionId);
- justCreatedSessionIdRef.current = newSessionId;
- setTimeout(() => {
- if (justCreatedSessionIdRef.current === newSessionId) {
- justCreatedSessionIdRef.current = null;
- }
- }, 10000);
- return newSessionId;
- } catch (err) {
- const error =
- err instanceof Error ? err : new Error("Failed to create session");
- setError(error);
- toast.error("Failed to create chat session", {
- description: error.message,
- });
- throw error;
- }
- },
- [createSessionMutation],
- );
-
- const loadSession = useCallback(
- async function loadSession(id: string) {
- try {
- setError(null);
- setSessionId(id);
- storage.set(Key.CHAT_SESSION_ID, id);
- const result = await refetch();
- if (!result.data || result.isError) {
- console.warn("Session not found on server, clearing local state");
- storage.clean(Key.CHAT_SESSION_ID);
- setSessionId(null);
- throw new Error("Session not found");
- }
- } catch (err) {
- const error =
- err instanceof Error ? err : new Error("Failed to load session");
- setError(error);
- throw error;
- }
- },
- [refetch],
- );
-
- const refreshSession = useCallback(
- async function refreshSession() {
- if (!sessionId) {
- console.log("[refreshSession] Skipping - no session ID");
- return;
- }
- try {
- setError(null);
- await refetch();
- } catch (err) {
- const error =
- err instanceof Error ? err : new Error("Failed to refresh session");
- setError(error);
- throw error;
- }
- },
- [sessionId, refetch],
- );
-
- const claimSession = useCallback(
- async function claimSession(id: string) {
- try {
- setError(null);
- await claimSessionMutation({ sessionId: id });
- if (justCreatedSessionIdRef.current === id) {
- justCreatedSessionIdRef.current = null;
- }
- await queryClient.invalidateQueries({
- queryKey: getGetV2GetSessionQueryKey(id),
- });
- await refetch();
- toast.success("Session claimed successfully", {
- description: "Your chat history has been saved to your account",
- });
- } catch (err: unknown) {
- const error =
- err instanceof Error ? err : new Error("Failed to claim session");
- const is404 =
- (typeof err === "object" &&
- err !== null &&
- "status" in err &&
- err.status === 404) ||
- (typeof err === "object" &&
- err !== null &&
- "response" in err &&
- typeof err.response === "object" &&
- err.response !== null &&
- "status" in err.response &&
- err.response.status === 404);
- if (!is404) {
- setError(error);
- toast.error("Failed to claim session", {
- description: error.message || "Unable to claim session",
- });
- }
- throw error;
- }
- },
- [claimSessionMutation, queryClient, refetch],
- );
-
- const clearSession = useCallback(function clearSession() {
- setSessionId(null);
- setError(null);
- storage.clean(Key.CHAT_SESSION_ID);
- justCreatedSessionIdRef.current = null;
- }, []);
-
- return {
- session,
- sessionId,
- messages,
- isLoading,
- isCreating,
- error,
- createSession,
- loadSession,
- refreshSession,
- claimSession,
- clearSession,
- };
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/useChatStream.ts b/autogpt_platform/frontend/src/app/(platform)/chat/useChatStream.ts
deleted file mode 100644
index 40455e9979..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/chat/useChatStream.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import { useState, useCallback, useRef, useEffect } from "react";
-import { toast } from "sonner";
-import type { ToolArguments, ToolResult } from "@/types/chat";
-
-const MAX_RETRIES = 3;
-const INITIAL_RETRY_DELAY = 1000;
-
-export interface StreamChunk {
- type:
- | "text_chunk"
- | "text_ended"
- | "tool_call"
- | "tool_call_start"
- | "tool_response"
- | "login_needed"
- | "need_login"
- | "credentials_needed"
- | "error"
- | "usage"
- | "stream_end";
- timestamp?: string;
- content?: string;
- message?: string;
- tool_id?: string;
- tool_name?: string;
- arguments?: ToolArguments;
- result?: ToolResult;
- success?: boolean;
- idx?: number;
- session_id?: string;
- agent_info?: {
- graph_id: string;
- name: string;
- trigger_type: string;
- };
- provider?: string;
- provider_name?: string;
- credential_type?: string;
- scopes?: string[];
- title?: string;
- [key: string]: unknown;
-}
-
-export function useChatStream() {
- const [isStreaming, setIsStreaming] = useState(false);
- const [error, setError] = useState(null);
- const eventSourceRef = useRef(null);
- const retryCountRef = useRef(0);
- const retryTimeoutRef = useRef(null);
- const abortControllerRef = useRef(null);
-
- const stopStreaming = useCallback(() => {
- if (abortControllerRef.current) {
- abortControllerRef.current.abort();
- abortControllerRef.current = null;
- }
- if (eventSourceRef.current) {
- eventSourceRef.current.close();
- eventSourceRef.current = null;
- }
- if (retryTimeoutRef.current) {
- clearTimeout(retryTimeoutRef.current);
- retryTimeoutRef.current = null;
- }
- retryCountRef.current = 0;
- setIsStreaming(false);
- }, []);
-
- useEffect(() => {
- return () => {
- stopStreaming();
- };
- }, [stopStreaming]);
-
- const sendMessage = useCallback(
- async (
- sessionId: string,
- message: string,
- onChunk: (chunk: StreamChunk) => void,
- isUserMessage: boolean = true,
- ) => {
- stopStreaming();
-
- const abortController = new AbortController();
- abortControllerRef.current = abortController;
-
- if (abortController.signal.aborted) {
- return Promise.reject(new Error("Request aborted"));
- }
-
- retryCountRef.current = 0;
- setIsStreaming(true);
- setError(null);
-
- try {
- const url = `/api/chat/sessions/${sessionId}/stream?message=${encodeURIComponent(
- message,
- )}&is_user_message=${isUserMessage}`;
-
- const eventSource = new EventSource(url);
- eventSourceRef.current = eventSource;
-
- abortController.signal.addEventListener("abort", () => {
- eventSource.close();
- eventSourceRef.current = null;
- });
-
- return new Promise((resolve, reject) => {
- const cleanup = () => {
- eventSource.removeEventListener("message", messageHandler);
- eventSource.removeEventListener("error", errorHandler);
- };
-
- const messageHandler = (event: MessageEvent) => {
- try {
- const chunk = JSON.parse(event.data) as StreamChunk;
-
- if (retryCountRef.current > 0) {
- retryCountRef.current = 0;
- }
-
- // Call the chunk handler
- onChunk(chunk);
-
- // Handle stream lifecycle
- if (chunk.type === "stream_end") {
- cleanup();
- stopStreaming();
- resolve();
- } else if (chunk.type === "error") {
- cleanup();
- reject(
- new Error(chunk.message || chunk.content || "Stream error"),
- );
- }
- } catch (err) {
- const parseError =
- err instanceof Error
- ? err
- : new Error("Failed to parse stream chunk");
- setError(parseError);
- cleanup();
- reject(parseError);
- }
- };
-
- const errorHandler = () => {
- if (eventSourceRef.current) {
- eventSourceRef.current.close();
- eventSourceRef.current = null;
- }
-
- if (retryCountRef.current < MAX_RETRIES) {
- retryCountRef.current += 1;
- const retryDelay =
- INITIAL_RETRY_DELAY * Math.pow(2, retryCountRef.current - 1);
-
- toast.info("Connection interrupted", {
- description: `Retrying in ${retryDelay / 1000} seconds...`,
- });
-
- retryTimeoutRef.current = setTimeout(() => {
- sendMessage(sessionId, message, onChunk, isUserMessage).catch(
- (_err) => {
- // Retry failed
- },
- );
- }, retryDelay);
- } else {
- const streamError = new Error(
- "Stream connection failed after multiple retries",
- );
- setError(streamError);
- toast.error("Connection Failed", {
- description:
- "Unable to connect to chat service. Please try again.",
- });
- cleanup();
- stopStreaming();
- reject(streamError);
- }
- };
-
- eventSource.addEventListener("message", messageHandler);
- eventSource.addEventListener("error", errorHandler);
- });
- } catch (err) {
- const streamError =
- err instanceof Error ? err : new Error("Failed to start stream");
- setError(streamError);
- setIsStreaming(false);
- throw streamError;
- }
- },
- [stopStreaming],
- );
-
- return {
- isStreaming,
- error,
- sendMessage,
- stopStreaming,
- };
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx
new file mode 100644
index 0000000000..3f695da5ed
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/CopilotShell.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
+import { Text } from "@/components/atoms/Text/Text";
+import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
+import type { ReactNode } from "react";
+import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar";
+import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
+import { MobileHeader } from "./components/MobileHeader/MobileHeader";
+import { useCopilotShell } from "./useCopilotShell";
+
+interface Props {
+ children: ReactNode;
+}
+
+export function CopilotShell({ children }: Props) {
+ const {
+ isMobile,
+ isDrawerOpen,
+ isLoading,
+ isCreatingSession,
+ isLoggedIn,
+ hasActiveSession,
+ sessions,
+ currentSessionId,
+ handleOpenDrawer,
+ handleCloseDrawer,
+ handleDrawerOpenChange,
+ handleNewChatClick,
+ handleSessionClick,
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ } = useCopilotShell();
+
+ if (!isLoggedIn) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {!isMobile && (
+
+ )}
+
+
+ {isMobile &&
}
+
+ {isCreatingSession ? (
+
+
+
+
+ Creating your chat...
+
+
+
+ ) : (
+ children
+ )}
+
+
+
+ {isMobile && (
+
+ )}
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/DesktopSidebar/DesktopSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/DesktopSidebar/DesktopSidebar.tsx
new file mode 100644
index 0000000000..122a09a02f
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/DesktopSidebar/DesktopSidebar.tsx
@@ -0,0 +1,70 @@
+import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
+import { Button } from "@/components/atoms/Button/Button";
+import { Text } from "@/components/atoms/Text/Text";
+import { scrollbarStyles } from "@/components/styles/scrollbars";
+import { cn } from "@/lib/utils";
+import { Plus } from "@phosphor-icons/react";
+import { SessionsList } from "../SessionsList/SessionsList";
+
+interface Props {
+ sessions: SessionSummaryResponse[];
+ currentSessionId: string | null;
+ isLoading: boolean;
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+ onSelectSession: (sessionId: string) => void;
+ onFetchNextPage: () => void;
+ onNewChat: () => void;
+ hasActiveSession: boolean;
+}
+
+export function DesktopSidebar({
+ sessions,
+ currentSessionId,
+ isLoading,
+ hasNextPage,
+ isFetchingNextPage,
+ onSelectSession,
+ onFetchNextPage,
+ onNewChat,
+ hasActiveSession,
+}: Props) {
+ return (
+
+
+
+ Your chats
+
+
+
+
+
+ {hasActiveSession && (
+
+ }
+ >
+ New Chat
+
+
+ )}
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/MobileDrawer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/MobileDrawer.tsx
new file mode 100644
index 0000000000..ea3b39f829
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/MobileDrawer.tsx
@@ -0,0 +1,91 @@
+import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
+import { Button } from "@/components/atoms/Button/Button";
+import { scrollbarStyles } from "@/components/styles/scrollbars";
+import { cn } from "@/lib/utils";
+import { PlusIcon, X } from "@phosphor-icons/react";
+import { Drawer } from "vaul";
+import { SessionsList } from "../SessionsList/SessionsList";
+
+interface Props {
+ isOpen: boolean;
+ sessions: SessionSummaryResponse[];
+ currentSessionId: string | null;
+ isLoading: boolean;
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+ onSelectSession: (sessionId: string) => void;
+ onFetchNextPage: () => void;
+ onNewChat: () => void;
+ onClose: () => void;
+ onOpenChange: (open: boolean) => void;
+ hasActiveSession: boolean;
+}
+
+export function MobileDrawer({
+ isOpen,
+ sessions,
+ currentSessionId,
+ isLoading,
+ hasNextPage,
+ isFetchingNextPage,
+ onSelectSession,
+ onFetchNextPage,
+ onNewChat,
+ onClose,
+ onOpenChange,
+ hasActiveSession,
+}: Props) {
+ return (
+
+
+
+
+
+
+
+ Your chats
+
+
+
+
+
+
+
+
+
+ {hasActiveSession && (
+
+ }
+ >
+ New Chat
+
+
+ )}
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/useMobileDrawer.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/useMobileDrawer.ts
new file mode 100644
index 0000000000..2ef63a4422
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileDrawer/useMobileDrawer.ts
@@ -0,0 +1,24 @@
+import { useState } from "react";
+
+export function useMobileDrawer() {
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
+
+ const handleOpenDrawer = () => {
+ setIsDrawerOpen(true);
+ };
+
+ const handleCloseDrawer = () => {
+ setIsDrawerOpen(false);
+ };
+
+ const handleDrawerOpenChange = (open: boolean) => {
+ setIsDrawerOpen(open);
+ };
+
+ return {
+ isDrawerOpen,
+ handleOpenDrawer,
+ handleCloseDrawer,
+ handleDrawerOpenChange,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileHeader/MobileHeader.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileHeader/MobileHeader.tsx
new file mode 100644
index 0000000000..e0d6161744
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/MobileHeader/MobileHeader.tsx
@@ -0,0 +1,22 @@
+import { Button } from "@/components/atoms/Button/Button";
+import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
+import { ListIcon } from "@phosphor-icons/react";
+
+interface Props {
+ onOpenDrawer: () => void;
+}
+
+export function MobileHeader({ onOpenDrawer }: Props) {
+ return (
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/SessionsList.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/SessionsList.tsx
new file mode 100644
index 0000000000..ef63e1aff4
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/SessionsList.tsx
@@ -0,0 +1,80 @@
+import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
+import { Skeleton } from "@/components/__legacy__/ui/skeleton";
+import { Text } from "@/components/atoms/Text/Text";
+import { InfiniteList } from "@/components/molecules/InfiniteList/InfiniteList";
+import { cn } from "@/lib/utils";
+import { getSessionTitle } from "../../helpers";
+
+interface Props {
+ sessions: SessionSummaryResponse[];
+ currentSessionId: string | null;
+ isLoading: boolean;
+ hasNextPage: boolean;
+ isFetchingNextPage: boolean;
+ onSelectSession: (sessionId: string) => void;
+ onFetchNextPage: () => void;
+}
+
+export function SessionsList({
+ sessions,
+ currentSessionId,
+ isLoading,
+ hasNextPage,
+ isFetchingNextPage,
+ onSelectSession,
+ onFetchNextPage,
+}: Props) {
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+
+
+ ))}
+
+ );
+ }
+
+ if (sessions.length === 0) {
+ return (
+
+
+ You don't have previous chats
+
+
+ );
+ }
+
+ return (
+ {
+ const isActive = session.id === currentSessionId;
+ return (
+ onSelectSession(session.id)}
+ className={cn(
+ "w-full rounded-lg px-3 py-2.5 text-left transition-colors",
+ isActive ? "bg-zinc-100" : "hover:bg-zinc-50",
+ )}
+ >
+
+ {getSessionTitle(session)}
+
+
+ );
+ }}
+ />
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts
new file mode 100644
index 0000000000..61e3e6f37f
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/components/SessionsList/useSessionsPagination.ts
@@ -0,0 +1,91 @@
+import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
+import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
+import { okData } from "@/app/api/helpers";
+import { useEffect, useState } from "react";
+
+const PAGE_SIZE = 50;
+
+export interface UseSessionsPaginationArgs {
+ enabled: boolean;
+}
+
+export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) {
+ const [offset, setOffset] = useState(0);
+
+ const [accumulatedSessions, setAccumulatedSessions] = useState<
+ SessionSummaryResponse[]
+ >([]);
+
+ const [totalCount, setTotalCount] = useState(null);
+
+ const { data, isLoading, isFetching, isError } = useGetV2ListSessions(
+ { limit: PAGE_SIZE, offset },
+ {
+ query: {
+ enabled: enabled && offset >= 0,
+ },
+ },
+ );
+
+ useEffect(() => {
+ const responseData = okData(data);
+ if (responseData) {
+ const newSessions = responseData.sessions;
+ const total = responseData.total;
+ setTotalCount(total);
+
+ if (offset === 0) {
+ setAccumulatedSessions(newSessions);
+ } else {
+ setAccumulatedSessions((prev) => [...prev, ...newSessions]);
+ }
+ } else if (!enabled) {
+ setAccumulatedSessions([]);
+ setTotalCount(null);
+ }
+ }, [data, offset, enabled]);
+
+ const hasNextPage =
+ totalCount !== null && accumulatedSessions.length < totalCount;
+
+ const areAllSessionsLoaded =
+ totalCount !== null &&
+ accumulatedSessions.length >= totalCount &&
+ !isFetching &&
+ !isLoading;
+
+ useEffect(() => {
+ if (
+ hasNextPage &&
+ !isFetching &&
+ !isLoading &&
+ !isError &&
+ totalCount !== null
+ ) {
+ setOffset((prev) => prev + PAGE_SIZE);
+ }
+ }, [hasNextPage, isFetching, isLoading, isError, totalCount]);
+
+ const fetchNextPage = () => {
+ if (hasNextPage && !isFetching) {
+ setOffset((prev) => prev + PAGE_SIZE);
+ }
+ };
+
+ const reset = () => {
+ // Only reset the offset - keep existing sessions visible during refetch
+ // The effect will replace sessions when new data arrives at offset 0
+ setOffset(0);
+ };
+
+ return {
+ sessions: accumulatedSessions,
+ isLoading,
+ isFetching,
+ hasNextPage,
+ areAllSessionsLoaded,
+ totalCount,
+ fetchNextPage,
+ reset,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts
new file mode 100644
index 0000000000..ef0d414edf
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/helpers.ts
@@ -0,0 +1,106 @@
+import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
+import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
+import { format, formatDistanceToNow, isToday } from "date-fns";
+
+export function convertSessionDetailToSummary(session: SessionDetailResponse) {
+ return {
+ id: session.id,
+ created_at: session.created_at,
+ updated_at: session.updated_at,
+ title: undefined,
+ };
+}
+
+export function filterVisibleSessions(sessions: SessionSummaryResponse[]) {
+ const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
+ return sessions.filter((session) => {
+ const hasBeenUpdated = session.updated_at !== session.created_at;
+
+ if (hasBeenUpdated) return true;
+
+ const isRecentlyCreated =
+ new Date(session.created_at).getTime() > fiveMinutesAgo;
+
+ return isRecentlyCreated;
+ });
+}
+
+export function getSessionTitle(session: SessionSummaryResponse) {
+ if (session.title) return session.title;
+
+ const isNewSession = session.updated_at === session.created_at;
+
+ if (isNewSession) {
+ const createdDate = new Date(session.created_at);
+ if (isToday(createdDate)) {
+ return "Today";
+ }
+ return format(createdDate, "MMM d, yyyy");
+ }
+
+ return "Untitled Chat";
+}
+
+export function getSessionUpdatedLabel(session: SessionSummaryResponse) {
+ if (!session.updated_at) return "";
+ return formatDistanceToNow(new Date(session.updated_at), { addSuffix: true });
+}
+
+export function mergeCurrentSessionIntoList(
+ accumulatedSessions: SessionSummaryResponse[],
+ currentSessionId: string | null,
+ currentSessionData: SessionDetailResponse | null | undefined,
+ recentlyCreatedSessions?: Map,
+) {
+ const filteredSessions: SessionSummaryResponse[] = [];
+ const addedIds = new Set();
+
+ if (accumulatedSessions.length > 0) {
+ const visibleSessions = filterVisibleSessions(accumulatedSessions);
+
+ if (currentSessionId) {
+ const currentInAll = accumulatedSessions.find(
+ (s) => s.id === currentSessionId,
+ );
+ if (currentInAll) {
+ const isInVisible = visibleSessions.some(
+ (s) => s.id === currentSessionId,
+ );
+ if (!isInVisible) {
+ filteredSessions.push(currentInAll);
+ addedIds.add(currentInAll.id);
+ }
+ }
+ }
+
+ for (const session of visibleSessions) {
+ if (!addedIds.has(session.id)) {
+ filteredSessions.push(session);
+ addedIds.add(session.id);
+ }
+ }
+ }
+
+ if (currentSessionId && currentSessionData) {
+ if (!addedIds.has(currentSessionId)) {
+ const summarySession = convertSessionDetailToSummary(currentSessionData);
+ filteredSessions.unshift(summarySession);
+ addedIds.add(currentSessionId);
+ }
+ }
+
+ if (recentlyCreatedSessions) {
+ for (const [sessionId, sessionData] of recentlyCreatedSessions) {
+ if (!addedIds.has(sessionId)) {
+ filteredSessions.unshift(sessionData);
+ addedIds.add(sessionId);
+ }
+ }
+ }
+
+ return filteredSessions;
+}
+
+export function getCurrentSessionId(searchParams: URLSearchParams) {
+ return searchParams.get("sessionId");
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts
new file mode 100644
index 0000000000..913c4d7ded
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useCopilotShell.ts
@@ -0,0 +1,124 @@
+"use client";
+
+import {
+ getGetV2GetSessionQueryKey,
+ getGetV2ListSessionsQueryKey,
+ useGetV2GetSession,
+} from "@/app/api/__generated__/endpoints/chat/chat";
+import { okData } from "@/app/api/helpers";
+import { useChatStore } from "@/components/contextual/Chat/chat-store";
+import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
+import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
+import { useQueryClient } from "@tanstack/react-query";
+import { usePathname, useSearchParams } from "next/navigation";
+import { useCopilotStore } from "../../copilot-page-store";
+import { useCopilotSessionId } from "../../useCopilotSessionId";
+import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer";
+import { getCurrentSessionId } from "./helpers";
+import { useShellSessionList } from "./useShellSessionList";
+
+export function useCopilotShell() {
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+ const queryClient = useQueryClient();
+ const breakpoint = useBreakpoint();
+ const { isLoggedIn } = useSupabase();
+ const isMobile =
+ breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
+
+ const { urlSessionId, setUrlSessionId } = useCopilotSessionId();
+
+ const isOnHomepage = pathname === "/copilot";
+ const paramSessionId = searchParams.get("sessionId");
+
+ const {
+ isDrawerOpen,
+ handleOpenDrawer,
+ handleCloseDrawer,
+ handleDrawerOpenChange,
+ } = useMobileDrawer();
+
+ const paginationEnabled = !isMobile || isDrawerOpen || !!paramSessionId;
+
+ const currentSessionId = getCurrentSessionId(searchParams);
+
+ const { data: currentSessionData } = useGetV2GetSession(
+ currentSessionId || "",
+ {
+ query: {
+ enabled: !!currentSessionId,
+ select: okData,
+ },
+ },
+ );
+
+ const {
+ sessions,
+ isLoading,
+ isSessionsFetching,
+ hasNextPage,
+ fetchNextPage,
+ resetPagination,
+ recentlyCreatedSessionsRef,
+ } = useShellSessionList({
+ paginationEnabled,
+ currentSessionId,
+ currentSessionData,
+ isOnHomepage,
+ paramSessionId,
+ });
+
+ const stopStream = useChatStore((s) => s.stopStream);
+ const isCreatingSession = useCopilotStore((s) => s.isCreatingSession);
+
+ function handleSessionClick(sessionId: string) {
+ if (sessionId === currentSessionId) return;
+
+ // Stop current stream - SSE reconnection allows resuming later
+ if (currentSessionId) {
+ stopStream(currentSessionId);
+ }
+
+ if (recentlyCreatedSessionsRef.current.has(sessionId)) {
+ queryClient.invalidateQueries({
+ queryKey: getGetV2GetSessionQueryKey(sessionId),
+ });
+ }
+ setUrlSessionId(sessionId, { shallow: false });
+ if (isMobile) handleCloseDrawer();
+ }
+
+ function handleNewChatClick() {
+ // Stop current stream - SSE reconnection allows resuming later
+ if (currentSessionId) {
+ stopStream(currentSessionId);
+ }
+
+ resetPagination();
+ queryClient.invalidateQueries({
+ queryKey: getGetV2ListSessionsQueryKey(),
+ });
+ setUrlSessionId(null, { shallow: false });
+ if (isMobile) handleCloseDrawer();
+ }
+
+ return {
+ isMobile,
+ isDrawerOpen,
+ isLoggedIn,
+ hasActiveSession:
+ Boolean(currentSessionId) && (!isOnHomepage || Boolean(paramSessionId)),
+ isLoading: isLoading || isCreatingSession,
+ isCreatingSession,
+ sessions,
+ currentSessionId: urlSessionId,
+ handleOpenDrawer,
+ handleCloseDrawer,
+ handleDrawerOpenChange,
+ handleNewChatClick,
+ handleSessionClick,
+ hasNextPage,
+ isFetchingNextPage: isSessionsFetching,
+ fetchNextPage,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useShellSessionList.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useShellSessionList.ts
new file mode 100644
index 0000000000..fb39a11096
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/CopilotShell/useShellSessionList.ts
@@ -0,0 +1,113 @@
+import { getGetV2ListSessionsQueryKey } from "@/app/api/__generated__/endpoints/chat/chat";
+import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
+import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
+import { useChatStore } from "@/components/contextual/Chat/chat-store";
+import { useQueryClient } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef } from "react";
+import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination";
+import {
+ convertSessionDetailToSummary,
+ filterVisibleSessions,
+ mergeCurrentSessionIntoList,
+} from "./helpers";
+
+interface UseShellSessionListArgs {
+ paginationEnabled: boolean;
+ currentSessionId: string | null;
+ currentSessionData: SessionDetailResponse | null | undefined;
+ isOnHomepage: boolean;
+ paramSessionId: string | null;
+}
+
+export function useShellSessionList({
+ paginationEnabled,
+ currentSessionId,
+ currentSessionData,
+ isOnHomepage,
+ paramSessionId,
+}: UseShellSessionListArgs) {
+ const queryClient = useQueryClient();
+ const onStreamComplete = useChatStore((s) => s.onStreamComplete);
+
+ const {
+ sessions: accumulatedSessions,
+ isLoading: isSessionsLoading,
+ isFetching: isSessionsFetching,
+ hasNextPage,
+ fetchNextPage,
+ reset: resetPagination,
+ } = useSessionsPagination({
+ enabled: paginationEnabled,
+ });
+
+ const recentlyCreatedSessionsRef = useRef<
+ Map
+ >(new Map());
+
+ useEffect(() => {
+ if (isOnHomepage && !paramSessionId) {
+ queryClient.invalidateQueries({
+ queryKey: getGetV2ListSessionsQueryKey(),
+ });
+ }
+ }, [isOnHomepage, paramSessionId, queryClient]);
+
+ useEffect(() => {
+ if (currentSessionId && currentSessionData) {
+ const isNewSession =
+ currentSessionData.updated_at === currentSessionData.created_at;
+ const isNotInAccumulated = !accumulatedSessions.some(
+ (s) => s.id === currentSessionId,
+ );
+ if (isNewSession || isNotInAccumulated) {
+ const summary = convertSessionDetailToSummary(currentSessionData);
+ recentlyCreatedSessionsRef.current.set(currentSessionId, summary);
+ }
+ }
+ }, [currentSessionId, currentSessionData, accumulatedSessions]);
+
+ useEffect(() => {
+ for (const sessionId of recentlyCreatedSessionsRef.current.keys()) {
+ if (accumulatedSessions.some((s) => s.id === sessionId)) {
+ recentlyCreatedSessionsRef.current.delete(sessionId);
+ }
+ }
+ }, [accumulatedSessions]);
+
+ useEffect(() => {
+ const unsubscribe = onStreamComplete(() => {
+ queryClient.invalidateQueries({
+ queryKey: getGetV2ListSessionsQueryKey(),
+ });
+ });
+ return unsubscribe;
+ }, [onStreamComplete, queryClient]);
+
+ const sessions = useMemo(
+ () =>
+ mergeCurrentSessionIntoList(
+ accumulatedSessions,
+ currentSessionId,
+ currentSessionData,
+ recentlyCreatedSessionsRef.current,
+ ),
+ [accumulatedSessions, currentSessionId, currentSessionData],
+ );
+
+ const visibleSessions = useMemo(
+ () => filterVisibleSessions(sessions),
+ [sessions],
+ );
+
+ const isLoading = isSessionsLoading && accumulatedSessions.length === 0;
+
+ return {
+ sessions: visibleSessions,
+ isLoading,
+ isSessionsFetching,
+ hasNextPage,
+ fetchNextPage,
+ resetPagination,
+ recentlyCreatedSessionsRef,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts
new file mode 100644
index 0000000000..9fc97a14e3
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/copilot-page-store.ts
@@ -0,0 +1,56 @@
+"use client";
+
+import { create } from "zustand";
+
+interface CopilotStoreState {
+ isStreaming: boolean;
+ isSwitchingSession: boolean;
+ isCreatingSession: boolean;
+ isInterruptModalOpen: boolean;
+ pendingAction: (() => void) | null;
+}
+
+interface CopilotStoreActions {
+ setIsStreaming: (isStreaming: boolean) => void;
+ setIsSwitchingSession: (isSwitchingSession: boolean) => void;
+ setIsCreatingSession: (isCreating: boolean) => void;
+ openInterruptModal: (onConfirm: () => void) => void;
+ confirmInterrupt: () => void;
+ cancelInterrupt: () => void;
+}
+
+type CopilotStore = CopilotStoreState & CopilotStoreActions;
+
+export const useCopilotStore = create((set, get) => ({
+ isStreaming: false,
+ isSwitchingSession: false,
+ isCreatingSession: false,
+ isInterruptModalOpen: false,
+ pendingAction: null,
+
+ setIsStreaming(isStreaming) {
+ set({ isStreaming });
+ },
+
+ setIsSwitchingSession(isSwitchingSession) {
+ set({ isSwitchingSession });
+ },
+
+ setIsCreatingSession(isCreatingSession) {
+ set({ isCreatingSession });
+ },
+
+ openInterruptModal(onConfirm) {
+ set({ isInterruptModalOpen: true, pendingAction: onConfirm });
+ },
+
+ confirmInterrupt() {
+ const { pendingAction } = get();
+ set({ isInterruptModalOpen: false, pendingAction: null });
+ if (pendingAction) pendingAction();
+ },
+
+ cancelInterrupt() {
+ set({ isInterruptModalOpen: false, pendingAction: null });
+ },
+}));
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts
new file mode 100644
index 0000000000..c6e479f896
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers.ts
@@ -0,0 +1,45 @@
+import type { User } from "@supabase/supabase-js";
+
+export function getGreetingName(user?: User | null): string {
+ if (!user) return "there";
+ const metadata = user.user_metadata as Record | undefined;
+ const fullName = metadata?.full_name;
+ const name = metadata?.name;
+ if (typeof fullName === "string" && fullName.trim()) {
+ return fullName.split(" ")[0];
+ }
+ if (typeof name === "string" && name.trim()) {
+ return name.split(" ")[0];
+ }
+ if (user.email) {
+ return user.email.split("@")[0];
+ }
+ return "there";
+}
+
+export function buildCopilotChatUrl(prompt: string): string {
+ const trimmed = prompt.trim();
+ if (!trimmed) return "/copilot/chat";
+ const encoded = encodeURIComponent(trimmed);
+ return `/copilot/chat?prompt=${encoded}`;
+}
+
+export function getQuickActions(): string[] {
+ return [
+ "I don't know where to start, just ask me stuff",
+ "I do the same thing every week and it's killing me",
+ "Help me find where I'm wasting my time",
+ ];
+}
+
+export function getInputPlaceholder(width?: number) {
+ if (!width) return "What's your role and what eats up most of your day?";
+
+ if (width < 500) {
+ return "I'm a chef and I hate...";
+ }
+ if (width <= 1080) {
+ return "What's your role and what eats up most of your day?";
+ }
+ return "What's your role and what eats up most of your day? e.g. 'I'm a recruiter and I hate...'";
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx
new file mode 100644
index 0000000000..876e5accfb
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/layout.tsx
@@ -0,0 +1,13 @@
+"use client";
+import { FeatureFlagPage } from "@/services/feature-flags/FeatureFlagPage";
+import { Flag } from "@/services/feature-flags/use-get-flag";
+import { type ReactNode } from "react";
+import { CopilotShell } from "./components/CopilotShell/CopilotShell";
+
+export default function CopilotLayout({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx
new file mode 100644
index 0000000000..542173a99c
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { Button } from "@/components/atoms/Button/Button";
+import { Skeleton } from "@/components/atoms/Skeleton/Skeleton";
+import { Text } from "@/components/atoms/Text/Text";
+import { Chat } from "@/components/contextual/Chat/Chat";
+import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput";
+import { Dialog } from "@/components/molecules/Dialog/Dialog";
+import { useEffect, useState } from "react";
+import { useCopilotStore } from "./copilot-page-store";
+import { getInputPlaceholder } from "./helpers";
+import { useCopilotPage } from "./useCopilotPage";
+
+export default function CopilotPage() {
+ const { state, handlers } = useCopilotPage();
+ const isInterruptModalOpen = useCopilotStore((s) => s.isInterruptModalOpen);
+ const confirmInterrupt = useCopilotStore((s) => s.confirmInterrupt);
+ const cancelInterrupt = useCopilotStore((s) => s.cancelInterrupt);
+
+ const [inputPlaceholder, setInputPlaceholder] = useState(
+ getInputPlaceholder(),
+ );
+
+ useEffect(() => {
+ const handleResize = () => {
+ setInputPlaceholder(getInputPlaceholder(window.innerWidth));
+ };
+
+ handleResize();
+
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
+ const { greetingName, quickActions, isLoading, hasSession, initialPrompt } =
+ state;
+
+ const {
+ handleQuickAction,
+ startChatWithPrompt,
+ handleSessionNotFound,
+ handleStreamingChange,
+ } = handlers;
+
+ if (hasSession) {
+ return (
+
+
+
{
+ if (!open) cancelInterrupt();
+ },
+ }}
+ onClose={cancelInterrupt}
+ >
+
+
+
+ The current chat response will be interrupted. Are you sure you
+ want to continue?
+
+
+
+ Cancel
+
+
+ Continue
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+ ) : (
+ <>
+
+
+ Hey, {greetingName}
+
+
+ Tell me about your work — I'll find what to automate.
+
+
+
+
+
+
+
+ {quickActions.map((action) => (
+ handleQuickAction(action)}
+ className="h-auto shrink-0 border-zinc-300 px-3 py-2 text-[.9rem] text-zinc-600"
+ >
+ {action}
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts
new file mode 100644
index 0000000000..9d99f8e7bd
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts
@@ -0,0 +1,127 @@
+import {
+ getGetV2ListSessionsQueryKey,
+ postV2CreateSession,
+} from "@/app/api/__generated__/endpoints/chat/chat";
+import { useToast } from "@/components/molecules/Toast/use-toast";
+import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
+import { useOnboarding } from "@/providers/onboarding/onboarding-provider";
+import { SessionKey, sessionStorage } from "@/services/storage/session-storage";
+import * as Sentry from "@sentry/nextjs";
+import { useQueryClient } from "@tanstack/react-query";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+import { useCopilotStore } from "./copilot-page-store";
+import { getGreetingName, getQuickActions } from "./helpers";
+import { useCopilotSessionId } from "./useCopilotSessionId";
+
+export function useCopilotPage() {
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const { user, isLoggedIn, isUserLoading } = useSupabase();
+ const { toast } = useToast();
+ const { completeStep } = useOnboarding();
+
+ const { urlSessionId, setUrlSessionId } = useCopilotSessionId();
+ const setIsStreaming = useCopilotStore((s) => s.setIsStreaming);
+ const isCreating = useCopilotStore((s) => s.isCreatingSession);
+ const setIsCreating = useCopilotStore((s) => s.setIsCreatingSession);
+
+ const greetingName = getGreetingName(user);
+ const quickActions = getQuickActions();
+
+ const hasSession = Boolean(urlSessionId);
+ const initialPrompt = urlSessionId
+ ? getInitialPrompt(urlSessionId)
+ : undefined;
+
+ useEffect(() => {
+ if (isLoggedIn) completeStep("VISIT_COPILOT");
+ }, [completeStep, isLoggedIn]);
+
+ async function startChatWithPrompt(prompt: string) {
+ if (!prompt?.trim()) return;
+ if (isCreating) return;
+
+ const trimmedPrompt = prompt.trim();
+ setIsCreating(true);
+
+ try {
+ const sessionResponse = await postV2CreateSession({
+ body: JSON.stringify({}),
+ });
+
+ if (sessionResponse.status !== 200 || !sessionResponse.data?.id) {
+ throw new Error("Failed to create session");
+ }
+
+ const sessionId = sessionResponse.data.id;
+ setInitialPrompt(sessionId, trimmedPrompt);
+
+ await queryClient.invalidateQueries({
+ queryKey: getGetV2ListSessionsQueryKey(),
+ });
+
+ await setUrlSessionId(sessionId, { shallow: true });
+ } catch (error) {
+ console.error("[CopilotPage] Failed to start chat:", error);
+ toast({ title: "Failed to start chat", variant: "destructive" });
+ Sentry.captureException(error);
+ } finally {
+ setIsCreating(false);
+ }
+ }
+
+ function handleQuickAction(action: string) {
+ startChatWithPrompt(action);
+ }
+
+ function handleSessionNotFound() {
+ router.replace("/copilot");
+ }
+
+ function handleStreamingChange(isStreamingValue: boolean) {
+ setIsStreaming(isStreamingValue);
+ }
+
+ return {
+ state: {
+ greetingName,
+ quickActions,
+ isLoading: isUserLoading,
+ hasSession,
+ initialPrompt,
+ },
+ handlers: {
+ handleQuickAction,
+ startChatWithPrompt,
+ handleSessionNotFound,
+ handleStreamingChange,
+ },
+ };
+}
+
+function getInitialPrompt(sessionId: string): string | undefined {
+ try {
+ const prompts = JSON.parse(
+ sessionStorage.get(SessionKey.CHAT_INITIAL_PROMPTS) || "{}",
+ );
+ return prompts[sessionId];
+ } catch {
+ return undefined;
+ }
+}
+
+function setInitialPrompt(sessionId: string, prompt: string): void {
+ try {
+ const prompts = JSON.parse(
+ sessionStorage.get(SessionKey.CHAT_INITIAL_PROMPTS) || "{}",
+ );
+ prompts[sessionId] = prompt;
+ sessionStorage.set(
+ SessionKey.CHAT_INITIAL_PROMPTS,
+ JSON.stringify(prompts),
+ );
+ } catch {
+ // Ignore storage errors
+ }
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotSessionId.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotSessionId.ts
new file mode 100644
index 0000000000..87f9b7d3ae
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotSessionId.ts
@@ -0,0 +1,10 @@
+import { parseAsString, useQueryState } from "nuqs";
+
+export function useCopilotSessionId() {
+ const [urlSessionId, setUrlSessionId] = useQueryState(
+ "sessionId",
+ parseAsString,
+ );
+
+ return { urlSessionId, setUrlSessionId };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/error/page.tsx b/autogpt_platform/frontend/src/app/(platform)/error/page.tsx
index b7858787cf..3cf68178ad 100644
--- a/autogpt_platform/frontend/src/app/(platform)/error/page.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/error/page.tsx
@@ -25,8 +25,8 @@ function ErrorPageContent() {
window.location.reload();
}, 2000);
} else {
- // For server/network errors, go to marketplace
- window.location.href = "/marketplace";
+ // For server/network errors, go to home
+ window.location.href = "/";
}
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/layout.tsx b/autogpt_platform/frontend/src/app/(platform)/layout.tsx
index 2975e7d097..048110f8b2 100644
--- a/autogpt_platform/frontend/src/app/(platform)/layout.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/layout.tsx
@@ -1,10 +1,12 @@
import { Navbar } from "@/components/layout/Navbar/Navbar";
-import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner";
+import { NetworkStatusMonitor } from "@/services/network-status/NetworkStatusMonitor";
import { ReactNode } from "react";
+import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner";
export default function PlatformLayout({ children }: { children: ReactNode }) {
return (
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/NewAgentLibraryView.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/NewAgentLibraryView.tsx
index 3768a0d150..b9091584bf 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/NewAgentLibraryView.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/NewAgentLibraryView.tsx
@@ -1,32 +1,31 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
+import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
import { PlusIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
-import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
-import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
-import { MarketplaceBanners } from "@/components/contextual/MarketplaceBanners/MarketplaceBanners";
-import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
-import { AgentSettingsButton } from "./components/other/AgentSettingsButton";
+import { AgentSettingsModal } from "./components/modals/AgentSettingsModal/AgentSettingsModal";
+import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
import { EmptySchedules } from "./components/other/EmptySchedules";
import { EmptyTasks } from "./components/other/EmptyTasks";
import { EmptyTemplates } from "./components/other/EmptyTemplates";
import { EmptyTriggers } from "./components/other/EmptyTriggers";
+import { MarketplaceBanners } from "./components/other/MarketplaceBanners";
import { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
-import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
+import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() {
@@ -45,7 +44,6 @@ export function NewAgentLibraryView() {
handleSelectRun,
handleCountsChange,
handleClearSelectedRun,
- handleSelectSettings,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
@@ -137,13 +135,16 @@ export function NewAgentLibraryView() {
return (
<>
-
-
+
-
+
-
-
- New task
-
- }
- agent={agent}
- onRunCreated={onRunInitiated}
- onScheduleCreated={onScheduleCreated}
- onTriggerSetup={onTriggerSetup}
- initialInputValues={activeTemplate?.inputs}
- initialInputCredentials={activeTemplate?.credentials}
- />
-
-
+
+ New task
+
+ }
+ agent={agent}
+ onRunCreated={onRunInitiated}
+ onScheduleCreated={onScheduleCreated}
+ onTriggerSetup={onTriggerSetup}
+ initialInputValues={activeTemplate?.inputs}
+ initialInputCredentials={activeTemplate?.credentials}
+ />
{activeItem ? (
- activeItem === "settings" ? (
-
- ) : activeTab === "scheduled" ? (
+ activeTab === "scheduled" ? (
)
) : sidebarLoading ? (
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/AgentInputsReadOnly.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/AgentInputsReadOnly.tsx
index bc9918c2bb..88a2be9b34 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/AgentInputsReadOnly.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/AgentInputsReadOnly/AgentInputsReadOnly.tsx
@@ -2,8 +2,9 @@
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
+import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
+import { isSystemCredential } from "@/components/contextual/CredentialsInput/helpers";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
-import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
@@ -71,6 +72,7 @@ export function AgentInputsReadOnly({
{credentialFieldEntries.map(([key, inputSubSchema]) => {
const credential = credentialInputs![key];
if (!credential) return null;
+ if (isSystemCredential(credential)) return null;
return (
void;
+}
+
+export function AgentSettingsModal({
+ agent,
+ controlledOpen,
+ onOpenChange,
+}: Props) {
+ const [internalIsOpen, setInternalIsOpen] = useState(false);
+ const isOpen = controlledOpen !== undefined ? controlledOpen : internalIsOpen;
+
+ function setIsOpen(open: boolean) {
+ if (onOpenChange) {
+ onOpenChange(open);
+ } else {
+ setInternalIsOpen(open);
+ }
+ }
+
+ const {
+ currentHITLSafeMode,
+ showHITLToggle,
+ handleHITLToggle,
+ currentSensitiveActionSafeMode,
+ showSensitiveActionToggle,
+ handleSensitiveActionToggle,
+ isPending,
+ shouldShowToggle,
+ } = useAgentSafeMode(agent);
+
+ if (!shouldShowToggle) return null;
+
+ return (
+
+ {controlledOpen === undefined && (
+
+
+
+ Agent Settings
+
+
+ )}
+
+
+ {showHITLToggle && (
+
+
+
+
+ Human-in-the-loop approval
+
+
+ The agent will pause at human-in-the-loop blocks and wait
+ for your review before continuing
+
+
+
+
+
+ )}
+ {showSensitiveActionToggle && (
+
+
+
+
+ Sensitive action approval
+
+
+ The agent will pause at sensitive action blocks and wait for
+ your review before continuing
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx
deleted file mode 100644
index 6e1ec2afb1..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/components/CredentialsSelect/CredentialsSelect.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/__legacy__/ui/select";
-import { Text } from "@/components/atoms/Text/Text";
-import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
-import { cn } from "@/lib/utils";
-import { useEffect } from "react";
-import { getCredentialDisplayName } from "../../helpers";
-import { CredentialRow } from "../CredentialRow/CredentialRow";
-
-interface Props {
- credentials: Array<{
- id: string;
- title?: string;
- username?: string;
- type: string;
- provider: string;
- }>;
- provider: string;
- displayName: string;
- selectedCredentials?: CredentialsMetaInput;
- onSelectCredential: (credentialId: string) => void;
- onClearCredential?: () => void;
- readOnly?: boolean;
- allowNone?: boolean;
- /** When "node", applies compact styling for node context */
- variant?: "default" | "node";
-}
-
-export function CredentialsSelect({
- credentials,
- provider,
- displayName,
- selectedCredentials,
- onSelectCredential,
- onClearCredential,
- readOnly = false,
- allowNone = true,
- variant = "default",
-}: Props) {
- // Auto-select first credential if none is selected (only if allowNone is false)
- useEffect(() => {
- if (!allowNone && !selectedCredentials && credentials.length > 0) {
- onSelectCredential(credentials[0].id);
- }
- }, [allowNone, selectedCredentials, credentials, onSelectCredential]);
-
- const handleValueChange = (value: string) => {
- if (value === "__none__") {
- onClearCredential?.();
- } else {
- onSelectCredential(value);
- }
- };
-
- return (
-
-
-
- {selectedCredentials ? (
-
- {}}
- onDelete={() => {}}
- readOnly={readOnly}
- asSelectTrigger={true}
- variant={variant}
- />
-
- ) : (
-
- )}
-
-
- {allowNone && (
-
-
-
- None (skip this credential)
-
-
-
- )}
- {credentials.map((credential) => (
-
-
-
- {getCredentialDisplayName(credential, displayName)}
-
-
-
- ))}
-
-
-
- );
-}
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs.tsx
index d3e6fd9669..72d04d634b 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentInputs/RunAgentInputs.tsx
@@ -68,9 +68,15 @@ export function RunAgentInputs({
type="number"
value={value ?? ""}
placeholder={placeholder || "Enter number"}
- onChange={(e) =>
- onChange(Number((e.target as HTMLInputElement).value))
- }
+ onChange={(e) => {
+ const v = (e.target as HTMLInputElement).value;
+ if (!v || v.trim() === "") {
+ onChange(undefined);
+ } else {
+ const numValue = Number(v);
+ onChange(isNaN(numValue) ? undefined : numValue);
+ }
+ }}
{...props}
/>
);
@@ -125,14 +131,32 @@ export function RunAgentInputs({
);
break;
- case DataType.DATE:
+ case DataType.DATE: {
+ function normalizeDateValue(val: any): Date | null {
+ if (!val) return null;
+ if (val instanceof Date) {
+ return isNaN(val.getTime()) ? null : val;
+ }
+ if (typeof val === "string") {
+ if (/^\d{4}-\d{2}-\d{2}$/.test(val)) {
+ const [y, m, d] = val.split("-").map(Number);
+ const date = new Date(y, m - 1, d);
+ return isNaN(date.getTime()) ? null : date;
+ }
+ const date = new Date(val);
+ return isNaN(date.getTime()) ? null : date;
+ }
+ return null;
+ }
+
+ const dateValue = normalizeDateValue(value);
innerInputElement = (
{
const v = (e.target as HTMLInputElement).value;
if (!v) onChange(undefined);
@@ -146,6 +170,7 @@ export function RunAgentInputs({
/>
);
break;
+ }
case DataType.TIME:
innerInputElement = (
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/RunAgentModal.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/RunAgentModal.tsx
index e53f31a349..aff06d79c5 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/RunAgentModal.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/RunAgentModal.tsx
@@ -12,8 +12,12 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
+import {
+ AIAgentSafetyPopup,
+ useAIAgentSafetyPopup,
+} from "./components/AIAgentSafetyPopup/AIAgentSafetyPopup";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
import { RunActions } from "./components/RunActions/RunActions";
@@ -82,6 +86,18 @@ export function RunAgentModal({
});
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
+ const [hasOverflow, setHasOverflow] = useState(false);
+ const [isSafetyPopupOpen, setIsSafetyPopupOpen] = useState(false);
+ const [pendingRunAction, setPendingRunAction] = useState<(() => void) | null>(
+ null,
+ );
+ const contentRef = useRef(null);
+
+ const { shouldShowPopup, dismissPopup } = useAIAgentSafetyPopup(
+ agent.id,
+ agent.has_sensitive_action,
+ agent.has_human_in_the_loop,
+ );
const hasAnySetupFields =
Object.keys(agentInputFields || {}).length > 0 ||
@@ -89,6 +105,43 @@ export function RunAgentModal({
const isTriggerRunType = defaultRunType.includes("trigger");
+ useEffect(() => {
+ if (!isOpen) return;
+
+ function checkOverflow() {
+ if (!contentRef.current) return;
+ const scrollableParent = contentRef.current
+ .closest("[data-dialog-content]")
+ ?.querySelector('[class*="overflow-y-auto"]');
+ if (scrollableParent) {
+ setHasOverflow(
+ scrollableParent.scrollHeight > scrollableParent.clientHeight,
+ );
+ }
+ }
+
+ const timeoutId = setTimeout(checkOverflow, 100);
+ const resizeObserver = new ResizeObserver(checkOverflow);
+ if (contentRef.current) {
+ const scrollableParent = contentRef.current
+ .closest("[data-dialog-content]")
+ ?.querySelector('[class*="overflow-y-auto"]');
+ if (scrollableParent) {
+ resizeObserver.observe(scrollableParent);
+ }
+ }
+
+ return () => {
+ clearTimeout(timeoutId);
+ resizeObserver.disconnect();
+ };
+ }, [
+ isOpen,
+ hasAnySetupFields,
+ agentInputFields,
+ agentCredentialsInputFields,
+ ]);
+
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
@@ -126,6 +179,24 @@ export function RunAgentModal({
onScheduleCreated?.(schedule);
}
+ function handleRunWithSafetyCheck() {
+ if (shouldShowPopup) {
+ setPendingRunAction(() => handleRun);
+ setIsSafetyPopupOpen(true);
+ } else {
+ handleRun();
+ }
+ }
+
+ function handleSafetyPopupAcknowledge() {
+ setIsSafetyPopupOpen(false);
+ dismissPopup();
+ if (pendingRunAction) {
+ pendingRunAction();
+ setPendingRunAction(null);
+ }
+ }
+
return (
<>
{triggerSlot}
- {/* Header */}
-
+
+
+ {/* Header */}
+
- {/* Content */}
- {hasAnySetupFields ? (
-
-
-
-
+ {/* Content */}
+ {hasAnySetupFields ? (
+
+
+
+
+
+ ) : null}
- ) : null}
-
-
- {isTriggerRunType ? null : !allRequiredInputsAreSet ? (
-
-
-
-
-
- Schedule Task
-
-
-
-
-
- Please set up all required inputs and credentials before
- scheduling
-
-
-
-
- ) : (
-
- Schedule Task
-
- )}
-
+
+ {isTriggerRunType ? null : !allRequiredInputsAreSet ? (
+
+
+
+
+
+ Schedule Task
+
+
+
+
+
+ Please set up all required inputs and credentials
+ before scheduling
+
+
+
+
+ ) : (
+
+ Schedule Task
+
+ )}
+
+
+
-
-
-
+
+
+
+
>
);
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/AIAgentSafetyPopup/AIAgentSafetyPopup.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/AIAgentSafetyPopup/AIAgentSafetyPopup.tsx
new file mode 100644
index 0000000000..f2d178b33d
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/AIAgentSafetyPopup/AIAgentSafetyPopup.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { Button } from "@/components/atoms/Button/Button";
+import { Text } from "@/components/atoms/Text/Text";
+import { Dialog } from "@/components/molecules/Dialog/Dialog";
+import { Key, storage } from "@/services/storage/local-storage";
+import { ShieldCheckIcon } from "@phosphor-icons/react";
+import { useCallback, useEffect, useState } from "react";
+
+interface Props {
+ agentId: string;
+ onAcknowledge: () => void;
+ isOpen: boolean;
+}
+
+export function AIAgentSafetyPopup({ agentId, onAcknowledge, isOpen }: Props) {
+ function handleAcknowledge() {
+ // Add this agent to the list of agents for which popup has been shown
+ const seenAgentsJson = storage.get(Key.AI_AGENT_SAFETY_POPUP_SHOWN);
+ const seenAgents: string[] = seenAgentsJson
+ ? JSON.parse(seenAgentsJson)
+ : [];
+
+ if (!seenAgents.includes(agentId)) {
+ seenAgents.push(agentId);
+ storage.set(Key.AI_AGENT_SAFETY_POPUP_SHOWN, JSON.stringify(seenAgents));
+ }
+
+ onAcknowledge();
+ }
+
+ if (!isOpen) return null;
+
+ return (
+
{} }}
+ styling={{ maxWidth: "480px" }}
+ >
+
+
+
+
+
+
+
+ Safety Checks Enabled
+
+
+
+ AI-generated agents may take actions that affect your data or
+ external systems.
+
+
+
+ AutoGPT includes safety checks so you'll always have the
+ opportunity to review and approve sensitive actions before they
+ happen.
+
+
+
+ Got it
+
+
+
+
+ );
+}
+
+export function useAIAgentSafetyPopup(
+ agentId: string,
+ hasSensitiveAction: boolean,
+ hasHumanInTheLoop: boolean,
+) {
+ const [shouldShowPopup, setShouldShowPopup] = useState(false);
+ const [hasChecked, setHasChecked] = useState(false);
+
+ useEffect(() => {
+ if (hasChecked) return;
+
+ const seenAgentsJson = storage.get(Key.AI_AGENT_SAFETY_POPUP_SHOWN);
+ const seenAgents: string[] = seenAgentsJson
+ ? JSON.parse(seenAgentsJson)
+ : [];
+ const hasSeenPopupForThisAgent = seenAgents.includes(agentId);
+ const isRelevantAgent = hasSensitiveAction || hasHumanInTheLoop;
+
+ setShouldShowPopup(!hasSeenPopupForThisAgent && isRelevantAgent);
+ setHasChecked(true);
+ }, [agentId, hasSensitiveAction, hasHumanInTheLoop, hasChecked]);
+
+ const dismissPopup = useCallback(() => {
+ setShouldShowPopup(false);
+ }, []);
+
+ return {
+ shouldShowPopup,
+ dismissPopup,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalHeader/ModalHeader.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalHeader/ModalHeader.tsx
index 2a31f62f82..b35ab87198 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalHeader/ModalHeader.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalHeader/ModalHeader.tsx
@@ -29,7 +29,7 @@ export function ModalHeader({ agent }: ModalHeaderProps) {
{agent.description}
@@ -40,6 +40,8 @@ export function ModalHeader({ agent }: ModalHeaderProps) {
Tip
+
+
For best results, run this agent{" "}
{humanizeCronExpression(
@@ -50,7 +52,7 @@ export function ModalHeader({ agent }: ModalHeaderProps) {
) : null}
{agent.instructions ? (
-
+
Instructions
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/ModalRunSection.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/ModalRunSection.tsx
index aba4caee7a..b3e0c17d74 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/ModalRunSection.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/components/ModalRunSection/ModalRunSection.tsx
@@ -1,6 +1,7 @@
-import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input";
+import { CredentialsGroupedView } from "@/components/contextual/CredentialsInput/components/CredentialsGroupedView/CredentialsGroupedView";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
+import { useMemo } from "react";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context";
import { ModalSection } from "../ModalSection/ModalSection";
@@ -17,15 +18,18 @@ export function ModalRunSection() {
inputValues,
setInputValue,
agentInputFields,
+ agentCredentialsInputFields,
inputCredentials,
setInputCredentialsValue,
- agentCredentialsInputFields,
} = useRunAgentModalContext();
const inputFields = Object.entries(agentInputFields || {});
- const credentialFields = Object.entries(agentCredentialsInputFields || {});
- // Get the list of required credentials from the schema
+ const credentialFields = useMemo(() => {
+ if (!agentCredentialsInputFields) return [];
+ return Object.entries(agentCredentialsInputFields);
+ }, [agentCredentialsInputFields]);
+
const requiredCredentials = new Set(
(agent.credentials_input_schema?.required as string[]) || [],
);
@@ -97,24 +101,13 @@ export function ModalRunSection() {
title="Task Credentials"
subtitle="These are the credentials the agent will use to perform this task"
>
-
- {Object.entries(agentCredentialsInputFields || {}).map(
- ([key, inputSubSchema]) => (
-
- setInputCredentialsValue(key, value)
- }
- siblingInputs={inputValues}
- isOptional={!requiredCredentials.has(key)}
- />
- ),
- )}
-
+
) : null}
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/useAgentRunModal.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/useAgentRunModal.tsx
index eb32083004..c867b11098 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/useAgentRunModal.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/RunAgentModal/useAgentRunModal.tsx
@@ -11,9 +11,18 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
+import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import { getSystemCredentials } from "../../../../../../../../../../components/contextual/CredentialsInput/helpers";
import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant =
@@ -42,8 +51,10 @@ export function useAgentRunModal(
const [inputCredentials, setInputCredentials] = useState
>(
callbacks?.initialInputCredentials || {},
);
+
const [presetName, setPresetName] = useState("");
const [presetDescription, setPresetDescription] = useState("");
+ const hasInitializedSystemCreds = useRef(false);
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.trigger_setup_info
@@ -58,6 +69,91 @@ export function useAgentRunModal(
setInputCredentials(callbacks?.initialInputCredentials || {});
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
+ const allProviders = useContext(CredentialsProvidersContext);
+
+ // Initialize credentials with default system credentials
+ useEffect(() => {
+ if (!allProviders || !agent.credentials_input_schema?.properties) return;
+ if (callbacks?.initialInputCredentials) {
+ hasInitializedSystemCreds.current = true;
+ return;
+ }
+ if (hasInitializedSystemCreds.current) return;
+
+ const properties = agent.credentials_input_schema.properties as Record<
+ string,
+ any
+ >;
+
+ setInputCredentials((currentCreds) => {
+ const credsToAdd: Record = {};
+
+ for (const [key, schema] of Object.entries(properties)) {
+ if (currentCreds[key]) continue;
+
+ const providerNames = schema.credentials_provider || [];
+ const supportedTypes = schema.credentials_types || [];
+ const requiredScopes = schema.credentials_scopes;
+
+ for (const providerName of providerNames) {
+ const providerData = allProviders[providerName];
+ if (!providerData) continue;
+
+ const systemCreds = getSystemCredentials(
+ providerData.savedCredentials ?? [],
+ );
+ const matchingSystemCreds = systemCreds.filter((cred) => {
+ if (!supportedTypes.includes(cred.type)) return false;
+
+ if (
+ cred.type === "oauth2" &&
+ requiredScopes &&
+ requiredScopes.length > 0
+ ) {
+ const grantedScopes = new Set(cred.scopes || []);
+ const hasAllRequiredScopes = requiredScopes.every(
+ (scope: string) => grantedScopes.has(scope),
+ );
+ if (!hasAllRequiredScopes) return false;
+ }
+
+ return true;
+ });
+
+ if (matchingSystemCreds.length === 1) {
+ const systemCred = matchingSystemCreds[0];
+ credsToAdd[key] = {
+ id: systemCred.id,
+ type: systemCred.type,
+ provider: providerName,
+ title: systemCred.title,
+ };
+ break;
+ }
+ }
+ }
+
+ if (Object.keys(credsToAdd).length > 0) {
+ hasInitializedSystemCreds.current = true;
+ return {
+ ...currentCreds,
+ ...credsToAdd,
+ };
+ }
+
+ return currentCreds;
+ });
+ }, [
+ allProviders,
+ agent.credentials_input_schema,
+ callbacks?.initialInputCredentials,
+ ]);
+
+ // Reset initialization flag when modal closes/opens or agent changes
+ useEffect(() => {
+ hasInitializedSystemCreds.current = false;
+ }, [isOpen, agent.graph_id]);
+
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
@@ -66,7 +162,6 @@ export function useAgentRunModal(
toast({
title: "Agent execution started",
});
- // Invalidate runs list for this graph
queryClient.invalidateQueries({
queryKey: getGetV1ListGraphExecutionsQueryKey(agent.graph_id),
});
@@ -163,14 +258,10 @@ export function useAgentRunModal(
}, [agentInputSchema.required, inputValues]);
const [allCredentialsAreSet, missingCredentials] = useMemo(() => {
- // Only check required credentials from schema, not all properties
- // Credentials marked as optional in node metadata won't be in the required array
const requiredCredentials = new Set(
(agent.credentials_input_schema?.required as string[]) || [],
);
- // Check if required credentials have valid id (not just key existence)
- // A credential is valid only if it has an id field set
const missing = [...requiredCredentials].filter((key) => {
const cred = inputCredentials[key];
return !cred || !cred.id;
@@ -184,7 +275,6 @@ export function useAgentRunModal(
[agentCredentialsInputFields],
);
- // Final readiness flag combining inputs + credentials when credentials are shown
const allRequiredInputsAreSet = useMemo(
() =>
allRequiredInputsAreSetRaw &&
@@ -223,7 +313,6 @@ export function useAgentRunModal(
defaultRunType === "automatic-trigger" ||
defaultRunType === "manual-trigger"
) {
- // Setup trigger
if (!presetName.trim()) {
toast({
title: "⚠️ Trigger name required",
@@ -244,9 +333,6 @@ export function useAgentRunModal(
},
});
} else {
- // Manual execution
- // Filter out incomplete credentials (optional ones not selected)
- // Only send credentials that have a valid id field
const validCredentials = Object.fromEntries(
Object.entries(inputCredentials).filter(([_, cred]) => cred && cred.id),
);
@@ -280,41 +366,24 @@ export function useAgentRunModal(
}, [agentInputFields]);
return {
- // UI state
isOpen,
setIsOpen,
-
- // Run mode
defaultRunType: defaultRunType as RunVariant,
-
- // Form: regular inputs
inputValues,
setInputValues,
-
- // Form: credentials
inputCredentials,
setInputCredentials,
-
- // Preset/trigger labels
presetName,
presetDescription,
setPresetName,
setPresetDescription,
-
- // Validation/readiness
allRequiredInputsAreSet,
missingInputs,
-
- // Schemas for rendering
agentInputFields,
agentCredentialsInputFields,
hasInputFields,
-
- // Async states
isExecuting: executeGraphMutation.isPending,
isSettingUpTrigger: setupTriggerMutation.isPending,
-
- // Actions
handleRun,
};
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton.tsx
index 11dcbd943f..95fdf826a2 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton.tsx
@@ -1,37 +1,17 @@
import { Button } from "@/components/atoms/Button/Button";
+import { Text } from "@/components/atoms/Text/Text";
import { GearIcon } from "@phosphor-icons/react";
-import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
-import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
-
-interface Props {
- agent: LibraryAgent;
- onSelectSettings: () => void;
- selected?: boolean;
-}
-
-export function AgentSettingsButton({
- agent,
- onSelectSettings,
- selected,
-}: Props) {
- const { hasHITLBlocks } = useAgentSafeMode(agent);
-
- if (!hasHITLBlocks) {
- return null;
- }
+export function AgentSettingsButton() {
return (
-
+
+ Agent Settings
);
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptySchedules.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptySchedules.tsx
index 97492d8a59..4c781b2896 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptySchedules.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptySchedules.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { Text } from "@/components/atoms/Text/Text";
export function EmptySchedules() {
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptyTemplates.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptyTemplates.tsx
index c33abe69ad..364b762167 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptyTemplates.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptyTemplates.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTemplates() {
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptyTriggers.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptyTriggers.tsx
index 0d9dc47fff..06d09ff9a0 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptyTriggers.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/EmptyTriggers.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTriggers() {
diff --git a/autogpt_platform/frontend/src/components/contextual/MarketplaceBanners/MarketplaceBanners.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/MarketplaceBanners.tsx
similarity index 97%
rename from autogpt_platform/frontend/src/components/contextual/MarketplaceBanners/MarketplaceBanners.tsx
rename to autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/MarketplaceBanners.tsx
index 4f826f6e85..00edcc721f 100644
--- a/autogpt_platform/frontend/src/components/contextual/MarketplaceBanners/MarketplaceBanners.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/MarketplaceBanners.tsx
@@ -3,7 +3,7 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
-interface MarketplaceBannersProps {
+interface Props {
hasUpdate?: boolean;
latestVersion?: number;
hasUnpublishedChanges?: boolean;
@@ -21,7 +21,7 @@ export function MarketplaceBanners({
isUpdating,
onUpdate,
onPublish,
-}: MarketplaceBannersProps) {
+}: Props) {
const renderUpdateBanner = () => {
if (hasUpdate && latestVersion) {
return (
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/SectionWrap.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/SectionWrap.tsx
index 75571dd856..f88d91bb0d 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/SectionWrap.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/SectionWrap.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { cn } from "@/lib/utils";
type Props = {
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/LoadingSelectedContent.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/LoadingSelectedContent.tsx
index dc2bb7cac2..bc5548afd0 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/LoadingSelectedContent.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/LoadingSelectedContent.tsx
@@ -1,22 +1,16 @@
+import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { cn } from "@/lib/utils";
-import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { SelectedViewLayout } from "./SelectedViewLayout";
interface Props {
agent: LibraryAgent;
- onSelectSettings?: () => void;
- selectedSettings?: boolean;
}
export function LoadingSelectedContent(props: Props) {
return (
-
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/SelectedRunView.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/SelectedRunView.tsx
index c66f0e9245..05da986583 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/SelectedRunView.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/SelectedRunView.tsx
@@ -33,8 +33,6 @@ interface Props {
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
- onSelectSettings?: () => void;
- selectedSettings?: boolean;
}
export function SelectedRunView({
@@ -43,8 +41,6 @@ export function SelectedRunView({
onSelectRun,
onClearSelectedRun,
banner,
- onSelectSettings,
- selectedSettings,
}: Props) {
const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId);
@@ -84,12 +80,7 @@ export function SelectedRunView({
return (
-
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/RunOutputs.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/RunOutputs.tsx
index 20e218abb2..9824283c40 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/RunOutputs.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/RunOutputs.tsx
@@ -3,12 +3,12 @@
import type {
OutputMetadata,
OutputRenderer,
-} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
+} from "@/components/contextual/OutputRenderers";
import {
globalRegistry,
OutputActions,
OutputItem,
-} from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/OutputRenderers";
+} from "@/components/contextual/OutputRenderers";
import React, { useMemo } from "react";
type OutputsRecord = Record>;
diff --git a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/SafeModeToggle.tsx b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/SafeModeToggle.tsx
index 9ba37d8d17..0fafa67414 100644
--- a/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/SafeModeToggle.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/selected-views/SelectedRunView/components/SafeModeToggle.tsx
@@ -5,48 +5,104 @@ import { Graph } from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { ShieldCheckIcon, ShieldIcon } from "@phosphor-icons/react";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/atoms/Tooltip/BaseTooltip";
interface Props {
graph: GraphModel | LibraryAgent | Graph;
className?: string;
- fullWidth?: boolean;
}
-export function SafeModeToggle({ graph }: Props) {
+interface SafeModeIconButtonProps {
+ isEnabled: boolean;
+ label: string;
+ tooltipEnabled: string;
+ tooltipDisabled: string;
+ onToggle: () => void;
+ isPending: boolean;
+}
+
+function SafeModeIconButton({
+ isEnabled,
+ label,
+ tooltipEnabled,
+ tooltipDisabled,
+ onToggle,
+ isPending,
+}: SafeModeIconButtonProps) {
+ return (
+
+
+