+ {/* 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/integrations/oauth_callback/route.ts b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/route.ts
index df1de26300..41d05a9afb 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,4 +1,4 @@
-import { OAuthPopupResultMessage } from "@/components/renderers/input-renderer/fields/CredentialField/models/OAuthCredentialModal/useOAuthCredentialModal";
+import { OAuthPopupResultMessage } from "./types";
import { NextResponse } from "next/server";
// This route is intended to be used as the callback for integration OAuth flows,
diff --git a/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/types.ts b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/types.ts
new file mode 100644
index 0000000000..9000adf392
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/auth/integrations/oauth_callback/types.ts
@@ -0,0 +1,11 @@
+export type OAuthPopupResultMessage = { message_type: "oauth_popup_result" } & (
+ | {
+ success: true;
+ code: string;
+ state: string;
+ }
+ | {
+ success: false;
+ message: string;
+ }
+);
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 237bea2ab0..cfea5d9452 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 {
@@ -16,16 +11,21 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/__legacy__/ui/sheet";
+import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
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";
-import { BuilderActionButton } from "../BuilderActionButton";
export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
const hasOutputs = useGraphStore(useShallow((state) => state.hasOutputs));
@@ -76,9 +76,14 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
-
-
-
+
+
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/BuilderActionButton.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/BuilderActionButton.tsx
deleted file mode 100644
index f8b3f1051e..0000000000
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/BuilderActionButton.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Button } from "@/components/atoms/Button/Button";
-import { ButtonProps } from "@/components/atoms/Button/helpers";
-import { cn } from "@/lib/utils";
-import { CircleNotchIcon } from "@phosphor-icons/react";
-
-export const BuilderActionButton = ({
- children,
- className,
- isLoading,
- ...props
-}: ButtonProps & { isLoading?: boolean }) => {
- return (
-
- {!isLoading ? (
- children
- ) : (
-
- )}
-
- );
-};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/PublishToMarketplace/PublishToMarketplace.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/PublishToMarketplace/PublishToMarketplace.tsx
index 1e6545dfbd..e7381b7d52 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/PublishToMarketplace/PublishToMarketplace.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/PublishToMarketplace/PublishToMarketplace.tsx
@@ -1,12 +1,12 @@
-import { ShareIcon } from "@phosphor-icons/react";
-import { BuilderActionButton } from "../BuilderActionButton";
+import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
-import { usePublishToMarketplace } from "./usePublishToMarketplace";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
+import { ShareIcon } from "@phosphor-icons/react";
+import { usePublishToMarketplace } from "./usePublishToMarketplace";
export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
const { handlePublishToMarketplace, publishState, handleStateChange } =
@@ -16,12 +16,14 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
<>
-
-
-
+
+
Publish to Marketplace
@@ -30,6 +32,7 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => {
targetState={publishState}
onStateChange={handleStateChange}
preSelectedAgentId={flowID || undefined}
+ showTrigger={false}
/>
>
);
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 f4c1a7331f..f381ccb93b 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
@@ -1,15 +1,14 @@
-import { useRunGraph } from "./useRunGraph";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
-import { useShallow } from "zustand/react/shallow";
-import { PlayIcon, StopIcon } from "@phosphor-icons/react";
-import { cn } from "@/lib/utils";
-import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
+import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
-import { BuilderActionButton } from "../BuilderActionButton";
+import { PlayIcon, StopIcon } from "@phosphor-icons/react";
+import { useShallow } from "zustand/react/shallow";
+import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
+import { useRunGraph } from "./useRunGraph";
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
const {
@@ -29,21 +28,20 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
<>
-
{!isGraphRunning ? (
-
+
) : (
-
+
)}
-
+
{isGraphRunning ? "Stop agent" : "Run agent"}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts
index b827cfdcb4..6980e95f11 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts
@@ -7,10 +7,11 @@ import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { GraphExecutionMeta } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/use-agent-runs";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { useShallow } from "zustand/react/shallow";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { useSaveGraph } from "@/app/(platform)/build/hooks/useSaveGraph";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { ApiError } from "@/lib/autogpt-server-api/helpers"; // Check if this exists
+import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
export const useRunGraph = () => {
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 2d9f51c8bf..df944da4f9 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
@@ -5,9 +5,11 @@ import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { Button } from "@/components/atoms/Button/Button";
import { ClockIcon, PlayIcon } from "@phosphor-icons/react";
import { Text } from "@/components/atoms/Text/Text";
-import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
+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";
export const RunInputDialog = ({
isOpen,
@@ -37,6 +39,21 @@ export const RunInputDialog = ({
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
-
+
handleCredentialChange(v.formData)}
@@ -66,6 +83,7 @@ export const RunInputDialog = ({
formContext={{
showHandles: false,
size: "large",
+ showOptionalToggle: false,
}}
/>
@@ -74,13 +92,13 @@ export const RunInputDialog = ({
{/* Inputs Section */}
{hasInputs() && (
-
+
Inputs
-
+
handleInputChange(v.formData)}
@@ -96,7 +114,10 @@ export const RunInputDialog = ({
)}
{/* Action Button */}
-
+
{purpose === "run" && (
{!isExecutingGraph && (
@@ -117,6 +139,7 @@ export const RunInputDialog = ({
size="large"
className="group h-fit min-w-0 gap-2"
onClick={() => 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 f0bb3b1c98..358fd3ae7e 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,18 @@
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/input-renderer/fields/CredentialField/helpers";
+import { isCredentialFieldSchema } from "@/components/renderers/InputRenderer/custom/CredentialField/helpers";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { useToast } from "@/components/molecules/Toast/use-toast";
+import { useReactFlow } from "@xyflow/react";
export const useRunInputDialog = ({
setIsOpen,
@@ -31,6 +35,7 @@ export const useRunInputDialog = ({
flowVersion: parseAsInteger,
});
const { toast } = useToast();
+ const { setViewport } = useReactFlow();
const { mutateAsync: executeGraph, isPending: isExecutingGraph } =
usePostV1ExecuteGraphAgent({
@@ -42,13 +47,75 @@ 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",
- });
},
},
});
@@ -66,7 +133,7 @@ export const useRunInputDialog = ({
if (isCredentialFieldSchema(fieldSchema)) {
dynamicUiSchema[fieldName] = {
...dynamicUiSchema[fieldName],
- "ui:field": "credentials",
+ "ui:field": "custom/credential_field",
};
}
});
@@ -76,12 +143,18 @@ export const useRunInputDialog = ({
}, [credentialsSchema]);
const handleManualRun = async () => {
+ // Filter out incomplete credentials (those without a valid id)
+ // RJSF auto-populates const values (provider, type) but not id field
+ const validCredentials = Object.fromEntries(
+ Object.entries(credentialValues).filter(([_, cred]) => cred && cred.id),
+ );
+
await executeGraph({
graphId: flowID ?? "",
graphVersion: flowVersion || null,
data: {
inputs: inputValues,
- credentials_inputs: credentialValues,
+ credentials_inputs: validCredentials,
source: "builder",
},
});
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 be588fa9e7..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
@@ -1,14 +1,14 @@
-import { ClockIcon } from "@phosphor-icons/react";
-import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
-import { useScheduleGraph } from "./useScheduleGraph";
+import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
+import { ClockIcon } from "@phosphor-icons/react";
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
-import { BuilderActionButton } from "../BuilderActionButton";
+import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
+import { useScheduleGraph } from "./useScheduleGraph";
export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
const {
@@ -23,12 +23,15 @@ export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
-
-
-
+
+
Schedule Graph
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx
new file mode 100644
index 0000000000..905d1d4680
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { Button } from "@/components/atoms/Button/Button";
+import { ClockCounterClockwiseIcon, XIcon } from "@phosphor-icons/react";
+import { cn } from "@/lib/utils";
+import { formatTimeAgo } from "@/lib/utils/time";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/atoms/Tooltip/BaseTooltip";
+import { useDraftRecoveryPopup } from "./useDraftRecoveryPopup";
+import { Text } from "@/components/atoms/Text/Text";
+import { AnimatePresence, motion } from "framer-motion";
+import { DraftDiff } from "@/lib/dexie/draft-utils";
+
+interface DraftRecoveryPopupProps {
+ isInitialLoadComplete: boolean;
+}
+
+function formatDiffSummary(diff: DraftDiff | null): string {
+ if (!diff) return "";
+
+ const parts: string[] = [];
+
+ // Node changes
+ const nodeChanges: string[] = [];
+ if (diff.nodes.added > 0) nodeChanges.push(`+${diff.nodes.added}`);
+ if (diff.nodes.removed > 0) nodeChanges.push(`-${diff.nodes.removed}`);
+ if (diff.nodes.modified > 0) nodeChanges.push(`~${diff.nodes.modified}`);
+
+ if (nodeChanges.length > 0) {
+ parts.push(
+ `${nodeChanges.join("/")} block${diff.nodes.added + diff.nodes.removed + diff.nodes.modified !== 1 ? "s" : ""}`,
+ );
+ }
+
+ // Edge changes
+ const edgeChanges: string[] = [];
+ if (diff.edges.added > 0) edgeChanges.push(`+${diff.edges.added}`);
+ if (diff.edges.removed > 0) edgeChanges.push(`-${diff.edges.removed}`);
+ if (diff.edges.modified > 0) edgeChanges.push(`~${diff.edges.modified}`);
+
+ if (edgeChanges.length > 0) {
+ parts.push(
+ `${edgeChanges.join("/")} connection${diff.edges.added + diff.edges.removed + diff.edges.modified !== 1 ? "s" : ""}`,
+ );
+ }
+
+ return parts.join(", ");
+}
+
+export function DraftRecoveryPopup({
+ isInitialLoadComplete,
+}: DraftRecoveryPopupProps) {
+ const {
+ isOpen,
+ popupRef,
+ nodeCount,
+ edgeCount,
+ diff,
+ savedAt,
+ onLoad,
+ onDiscard,
+ } = useDraftRecoveryPopup(isInitialLoadComplete);
+
+ const diffSummary = formatDiffSummary(diff);
+
+ return (
+
+ {isOpen && (
+
+
+
+
+
+
+
+
+ Unsaved changes found
+
+
+ {diffSummary ||
+ `${nodeCount} block${nodeCount !== 1 ? "s" : ""}, ${edgeCount} connection${edgeCount !== 1 ? "s" : ""}`}{" "}
+ • {formatTimeAgo(new Date(savedAt).toISOString())}
+
+
+
+
+
+
+
+
+ Restore changes
+
+
+ Restore changes
+
+
+
+
+
+ Discard changes
+
+
+ Discard changes
+
+
+
+
+ )}
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx
new file mode 100644
index 0000000000..7a77f7b4cc
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx
@@ -0,0 +1,63 @@
+import { useEffect, useRef } from "react";
+import { useDraftManager } from "../FlowEditor/Flow/useDraftManager";
+
+export const useDraftRecoveryPopup = (isInitialLoadComplete: boolean) => {
+ const popupRef = useRef(null);
+
+ const {
+ isRecoveryOpen: isOpen,
+ savedAt,
+ nodeCount,
+ edgeCount,
+ diff,
+ loadDraft: onLoad,
+ discardDraft: onDiscard,
+ } = useDraftManager(isInitialLoadComplete);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ popupRef.current &&
+ !popupRef.current.contains(event.target as Node)
+ ) {
+ onDiscard();
+ }
+ };
+
+ const timeoutId = setTimeout(() => {
+ document.addEventListener("mousedown", handleClickOutside);
+ }, 100);
+
+ return () => {
+ clearTimeout(timeoutId);
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, [isOpen, onDiscard]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ onDiscard();
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [isOpen, onDiscard]);
+ return {
+ popupRef,
+ isOpen,
+ nodeCount,
+ edgeCount,
+ diff,
+ savedAt,
+ onLoad,
+ onDiscard,
+ };
+};
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 c9cf5296c6..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
@@ -1,26 +1,27 @@
-import { ReactFlow, Background } from "@xyflow/react";
-import NewControlPanel from "../../NewControlPanel/NewControlPanel";
-import CustomEdge from "../edges/CustomEdge";
-import { useFlow } from "./useFlow";
-import { useShallow } from "zustand/react/shallow";
-import { useNodeStore } from "../../../stores/nodeStore";
-import { useMemo, useEffect, useCallback } from "react";
-import { CustomNode } from "../nodes/CustomNode/CustomNode";
-import { useCustomEdge } from "../edges/useCustomEdge";
-import { useFlowRealtime } from "./useFlowRealtime";
-import { GraphLoadingBox } from "./components/GraphLoadingBox";
-import { BuilderActions } from "../../BuilderActions/BuilderActions";
-import { RunningBackground } from "./components/RunningBackground";
-import { useGraphStore } from "../../../stores/graphStore";
-import { useCopyPaste } from "./useCopyPaste";
-import { FloatingReviewsPanel } from "@/components/organisms/FloatingReviewsPanel/FloatingReviewsPanel";
-import { parseAsString, useQueryStates } from "nuqs";
-import { CustomControls } from "./components/CustomControl";
import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/graphs/graphs";
import { okData } from "@/app/api/helpers";
+import { FloatingReviewsPanel } from "@/components/organisms/FloatingReviewsPanel/FloatingReviewsPanel";
+import { Background, ReactFlow } from "@xyflow/react";
+import { parseAsString, useQueryStates } from "nuqs";
+import { useCallback, useMemo } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { useGraphStore } from "../../../stores/graphStore";
+import { useNodeStore } from "../../../stores/nodeStore";
+import { BuilderActions } from "../../BuilderActions/BuilderActions";
+import { DraftRecoveryPopup } from "../../DraftRecoveryDialog/DraftRecoveryPopup";
+import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
+import NewControlPanel from "../../NewControlPanel/NewControlPanel";
+import CustomEdge from "../edges/CustomEdge";
+import { useCustomEdge } from "../edges/useCustomEdge";
+import { CustomNode } from "../nodes/CustomNode/CustomNode";
+import { CustomControls } from "./components/CustomControl";
+import { GraphLoadingBox } from "./components/GraphLoadingBox";
+import { RunningBackground } from "./components/RunningBackground";
import { TriggerAgentBanner } from "./components/TriggerAgentBanner";
import { resolveCollisions } from "./helpers/resolve-collision";
-import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
+import { useCopyPaste } from "./useCopyPaste";
+import { useFlow } from "./useFlow";
+import { useFlowRealtime } from "./useFlowRealtime";
export const Flow = () => {
const [{ flowID, flowExecutionID }] = useQueryStates({
@@ -41,48 +42,51 @@ export const Flow = () => {
const nodes = useNodeStore(useShallow((state) => state.nodes));
const setNodes = useNodeStore(useShallow((state) => state.setNodes));
+
const onNodesChange = useNodeStore(
useShallow((state) => state.onNodesChange),
);
+
const hasWebhookNodes = useNodeStore(
useShallow((state) => state.hasWebhookNodes()),
);
+
const nodeTypes = useMemo(() => ({ custom: CustomNode }), []);
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();
- // We use this hook to load the graph and convert them into custom nodes and edges.
- const { onDragOver, onDrop, isFlowContentLoading, isLocked, setIsLocked } =
- useFlow();
+ // for loading purpose
+ const {
+ onDragOver,
+ onDrop,
+ isFlowContentLoading,
+ isInitialLoadComplete,
+ isLocked,
+ setIsLocked,
+ } = useFlow();
// This hook is used for websocket realtime updates.
useFlowRealtime();
// Copy/paste functionality
- const handleCopyPaste = useCopyPaste();
+ useCopyPaste();
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- handleCopyPaste(event);
- };
-
- window.addEventListener("keydown", handleKeyDown);
- return () => {
- window.removeEventListener("keydown", handleKeyDown);
- };
- }, [handleCopyPaste]);
const isGraphRunning = useGraphStore(
useShallow((state) => state.isGraphRunning),
);
+
return (
@@ -95,6 +99,9 @@ export const Flow = () => {
onConnect={onConnect}
onEdgesChange={onEdgesChange}
onNodeDragStop={onNodeDragStop}
+ onNodeContextMenu={(event) => {
+ event.preventDefault();
+ }}
maxZoom={2}
minZoom={0.1}
onDragOver={onDragOver}
@@ -102,6 +109,7 @@ export const Flow = () => {
nodesDraggable={!isLocked}
nodesConnectable={!isLocked}
elementsSelectable={!isLocked}
+ deleteKeyCode={["Backspace", "Delete"]}
>
@@ -115,6 +123,7 @@ export const Flow = () => {
className="right-2 top-32 p-2"
/>
)}
+
{/* TODO: Need to update it in future - also do not send executionId as prop - rather use useQueryState inside the component */}
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..50e2034f75 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,27 +27,65 @@ 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 = [
{
+ id: "zoom-in-button",
icon:
,
label: "Zoom In",
onClick: () => zoomIn(),
className: "h-10 w-10 border-none",
},
{
+ id: "zoom-out-button",
icon:
,
label: "Zoom Out",
onClick: () => zoomOut(),
className: "h-10 w-10 border-none",
},
{
+ 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 ? (
) : (
@@ -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/helpers/resolve-collision.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts
index c05f00b5fb..890d1982c8 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts
@@ -48,8 +48,6 @@ export const resolveCollisions: CollisionAlgorithm = (
const width = (node.width ?? node.measured?.width ?? 0) + margin * 2;
const height = (node.height ?? node.measured?.height ?? 0) + margin * 2;
- console.log("width", width);
- console.log("height", height);
const x = node.position.x - margin;
const y = node.position.y - margin;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts
index 7a8213da22..c6c54006d4 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts
@@ -1,4 +1,4 @@
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
import { useReactFlow } from "@xyflow/react";
import { v4 as uuidv4 } from "uuid";
import { useNodeStore } from "../../../stores/nodeStore";
@@ -151,5 +151,16 @@ export function useCopyPaste() {
[getViewport, toast],
);
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ handleCopyPaste(event);
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [handleCopyPaste]);
+
return handleCopyPaste;
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts
new file mode 100644
index 0000000000..a38def74f6
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts
@@ -0,0 +1,319 @@
+import { useState, useCallback, useEffect, useRef } from "react";
+import { parseAsString, parseAsInteger, useQueryStates } from "nuqs";
+import {
+ draftService,
+ getTempFlowId,
+ getOrCreateTempFlowId,
+ DraftData,
+} from "@/services/builder-draft/draft-service";
+import { BuilderDraft } from "@/lib/dexie/db";
+import {
+ cleanNodes,
+ cleanEdges,
+ calculateDraftDiff,
+ DraftDiff,
+} from "@/lib/dexie/draft-utils";
+import { useNodeStore } from "../../../stores/nodeStore";
+import { useEdgeStore } from "../../../stores/edgeStore";
+import { useGraphStore } from "../../../stores/graphStore";
+import { useHistoryStore } from "../../../stores/historyStore";
+import isEqual from "lodash/isEqual";
+
+const AUTO_SAVE_INTERVAL_MS = 15000; // 15 seconds
+
+interface DraftRecoveryState {
+ isOpen: boolean;
+ draft: BuilderDraft | null;
+ diff: DraftDiff | null;
+}
+
+/**
+ * Consolidated hook for draft persistence and recovery
+ * - Auto-saves builder state every 15 seconds
+ * - Saves on beforeunload event
+ * - Checks for and manages unsaved drafts on load
+ */
+export function useDraftManager(isInitialLoadComplete: boolean) {
+ const [state, setState] = useState({
+ isOpen: false,
+ draft: null,
+ diff: null,
+ });
+
+ const [{ flowID, flowVersion }] = useQueryStates({
+ flowID: parseAsString,
+ flowVersion: parseAsInteger,
+ });
+
+ const lastSavedStateRef = useRef(null);
+ const saveTimeoutRef = useRef(null);
+ const isDirtyRef = useRef(false);
+ const hasCheckedForDraft = useRef(false);
+
+ const getEffectiveFlowId = useCallback((): string => {
+ return flowID || getOrCreateTempFlowId();
+ }, [flowID]);
+
+ const getCurrentState = useCallback((): DraftData => {
+ const nodes = useNodeStore.getState().nodes;
+ const edges = useEdgeStore.getState().edges;
+ const nodeCounter = useNodeStore.getState().nodeCounter;
+ const graphStore = useGraphStore.getState();
+
+ return {
+ nodes,
+ edges,
+ graphSchemas: {
+ input: graphStore.inputSchema,
+ credentials: graphStore.credentialsInputSchema,
+ output: graphStore.outputSchema,
+ },
+ nodeCounter,
+ flowVersion: flowVersion ?? undefined,
+ };
+ }, [flowVersion]);
+
+ const cleanStateForComparison = useCallback((stateData: DraftData) => {
+ return {
+ nodes: cleanNodes(stateData.nodes),
+ edges: cleanEdges(stateData.edges),
+ };
+ }, []);
+
+ const hasChanges = useCallback((): boolean => {
+ const currentState = getCurrentState();
+
+ if (!lastSavedStateRef.current) {
+ return currentState.nodes.length > 0;
+ }
+
+ const currentClean = cleanStateForComparison(currentState);
+ const lastClean = cleanStateForComparison(lastSavedStateRef.current);
+
+ return !isEqual(currentClean, lastClean);
+ }, [getCurrentState, cleanStateForComparison]);
+
+ const saveDraft = useCallback(async () => {
+ const effectiveFlowId = getEffectiveFlowId();
+ const currentState = getCurrentState();
+
+ if (currentState.nodes.length === 0 && currentState.edges.length === 0) {
+ return;
+ }
+
+ if (!hasChanges()) {
+ return;
+ }
+
+ try {
+ await draftService.saveDraft(effectiveFlowId, currentState);
+ lastSavedStateRef.current = currentState;
+ isDirtyRef.current = false;
+ } catch (error) {
+ console.error("[DraftPersistence] Failed to save draft:", error);
+ }
+ }, [getEffectiveFlowId, getCurrentState, hasChanges]);
+
+ const scheduleSave = useCallback(() => {
+ isDirtyRef.current = true;
+
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+
+ saveTimeoutRef.current = setTimeout(() => {
+ saveDraft();
+ }, AUTO_SAVE_INTERVAL_MS);
+ }, [saveDraft]);
+
+ useEffect(() => {
+ const unsubscribeNodes = useNodeStore.subscribe((storeState, prevState) => {
+ if (storeState.nodes !== prevState.nodes) {
+ scheduleSave();
+ }
+ });
+
+ const unsubscribeEdges = useEdgeStore.subscribe((storeState, prevState) => {
+ if (storeState.edges !== prevState.edges) {
+ scheduleSave();
+ }
+ });
+
+ return () => {
+ unsubscribeNodes();
+ unsubscribeEdges();
+ };
+ }, [scheduleSave]);
+
+ useEffect(() => {
+ const handleBeforeUnload = () => {
+ if (isDirtyRef.current) {
+ const effectiveFlowId = getEffectiveFlowId();
+ const currentState = getCurrentState();
+
+ if (
+ currentState.nodes.length === 0 &&
+ currentState.edges.length === 0
+ ) {
+ return;
+ }
+
+ draftService.saveDraft(effectiveFlowId, currentState).catch(() => {
+ // Ignore errors on unload
+ });
+ }
+ };
+
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => {
+ window.removeEventListener("beforeunload", handleBeforeUnload);
+ };
+ }, [getEffectiveFlowId, getCurrentState]);
+
+ useEffect(() => {
+ return () => {
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ }
+ if (isDirtyRef.current) {
+ saveDraft();
+ }
+ };
+ }, [saveDraft]);
+
+ useEffect(() => {
+ draftService.cleanupExpired().catch((error) => {
+ console.error(
+ "[DraftPersistence] Failed to cleanup expired drafts:",
+ error,
+ );
+ });
+ }, []);
+
+ const checkForDraft = useCallback(async () => {
+ const effectiveFlowId = flowID || getTempFlowId();
+
+ if (!effectiveFlowId) {
+ return;
+ }
+
+ try {
+ const draft = await draftService.loadDraft(effectiveFlowId);
+
+ if (!draft) {
+ return;
+ }
+
+ const currentNodes = useNodeStore.getState().nodes;
+ const currentEdges = useEdgeStore.getState().edges;
+
+ const isDifferent = draftService.isDraftDifferent(
+ draft,
+ currentNodes,
+ currentEdges,
+ );
+
+ if (isDifferent && (draft.nodes.length > 0 || draft.edges.length > 0)) {
+ const diff = calculateDraftDiff(
+ draft.nodes,
+ draft.edges,
+ currentNodes,
+ currentEdges,
+ );
+ setState({
+ isOpen: true,
+ draft,
+ diff,
+ });
+ } else {
+ await draftService.deleteDraft(effectiveFlowId);
+ }
+ } catch (error) {
+ console.error("[DraftRecovery] Failed to check for draft:", error);
+ }
+ }, [flowID]);
+
+ useEffect(() => {
+ if (isInitialLoadComplete && !hasCheckedForDraft.current) {
+ hasCheckedForDraft.current = true;
+ checkForDraft();
+ }
+ }, [isInitialLoadComplete, checkForDraft]);
+
+ useEffect(() => {
+ hasCheckedForDraft.current = false;
+ setState({
+ isOpen: false,
+ draft: null,
+ diff: null,
+ });
+ }, [flowID]);
+
+ const loadDraft = useCallback(async () => {
+ if (!state.draft) return;
+
+ const { draft } = state;
+
+ try {
+ useNodeStore.getState().setNodes(draft.nodes);
+ useEdgeStore.getState().setEdges(draft.edges);
+ draft.nodes.forEach((node) => {
+ useNodeStore.getState().syncHardcodedValuesWithHandleIds(node.id);
+ });
+
+ if (draft.nodeCounter !== undefined) {
+ useNodeStore.setState({ nodeCounter: draft.nodeCounter });
+ }
+
+ if (draft.graphSchemas) {
+ useGraphStore
+ .getState()
+ .setGraphSchemas(
+ draft.graphSchemas.input as Record | null,
+ draft.graphSchemas.credentials as Record | null,
+ draft.graphSchemas.output as Record | null,
+ );
+ }
+
+ setTimeout(() => {
+ useHistoryStore.getState().initializeHistory();
+ }, 100);
+
+ await draftService.deleteDraft(draft.id);
+
+ setState({
+ isOpen: false,
+ draft: null,
+ diff: null,
+ });
+ } catch (error) {
+ console.error("[DraftRecovery] Failed to load draft:", error);
+ }
+ }, [state.draft]);
+
+ const discardDraft = useCallback(async () => {
+ if (!state.draft) {
+ setState({ isOpen: false, draft: null, diff: null });
+ return;
+ }
+
+ try {
+ await draftService.deleteDraft(state.draft.id);
+ } catch (error) {
+ console.error("[DraftRecovery] Failed to discard draft:", error);
+ }
+
+ setState({ isOpen: false, draft: null, diff: null });
+ }, [state.draft]);
+
+ return {
+ // Recovery popup props
+ isRecoveryOpen: state.isOpen,
+ savedAt: state.draft?.savedAt ?? 0,
+ nodeCount: state.draft?.nodes.length ?? 0,
+ edgeCount: state.draft?.edges.length ?? 0,
+ diff: state.diff,
+ loadDraft,
+ discardDraft,
+ };
+}
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 be76c4ec2b..694c1be81b 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
@@ -3,6 +3,7 @@ import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/def
import {
useGetV1GetExecutionDetails,
useGetV1GetSpecificGraph,
+ useGetV1ListUserGraphs,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
@@ -17,10 +18,12 @@ import { useReactFlow } from "@xyflow/react";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { useHistoryStore } from "../../../stores/historyStore";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
+import { okData } from "@/app/api/helpers";
export const useFlow = () => {
const [isLocked, setIsLocked] = useState(false);
const [hasAutoFramed, setHasAutoFramed] = useState(false);
+ const [isInitialLoadComplete, setIsInitialLoadComplete] = useState(false);
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
const updateNodeStatus = useNodeStore(
@@ -35,6 +38,9 @@ export const useFlow = () => {
const setGraphExecutionStatus = useGraphStore(
useShallow((state) => state.setGraphExecutionStatus),
);
+ const setAvailableSubGraphs = useGraphStore(
+ useShallow((state) => state.setAvailableSubGraphs),
+ );
const updateEdgeBeads = useEdgeStore(
useShallow((state) => state.updateEdgeBeads),
);
@@ -61,6 +67,11 @@ export const useFlow = () => {
},
);
+ // Fetch all available graphs for sub-agent update detection
+ const { data: availableGraphs } = useGetV1ListUserGraphs({
+ query: { select: okData },
+ });
+
const { data: graph, isLoading: isGraphLoading } = useGetV1GetSpecificGraph(
flowID ?? "",
flowVersion !== null ? { version: flowVersion } : {},
@@ -115,11 +126,27 @@ export const useFlow = () => {
}
}, [graph]);
+ // Update available sub-graphs in store for sub-agent update detection
+ useEffect(() => {
+ if (availableGraphs) {
+ setAvailableSubGraphs(availableGraphs);
+ }
+ }, [availableGraphs, setAvailableSubGraphs]);
+
// adding nodes
useEffect(() => {
if (customNodes.length > 0) {
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]);
@@ -174,14 +201,27 @@ export const useFlow = () => {
if (customNodes.length > 0 && graph?.links) {
const timer = setTimeout(() => {
useHistoryStore.getState().initializeHistory();
+ // Mark initial load as complete after history is initialized
+ setIsInitialLoadComplete(true);
}, 100);
return () => clearTimeout(timer);
}
}, [customNodes, graph?.links]);
+ // Also mark as complete for new flows (no flowID) after a short delay
+ useEffect(() => {
+ if (!flowID && !isGraphLoading && !isBlocksLoading) {
+ const timer = setTimeout(() => {
+ setIsInitialLoadComplete(true);
+ }, 200);
+ return () => clearTimeout(timer);
+ }
+ }, [flowID, isGraphLoading, isBlocksLoading]);
+
useEffect(() => {
return () => {
useNodeStore.getState().setNodes([]);
+ useNodeStore.getState().clearResolutionState();
useEdgeStore.getState().setEdges([]);
useGraphStore.getState().reset();
useEdgeStore.getState().resetEdgeBeads();
@@ -217,6 +257,7 @@ export const useFlow = () => {
useEffect(() => {
setHasAutoFramed(false);
+ setIsInitialLoadComplete(false);
}, [flowID, flowVersion]);
// Drag and drop block from block menu
@@ -253,6 +294,7 @@ export const useFlow = () => {
return {
isFlowContentLoading: isGraphLoading || isBlocksLoading,
+ isInitialLoadComplete,
onDragOver,
onDrop,
isLocked,
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 ff80fdc8ac..eb221b5d34 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
@@ -8,6 +8,7 @@ import {
getBezierPath,
} from "@xyflow/react";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { XIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
@@ -35,6 +36,8 @@ 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);
const [edgePath, labelX, labelY] = getBezierPath({
@@ -50,6 +53,12 @@ const CustomEdge = ({
const beadUp = data?.beadUp ?? 0;
const beadDown = data?.beadDown ?? 0;
+ const handleRemoveEdge = () => {
+ removeConnection(id);
+ // Note: broken edge tracking is cleaned up automatically by useSubAgentUpdateState
+ // when it detects the edge no longer exists
+ };
+
return (
<>
removeConnection(id)}
+ onClick={handleRemoveEdge}
className={cn(
"absolute h-fit min-w-0 p-1 transition-opacity",
- isHovered ? "opacity-100" : "opacity-0",
+ isBroken
+ ? "bg-red-500 opacity-100 hover:bg-red-600"
+ : isHovered
+ ? "opacity-100"
+ : "opacity-0",
)}
- variant="secondary"
+ variant={isBroken ? "primary" : "secondary"}
style={{
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: "all",
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts
index 8d27f346ef..d8571749d3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/useCustomEdge.ts
@@ -1,12 +1,18 @@
-import { Connection as RFConnection, EdgeChange } from "@xyflow/react";
+import {
+ Connection as RFConnection,
+ EdgeChange,
+ applyEdgeChanges,
+} from "@xyflow/react";
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { useCallback } from "react";
import { useNodeStore } from "../../../stores/nodeStore";
+import { useHistoryStore } from "../../../stores/historyStore";
+import { CustomEdge } from "./CustomEdge";
export const useCustomEdge = () => {
const edges = useEdgeStore((s) => s.edges);
const addEdge = useEdgeStore((s) => s.addEdge);
- const removeEdge = useEdgeStore((s) => s.removeEdge);
+ const setEdges = useEdgeStore((s) => s.setEdges);
const onConnect = useCallback(
(conn: RFConnection) => {
@@ -45,14 +51,23 @@ export const useCustomEdge = () => {
);
const onEdgesChange = useCallback(
- (changes: EdgeChange[]) => {
- changes.forEach((change) => {
- if (change.type === "remove") {
- removeEdge(change.id);
- }
- });
+ (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);
+ }
},
- [removeEdge],
+ [edges, setEdges],
);
return { edges, onConnect, onEdgesChange };
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 4eb2437b65..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
@@ -1,31 +1,83 @@
import { CircleIcon } from "@phosphor-icons/react";
import { Handle, Position } from "@xyflow/react";
+import { useEdgeStore } from "../../../stores/edgeStore";
+import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
+import { cn } from "@/lib/utils";
+import { useNodeStore } from "../../../stores/nodeStore";
-const NodeHandle = ({
+const InputNodeHandle = ({
handleId,
- isConnected,
- side,
+ nodeId,
}: {
handleId: string;
- isConnected: boolean;
- side: "left" | "right";
+ nodeId: string;
}) => {
+ const cleanedHandleId = cleanUpHandleId(handleId);
+ const isInputConnected = useEdgeStore((state) =>
+ state.isInputConnected(nodeId ?? "", cleanedHandleId),
+ );
+ const isInputBroken = useNodeStore((state) =>
+ state.isInputBroken(nodeId, cleanedHandleId),
+ );
+
return (
);
};
-export default NodeHandle;
+const OutputNodeHandle = ({
+ field_name,
+ nodeId,
+ hexColor,
+ isBroken,
+}: {
+ field_name: string;
+ nodeId: string;
+ hexColor: string;
+ isBroken: boolean;
+}) => {
+ const isOutputConnected = useEdgeStore((state) =>
+ state.isOutputConnected(nodeId, field_name),
+ );
+
+ return (
+
+
+
+
+
+ );
+};
+
+export { InputNodeHandle, OutputNodeHandle };
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/helpers.ts
index ecacc83146..afaa85a38a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/helpers.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/handlers/helpers.ts
@@ -1,31 +1,4 @@
-/**
- * Handle ID Types for different input structures
- *
- * Examples:
- * SIMPLE: "message"
- * NESTED: "config.api_key"
- * ARRAY: "items_$_0", "items_$_1"
- * KEY_VALUE: "headers_#_Authorization", "params_#_limit"
- *
- * Note: All handle IDs are sanitized to remove spaces and special characters.
- * Spaces become underscores, and special characters are removed.
- * Example: "user name" becomes "user_name", "email@domain.com" becomes "emaildomaincom"
- */
-export enum HandleIdType {
- SIMPLE = "SIMPLE",
- NESTED = "NESTED",
- ARRAY = "ARRAY",
- KEY_VALUE = "KEY_VALUE",
-}
-
-const fromRjsfId = (id: string): string => {
- if (!id) return "";
- const parts = id.split("_");
- const filtered = parts.filter(
- (p) => p !== "root" && p !== "properties" && p.length > 0,
- );
- return filtered.join("_") || "";
-};
+// Here we are handling single level of nesting, if need more in future then i will update it
const sanitizeForHandleId = (str: string): string => {
if (!str) return "";
@@ -38,51 +11,53 @@ const sanitizeForHandleId = (str: string): string => {
.replace(/^_|_$/g, ""); // Remove leading/trailing underscores
};
-export const generateHandleId = (
+const cleanTitleId = (id: string): string => {
+ if (!id) return "";
+
+ if (id.endsWith("_title")) {
+ id = id.slice(0, -6);
+ }
+ const parts = id.split("_");
+ const filtered = parts.filter(
+ (p) => p !== "root" && p !== "properties" && p.length > 0,
+ );
+ const filtered_id = filtered.join("_") || "";
+ return filtered_id;
+};
+
+export const generateHandleIdFromTitleId = (
fieldKey: string,
- nestedValues: string[] = [],
- type: HandleIdType = HandleIdType.SIMPLE,
+ {
+ isObjectProperty,
+ isAdditionalProperty,
+ isArrayItem,
+ }: {
+ isArrayItem?: boolean;
+ isObjectProperty?: boolean;
+ isAdditionalProperty?: boolean;
+ } = {
+ isArrayItem: false,
+ isObjectProperty: false,
+ isAdditionalProperty: false,
+ },
): string => {
if (!fieldKey) return "";
- fieldKey = fromRjsfId(fieldKey);
- fieldKey = sanitizeForHandleId(fieldKey);
+ const filteredKey = cleanTitleId(fieldKey);
+ if (isAdditionalProperty || isArrayItem) {
+ return filteredKey;
+ }
+ const cleanedKey = sanitizeForHandleId(filteredKey);
- if (type === HandleIdType.SIMPLE || nestedValues.length === 0) {
- return fieldKey;
+ if (isObjectProperty) {
+ // "config_api_key" -> "config.api_key"
+ const parts = cleanedKey.split("_");
+ if (parts.length >= 2) {
+ const baseName = parts[0];
+ const propertyName = parts.slice(1).join("_");
+ return `${baseName}.${propertyName}`;
+ }
}
- const sanitizedNestedValues = nestedValues.map((value) =>
- sanitizeForHandleId(value),
- );
-
- switch (type) {
- case HandleIdType.NESTED:
- return [fieldKey, ...sanitizedNestedValues].join(".");
-
- case HandleIdType.ARRAY:
- return [fieldKey, ...sanitizedNestedValues].join("_$_");
-
- case HandleIdType.KEY_VALUE:
- return [fieldKey, ...sanitizedNestedValues].join("_#_");
-
- default:
- return fieldKey;
- }
-};
-
-export const parseKeyValueHandleId = (
- handleId: string,
- type: HandleIdType,
-): string => {
- if (type === HandleIdType.KEY_VALUE) {
- return handleId.split("_#_")[1];
- } else if (type === HandleIdType.ARRAY) {
- return handleId.split("_$_")[1];
- } else if (type === HandleIdType.NESTED) {
- return handleId.split(".")[1];
- } else if (type === HandleIdType.SIMPLE) {
- return handleId.split("_")[1];
- }
- return "";
+ return cleanedKey;
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx
index 52068f3acb..6306582c3b 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode.tsx
@@ -1,24 +1,27 @@
-import React from "react";
-import { Node as XYNode, NodeProps } from "@xyflow/react";
-import { RJSFSchema } from "@rjsf/utils";
-import { BlockUIType } from "../../../types";
-import { StickyNoteBlock } from "./components/StickyNoteBlock";
-import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
-import { BlockCost } from "@/app/api/__generated__/models/blockCost";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
+import { BlockCost } from "@/app/api/__generated__/models/blockCost";
+import { BlockInfoCategoriesItem } from "@/app/api/__generated__/models/blockInfoCategoriesItem";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
-import { NodeContainer } from "./components/NodeContainer";
-import { NodeHeader } from "./components/NodeHeader";
-import { FormCreator } from "../FormCreator";
-import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
-import { OutputHandler } from "../OutputHandler";
-import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
-import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
-import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
-import { cn } from "@/lib/utils";
-import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
-import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
import { NodeModelMetadata } from "@/app/api/__generated__/models/nodeModelMetadata";
+import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
+import { cn } from "@/lib/utils";
+import { RJSFSchema } from "@rjsf/utils";
+import { NodeProps, Node as XYNode } from "@xyflow/react";
+import React from "react";
+import { BlockUIType } from "../../../types";
+import { FormCreator } from "../FormCreator";
+import { OutputHandler } from "../OutputHandler";
+import { AyrshareConnectButton } from "./components/AyrshareConnectButton";
+import { NodeAdvancedToggle } from "./components/NodeAdvancedToggle";
+import { NodeContainer } from "./components/NodeContainer";
+import { NodeExecutionBadge } from "./components/NodeExecutionBadge";
+import { NodeHeader } from "./components/NodeHeader";
+import { NodeDataRenderer } from "./components/NodeOutput/NodeOutput";
+import { NodeRightClickMenu } from "./components/NodeRightClickMenu";
+import { StickyNoteBlock } from "./components/StickyNoteBlock";
+import { WebhookDisclaimer } from "./components/WebhookDisclaimer";
+import { SubAgentUpdateFeature } from "./components/SubAgentUpdate/SubAgentUpdateFeature";
+import { useCustomNode } from "./useCustomNode";
export type CustomNodeData = {
hardcodedValues: {
@@ -44,6 +47,10 @@ export type CustomNode = XYNode;
export const CustomNode: React.FC> = React.memo(
({ data, id: nodeId, selected }) => {
+ const { inputSchema, outputSchema } = useCustomNode({ data, nodeId });
+
+ const isAgent = data.uiType === BlockUIType.AGENT;
+
if (data.uiType === BlockUIType.NOTE) {
return (
@@ -62,16 +69,6 @@ export const CustomNode: React.FC> = React.memo(
const isAyrshare = data.uiType === BlockUIType.AYRSHARE;
- const inputSchema =
- data.uiType === BlockUIType.AGENT
- ? (data.hardcodedValues.input_schema ?? {})
- : data.inputSchema;
-
- const outputSchema =
- data.uiType === BlockUIType.AGENT
- ? (data.hardcodedValues.output_schema ?? {})
- : data.outputSchema;
-
const hasConfigErrors =
data.errors &&
Object.values(data.errors).some(
@@ -86,12 +83,11 @@ export const CustomNode: React.FC> = React.memo(
const hasErrors = hasConfigErrors || hasOutputError;
- // Currently all blockTypes design are similar - that's why i am using the same component for all of them
- // If in future - if we need some drastic change in some blockTypes design - we can create separate components for them
- return (
+ const node = (
+ {isAgent &&
}
{isWebhook &&
}
{isAyrshare &&
}
> = React.memo(
nodeId={nodeId}
uiType={data.uiType}
className={cn(
- "bg-white pr-6",
+ "bg-white px-4",
isWebhook && "pointer-events-none opacity-50",
)}
showHandles={showHandles}
@@ -117,6 +113,15 @@ export const CustomNode: React.FC> = React.memo(
);
+
+ return (
+
+ {node}
+
+ );
},
);
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle.tsx
index 4903f2e020..950db1657f 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle.tsx
@@ -8,7 +8,7 @@ export const NodeAdvancedToggle = ({ nodeId }: { nodeId: string }) => {
);
const setShowAdvanced = useNodeStore((state) => state.setShowAdvanced);
return (
-
+
Advanced
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 f8d5b2e089..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
@@ -22,11 +22,12 @@ export const NodeContainer = ({
return (
{children}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContextMenu.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContextMenu.tsx
index 6e482122f6..1a0e23fead 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContextMenu.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContextMenu.tsx
@@ -1,26 +1,31 @@
-import { Separator } from "@/components/__legacy__/ui/separator";
+import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import {
DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
-import { DotsThreeOutlineVerticalIcon } from "@phosphor-icons/react";
-import { Copy, Trash2, ExternalLink } from "lucide-react";
-import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
-import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
+import {
+ SecondaryDropdownMenuContent,
+ SecondaryDropdownMenuItem,
+ SecondaryDropdownMenuSeparator,
+} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
+import {
+ ArrowSquareOutIcon,
+ CopyIcon,
+ DotsThreeOutlineVerticalIcon,
+ TrashIcon,
+} from "@phosphor-icons/react";
import { useReactFlow } from "@xyflow/react";
-export const NodeContextMenu = ({
- nodeId,
- subGraphID,
-}: {
+type Props = {
nodeId: string;
subGraphID?: string;
-}) => {
+};
+
+export const NodeContextMenu = ({ nodeId, subGraphID }: Props) => {
const { deleteElements } = useReactFlow();
- const handleCopy = () => {
+ function handleCopy() {
useNodeStore.setState((state) => ({
nodes: state.nodes.map((node) => ({
...node,
@@ -30,47 +35,47 @@ export const NodeContextMenu = ({
useCopyPasteStore.getState().copySelectedNodes();
useCopyPasteStore.getState().pasteNodes();
- };
+ }
- const handleDelete = () => {
+ function handleDelete() {
deleteElements({ nodes: [{ id: nodeId }] });
- };
+ }
return (
-
-
-
- Copy Node
-
+
+
+
+ Copy
+
+
{subGraphID && (
- window.open(`/build?flowID=${subGraphID}`)}
- className="hover:rounded-xlarge"
- >
-
- Open Agent
-
+ <>
+ window.open(`/build?flowID=${subGraphID}`)}
+ >
+
+ Open agent
+
+
+ >
)}
-
-
-
-
- Delete
-
-
+
+
+ Delete
+
+
);
};
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 42d14a81a0..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
@@ -1,27 +1,30 @@
-import { Text } from "@/components/atoms/Text/Text";
-import { beautifyString, cn } from "@/lib/utils";
-import { NodeCost } from "./NodeCost";
-import { NodeBadges } from "./NodeBadges";
-import { NodeContextMenu } from "./NodeContextMenu";
-import { CustomNodeData } from "../CustomNode";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
-import { useState } from "react";
+import { Text } from "@/components/atoms/Text/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
+import { beautifyString, cn } from "@/lib/utils";
+import { useState } from "react";
+import { CustomNodeData } from "../CustomNode";
+import { NodeBadges } from "./NodeBadges";
+import { NodeContextMenu } from "./NodeContextMenu";
+import { NodeCost } from "./NodeCost";
-export const NodeHeader = ({
- data,
- nodeId,
-}: {
+type Props = {
data: CustomNodeData;
nodeId: string;
-}) => {
+};
+
+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(title);
@@ -41,7 +44,7 @@ export const NodeHeader = ({
};
return (
-
+
{/* Title row with context menu */}
@@ -67,13 +70,16 @@ export const NodeHeader = ({
-
- {beautifyString(title)}
+
+ {beautifyString(title).replace("Block", "").trim()}
- {beautifyString(title)}
+ {beautifyString(title).replace("Block", "").trim()}
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 a4b53e3ac3..7189ab9ca7 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
@@ -23,7 +23,10 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
}
return (
-
+
Node Output
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 c505282e7b..0858db8f0e 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 {
@@ -151,7 +151,7 @@ export const NodeDataViewer: FC = ({
- {outputItems.length > 0 && (
+ {outputItems.length > 1 && (
({
value: item.value,
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..d3c555970c 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,6 +1,6 @@
-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 type { OutputMetadata } from "@/components/contextual/OutputRenderers";
+import { globalRegistry } from "@/components/contextual/OutputRenderers";
+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";
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeRightClickMenu.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeRightClickMenu.tsx
new file mode 100644
index 0000000000..a56e42544f
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeRightClickMenu.tsx
@@ -0,0 +1,104 @@
+import { useCopyPasteStore } from "@/app/(platform)/build/stores/copyPasteStore";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import {
+ SecondaryMenuContent,
+ SecondaryMenuItem,
+ SecondaryMenuSeparator,
+} from "@/components/molecules/SecondaryMenu/SecondaryMenu";
+import { ArrowSquareOutIcon, CopyIcon, TrashIcon } from "@phosphor-icons/react";
+import * as ContextMenu from "@radix-ui/react-context-menu";
+import { useReactFlow } from "@xyflow/react";
+import { useEffect, useRef } from "react";
+import { CustomNode } from "../CustomNode";
+
+type Props = {
+ nodeId: string;
+ subGraphID?: string;
+ children: React.ReactNode;
+};
+
+const DOUBLE_CLICK_TIMEOUT = 300;
+
+export function NodeRightClickMenu({ nodeId, subGraphID, children }: Props) {
+ const { deleteElements } = useReactFlow();
+ const lastRightClickTime = useRef(0);
+ const containerRef = useRef(null);
+
+ function copyNode() {
+ useNodeStore.setState((state) => ({
+ nodes: state.nodes.map((node) => ({
+ ...node,
+ selected: node.id === nodeId,
+ })),
+ }));
+
+ useCopyPasteStore.getState().copySelectedNodes();
+ useCopyPasteStore.getState().pasteNodes();
+ }
+
+ function deleteNode() {
+ deleteElements({ nodes: [{ id: nodeId }] });
+ }
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ function handleContextMenu(e: MouseEvent) {
+ const now = Date.now();
+ const timeSinceLastClick = now - lastRightClickTime.current;
+
+ if (timeSinceLastClick < DOUBLE_CLICK_TIMEOUT) {
+ e.stopImmediatePropagation();
+ lastRightClickTime.current = 0;
+ return;
+ }
+
+ lastRightClickTime.current = now;
+ }
+
+ container.addEventListener("contextmenu", handleContextMenu, true);
+
+ return () => {
+ container.removeEventListener("contextmenu", handleContextMenu, true);
+ };
+ }, []);
+
+ return (
+
+
+ {children}
+
+
+
+
+ Copy
+
+
+
+ {subGraphID && (
+ <>
+ window.open(`/build?flowID=${subGraphID}`)}
+ >
+
+ Open agent
+
+
+ >
+ )}
+
+
+
+ Delete
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock.tsx
index 5d57c6c5b6..f900b1633f 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock.tsx
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { FormCreator } from "../../FormCreator";
-import { preprocessInputSchema } from "@/components/renderers/input-renderer/utils/input-schema-pre-processor";
+import { preprocessInputSchema } from "@/components/renderers/InputRenderer/utils/input-schema-pre-processor";
import { CustomNodeData } from "../CustomNode";
import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/SubAgentUpdateFeature.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/SubAgentUpdateFeature.tsx
new file mode 100644
index 0000000000..388349b2e0
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/SubAgentUpdateFeature.tsx
@@ -0,0 +1,118 @@
+import React from "react";
+import { ArrowUpIcon, WarningIcon } from "@phosphor-icons/react";
+import { Button } from "@/components/atoms/Button/Button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/atoms/Tooltip/BaseTooltip";
+import { cn, beautifyString } from "@/lib/utils";
+import { CustomNodeData } from "../../CustomNode";
+import { useSubAgentUpdateState } from "./useSubAgentUpdateState";
+import { IncompatibleUpdateDialog } from "./components/IncompatibleUpdateDialog";
+import { ResolutionModeBar } from "./components/ResolutionModeBar";
+
+/**
+ * Inline component for the update bar that can be placed after the header.
+ * Use this inside the node content where you want the bar to appear.
+ */
+type SubAgentUpdateFeatureProps = {
+ nodeID: string;
+ nodeData: CustomNodeData;
+};
+
+export function SubAgentUpdateFeature({
+ nodeID,
+ nodeData,
+}: SubAgentUpdateFeatureProps) {
+ const {
+ updateInfo,
+ isInResolutionMode,
+ handleUpdateClick,
+ showIncompatibilityDialog,
+ setShowIncompatibilityDialog,
+ handleConfirmIncompatibleUpdate,
+ } = useSubAgentUpdateState({ nodeID: nodeID, nodeData: nodeData });
+
+ const agentName = nodeData.title || "Agent";
+
+ if (!updateInfo.hasUpdate && !isInResolutionMode) {
+ return null;
+ }
+
+ return (
+ <>
+ {isInResolutionMode ? (
+
+ ) : (
+
+ )}
+ {/* Incompatibility dialog - rendered here since this component owns the state */}
+ {updateInfo.incompatibilities && (
+ setShowIncompatibilityDialog(false)}
+ onConfirm={handleConfirmIncompatibleUpdate}
+ currentVersion={updateInfo.currentVersion}
+ latestVersion={updateInfo.latestVersion}
+ agentName={beautifyString(agentName)}
+ incompatibilities={updateInfo.incompatibilities}
+ />
+ )}
+ >
+ );
+}
+
+type SubAgentUpdateAvailableBarProps = {
+ currentVersion: number;
+ latestVersion: number;
+ isCompatible: boolean;
+ onUpdate: () => void;
+};
+
+function SubAgentUpdateAvailableBar({
+ currentVersion,
+ latestVersion,
+ isCompatible,
+ onUpdate,
+}: SubAgentUpdateAvailableBarProps): React.ReactElement {
+ return (
+
+
+
+
+ Update available (v{currentVersion} → v{latestVersion})
+
+ {!isCompatible && (
+
+
+
+
+
+ Incompatible changes detected
+
+ Click Update to see details
+
+
+
+ )}
+
+
+ Update
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/IncompatibleUpdateDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/IncompatibleUpdateDialog.tsx
new file mode 100644
index 0000000000..67f20789ed
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/IncompatibleUpdateDialog.tsx
@@ -0,0 +1,274 @@
+import React from "react";
+import {
+ WarningIcon,
+ XCircleIcon,
+ PlusCircleIcon,
+} from "@phosphor-icons/react";
+import { Button } from "@/components/atoms/Button/Button";
+import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
+import { Dialog } from "@/components/molecules/Dialog/Dialog";
+import { beautifyString } from "@/lib/utils";
+import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate/types";
+
+type IncompatibleUpdateDialogProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ currentVersion: number;
+ latestVersion: number;
+ agentName: string;
+ incompatibilities: IncompatibilityInfo;
+};
+
+export function IncompatibleUpdateDialog({
+ isOpen,
+ onClose,
+ onConfirm,
+ currentVersion,
+ latestVersion,
+ agentName,
+ incompatibilities,
+}: IncompatibleUpdateDialogProps) {
+ const hasMissingInputs = incompatibilities.missingInputs.length > 0;
+ const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
+ const hasNewInputs = incompatibilities.newInputs.length > 0;
+ const hasNewOutputs = incompatibilities.newOutputs.length > 0;
+ const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
+ const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
+
+ const hasInputChanges = hasMissingInputs || hasNewInputs;
+ const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
+
+ return (
+
+
+ Incompatible Update
+
+ }
+ controlled={{
+ isOpen,
+ set: async (open) => {
+ if (!open) onClose();
+ },
+ }}
+ onClose={onClose}
+ styling={{ maxWidth: "32rem" }}
+ >
+
+
+
+ Updating {beautifyString(agentName)} from v
+ {currentVersion} to v{latestVersion} will break some connections.
+
+
+ {/* Input changes - two column layout */}
+ {hasInputChanges && (
+
+ }
+ leftTitle="Removed"
+ leftItems={incompatibilities.missingInputs}
+ rightIcon={
+
+ }
+ rightTitle="Added"
+ rightItems={incompatibilities.newInputs}
+ />
+ )}
+
+ {/* Output changes - two column layout */}
+ {hasOutputChanges && (
+
+ }
+ leftTitle="Removed"
+ leftItems={incompatibilities.missingOutputs}
+ rightIcon={
+
+ }
+ rightTitle="Added"
+ rightItems={incompatibilities.newOutputs}
+ />
+ )}
+
+ {hasTypeMismatches && (
+
+ }
+ title="Type Changed"
+ description="These connected inputs have a different type:"
+ items={incompatibilities.inputTypeMismatches.map(
+ (m) => `${m.name} (${m.oldType} → ${m.newType})`,
+ )}
+ />
+ )}
+
+ {hasNewRequired && (
+
+ }
+ title="New Required Inputs"
+ description="These inputs are now required:"
+ items={incompatibilities.newRequiredInputs}
+ />
+ )}
+
+
+
+ If you proceed, you'll need to remove the broken connections
+ before you can save or run your agent.
+
+
+
+
+
+ Cancel
+
+
+ Update Anyway
+
+
+
+
+
+ );
+}
+
+type TwoColumnSectionProps = {
+ title: string;
+ leftIcon: React.ReactNode;
+ leftTitle: string;
+ leftItems: string[];
+ rightIcon: React.ReactNode;
+ rightTitle: string;
+ rightItems: string[];
+};
+
+function TwoColumnSection({
+ title,
+ leftIcon,
+ leftTitle,
+ leftItems,
+ rightIcon,
+ rightTitle,
+ rightItems,
+}: TwoColumnSectionProps) {
+ return (
+
+
{title}
+
+ {/* Left column - Breaking changes */}
+
+
+ {leftIcon}
+ {leftTitle}
+
+
+ {leftItems.length > 0 ? (
+ leftItems.map((item) => (
+
+
+ {item}
+
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+ {/* Right column - Possible solutions */}
+
+
+ {rightIcon}
+ {rightTitle}
+
+
+ {rightItems.length > 0 ? (
+ rightItems.map((item) => (
+
+
+ {item}
+
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+
+ );
+}
+
+type SingleColumnSectionProps = {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ items: string[];
+};
+
+function SingleColumnSection({
+ icon,
+ title,
+ description,
+ items,
+}: SingleColumnSectionProps) {
+ return (
+
+
+ {icon}
+ {title}
+
+
+ {description}
+
+
+ {items.map((item) => (
+
+
+ {item}
+
+
+ ))}
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/ResolutionModeBar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/ResolutionModeBar.tsx
new file mode 100644
index 0000000000..f60bd60c52
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/components/ResolutionModeBar.tsx
@@ -0,0 +1,107 @@
+import React from "react";
+import { InfoIcon, WarningIcon } from "@phosphor-icons/react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/atoms/Tooltip/BaseTooltip";
+import { IncompatibilityInfo } from "@/app/(platform)/build/hooks/useSubAgentUpdate/types";
+
+type ResolutionModeBarProps = {
+ incompatibilities: IncompatibilityInfo | null;
+};
+
+export function ResolutionModeBar({
+ incompatibilities,
+}: ResolutionModeBarProps): React.ReactElement {
+ const renderIncompatibilities = () => {
+ if (!incompatibilities) return
No incompatibilities ;
+
+ const sections: React.ReactNode[] = [];
+
+ if (incompatibilities.missingInputs.length > 0) {
+ sections.push(
+
+ Missing inputs:
+ {incompatibilities.missingInputs.map((name, i) => (
+
+ {name}
+ {i < incompatibilities.missingInputs.length - 1 && ", "}
+
+ ))}
+
,
+ );
+ }
+ if (incompatibilities.missingOutputs.length > 0) {
+ sections.push(
+
+ Missing outputs:
+ {incompatibilities.missingOutputs.map((name, i) => (
+
+ {name}
+ {i < incompatibilities.missingOutputs.length - 1 && ", "}
+
+ ))}
+
,
+ );
+ }
+ if (incompatibilities.newRequiredInputs.length > 0) {
+ sections.push(
+
+ New required inputs:
+ {incompatibilities.newRequiredInputs.map((name, i) => (
+
+ {name}
+ {i < incompatibilities.newRequiredInputs.length - 1 && ", "}
+
+ ))}
+
,
+ );
+ }
+ if (incompatibilities.inputTypeMismatches.length > 0) {
+ sections.push(
+
+ Type changed:
+ {incompatibilities.inputTypeMismatches.map((m, i) => (
+
+ {m.name}
+
+ {" "}
+ ({m.oldType} → {m.newType})
+
+ {i < incompatibilities.inputTypeMismatches.length - 1 && ", "}
+
+ ))}
+
,
+ );
+ }
+
+ return <>{sections}>;
+ };
+
+ return (
+
+
+
+
+ Remove incompatible connections
+
+
+
+
+
+
+ Incompatible changes:
+ {renderIncompatibilities()}
+
+ {(incompatibilities?.newRequiredInputs.length ?? 0) > 0
+ ? "Replace / delete"
+ : "Delete"}{" "}
+ the red connections to continue
+
+
+
+
+
+ );
+}
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
new file mode 100644
index 0000000000..d4ba538172
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/useSubAgentUpdateState.ts
@@ -0,0 +1,194 @@
+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 { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
+import {
+ useSubAgentUpdate,
+ createUpdatedAgentNodeInputs,
+ getBrokenEdgeIDs,
+} from "@/app/(platform)/build/hooks/useSubAgentUpdate";
+import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
+import { CustomNodeData } from "../../CustomNode";
+
+// Stable empty set to avoid creating new references in selectors
+const EMPTY_SET: Set
= new Set();
+
+type UseSubAgentUpdateParams = {
+ nodeID: string;
+ nodeData: CustomNodeData;
+};
+
+export function useSubAgentUpdateState({
+ nodeID,
+ nodeData,
+}: UseSubAgentUpdateParams) {
+ const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
+ useState(false);
+
+ // Get store actions
+ const updateNodeData = useNodeStore(
+ useShallow((state) => state.updateNodeData),
+ );
+ const setNodeResolutionMode = useNodeStore(
+ useShallow((state) => state.setNodeResolutionMode),
+ );
+ const isNodeInResolutionMode = useNodeStore(
+ useShallow((state) => state.isNodeInResolutionMode),
+ );
+ const setBrokenEdgeIDs = useNodeStore(
+ useShallow((state) => state.setBrokenEdgeIDs),
+ );
+ // Get this node's broken edge IDs from the per-node map
+ // Use EMPTY_SET as fallback to maintain referential stability
+ const brokenEdgeIDs = useNodeStore(
+ (state) => state.brokenEdgeIDs.get(nodeID) || EMPTY_SET,
+ );
+ const getNodeResolutionData = useNodeStore(
+ useShallow((state) => state.getNodeResolutionData),
+ );
+ const connectedEdges = useEdgeStore(
+ useShallow((state) => state.getNodeEdges(nodeID)),
+ );
+ const availableSubGraphs = useGraphStore(
+ useShallow((state) => state.availableSubGraphs),
+ );
+
+ // Extract agent-specific data
+ const graphID = nodeData.hardcodedValues?.graph_id as string | undefined;
+ const graphVersion = nodeData.hardcodedValues?.graph_version as
+ | number
+ | undefined;
+ const currentInputSchema = nodeData.hardcodedValues?.input_schema as
+ | GraphInputSchema
+ | undefined;
+ const currentOutputSchema = nodeData.hardcodedValues?.output_schema as
+ | GraphOutputSchema
+ | undefined;
+
+ // Use the sub-agent update hook
+ const updateInfo = useSubAgentUpdate(
+ nodeID,
+ graphID,
+ graphVersion,
+ currentInputSchema,
+ currentOutputSchema,
+ connectedEdges,
+ availableSubGraphs,
+ );
+
+ const isInResolutionMode = isNodeInResolutionMode(nodeID);
+
+ // Handle update button click
+ const handleUpdateClick = useCallback(() => {
+ if (!updateInfo.hasUpdate || !updateInfo.latestGraph) return;
+
+ if (updateInfo.isCompatible) {
+ // Compatible update - apply directly
+ const newHardcodedValues = createUpdatedAgentNodeInputs(
+ nodeData.hardcodedValues,
+ updateInfo.latestGraph,
+ );
+ updateNodeData(nodeID, { hardcodedValues: newHardcodedValues });
+ } else {
+ // Incompatible update - show dialog
+ setShowIncompatibilityDialog(true);
+ }
+ }, [
+ updateInfo.hasUpdate,
+ updateInfo.latestGraph,
+ updateInfo.isCompatible,
+ nodeData.hardcodedValues,
+ updateNodeData,
+ nodeID,
+ ]);
+
+ // Handle confirming an incompatible update
+ function handleConfirmIncompatibleUpdate() {
+ if (!updateInfo.latestGraph || !updateInfo.incompatibilities) return;
+
+ const latestGraph = updateInfo.latestGraph;
+
+ // Get the new schemas from the latest graph version
+ const newInputSchema =
+ (latestGraph.input_schema as Record) || {};
+ const newOutputSchema =
+ (latestGraph.output_schema as Record) || {};
+
+ // Create the updated hardcoded values but DON'T apply them yet
+ // We'll apply them when resolution is complete
+ const pendingHardcodedValues = createUpdatedAgentNodeInputs(
+ nodeData.hardcodedValues,
+ latestGraph,
+ );
+
+ // Get broken edge IDs and store them for this node
+ const brokenIds = getBrokenEdgeIDs(
+ connectedEdges,
+ updateInfo.incompatibilities,
+ nodeID,
+ );
+ setBrokenEdgeIDs(nodeID, brokenIds);
+
+ // Enter resolution mode with both old and new schemas
+ // DON'T apply the update yet - keep old schema so connections remain visible
+ const resolutionData: NodeResolutionData = {
+ incompatibilities: updateInfo.incompatibilities,
+ pendingUpdate: {
+ input_schema: newInputSchema,
+ output_schema: newOutputSchema,
+ },
+ currentSchema: {
+ input_schema: (currentInputSchema as Record) || {},
+ output_schema: (currentOutputSchema as Record) || {},
+ },
+ pendingHardcodedValues,
+ };
+ setNodeResolutionMode(nodeID, true, resolutionData);
+
+ setShowIncompatibilityDialog(false);
+ }
+
+ // Check if resolution is complete (all broken edges removed)
+ const resolutionData = getNodeResolutionData(nodeID);
+
+ // Auto-check resolution on edge changes
+ useEffect(() => {
+ if (!isInResolutionMode) return;
+
+ // Check if any broken edges still exist
+ const remainingBroken = Array.from(brokenEdgeIDs).filter((edgeId) =>
+ connectedEdges.some((e) => e.id === edgeId),
+ );
+
+ if (remainingBroken.length === 0) {
+ // Resolution complete - now apply the pending update
+ if (resolutionData?.pendingHardcodedValues) {
+ updateNodeData(nodeID, {
+ hardcodedValues: resolutionData.pendingHardcodedValues,
+ });
+ }
+ // setNodeResolutionMode will clean up this node's broken edges automatically
+ setNodeResolutionMode(nodeID, false);
+ }
+ }, [
+ isInResolutionMode,
+ brokenEdgeIDs,
+ connectedEdges,
+ resolutionData,
+ nodeID,
+ ]);
+
+ return {
+ updateInfo,
+ isInResolutionMode,
+ resolutionData,
+ showIncompatibilityDialog,
+ setShowIncompatibilityDialog,
+ handleUpdateClick,
+ handleConfirmIncompatibleUpdate,
+ };
+}
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 8d228d0cd0..54ddf2a61d 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,4 +1,6 @@
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
+import { NodeResolutionData } from "@/app/(platform)/build/stores/nodeStore";
+import { RJSFSchema } from "@rjsf/utils";
export const nodeStyleBasedOnStatus: Record = {
INCOMPLETE: "ring-slate-300 bg-slate-300",
@@ -9,3 +11,48 @@ export const nodeStyleBasedOnStatus: Record = {
TERMINATED: "ring-orange-300 bg-orange-300 ",
FAILED: "ring-red-300 bg-red-300",
};
+
+/**
+ * Merges schemas during resolution mode to include removed inputs/outputs
+ * that still have connections, so users can see and delete them.
+ */
+export function mergeSchemaForResolution(
+ currentSchema: Record,
+ newSchema: Record,
+ resolutionData: NodeResolutionData,
+ type: "input" | "output",
+): Record {
+ const newProps = (newSchema.properties as RJSFSchema) || {};
+ const currentProps = (currentSchema.properties as RJSFSchema) || {};
+ const mergedProps = { ...newProps };
+ const incomp = resolutionData.incompatibilities;
+
+ if (type === "input") {
+ // Add back missing inputs that have connections
+ incomp.missingInputs.forEach((inputName: string) => {
+ if (currentProps[inputName]) {
+ mergedProps[inputName] = currentProps[inputName];
+ }
+ });
+ // Add back inputs with type mismatches (keep old type so connection works visually)
+ incomp.inputTypeMismatches.forEach(
+ (mismatch: { name: string; oldType: string; newType: string }) => {
+ if (currentProps[mismatch.name]) {
+ mergedProps[mismatch.name] = currentProps[mismatch.name];
+ }
+ },
+ );
+ } else {
+ // Add back missing outputs that have connections
+ incomp.missingOutputs.forEach((outputName: string) => {
+ if (currentProps[outputName]) {
+ mergedProps[outputName] = currentProps[outputName];
+ }
+ });
+ }
+
+ return {
+ ...newSchema,
+ properties: mergedProps,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx
new file mode 100644
index 0000000000..e58d0ab12b
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode.tsx
@@ -0,0 +1,58 @@
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+import { CustomNodeData } from "./CustomNode";
+import { BlockUIType } from "../../../types";
+import { useMemo } from "react";
+import { mergeSchemaForResolution } from "./helpers";
+
+export const useCustomNode = ({
+ data,
+ nodeId,
+}: {
+ data: CustomNodeData;
+ nodeId: string;
+}) => {
+ const isInResolutionMode = useNodeStore((state) =>
+ state.nodesInResolutionMode.has(nodeId),
+ );
+ const resolutionData = useNodeStore((state) =>
+ state.nodeResolutionData.get(nodeId),
+ );
+
+ const isAgent = data.uiType === BlockUIType.AGENT;
+
+ const currentInputSchema = isAgent
+ ? (data.hardcodedValues.input_schema ?? {})
+ : data.inputSchema;
+ const currentOutputSchema = isAgent
+ ? (data.hardcodedValues.output_schema ?? {})
+ : data.outputSchema;
+
+ const inputSchema = useMemo(() => {
+ if (isAgent && isInResolutionMode && resolutionData) {
+ return mergeSchemaForResolution(
+ resolutionData.currentSchema.input_schema,
+ resolutionData.pendingUpdate.input_schema,
+ resolutionData,
+ "input",
+ );
+ }
+ return currentInputSchema;
+ }, [isAgent, isInResolutionMode, resolutionData, currentInputSchema]);
+
+ const outputSchema = useMemo(() => {
+ if (isAgent && isInResolutionMode && resolutionData) {
+ return mergeSchemaForResolution(
+ resolutionData.currentSchema.output_schema,
+ resolutionData.pendingUpdate.output_schema,
+ resolutionData,
+ "output",
+ );
+ }
+ return currentOutputSchema;
+ }, [isAgent, isInResolutionMode, resolutionData, currentOutputSchema]);
+
+ return {
+ inputSchema,
+ outputSchema,
+ };
+};
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 cfee0bf89f..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
@@ -3,22 +3,18 @@ import React from "react";
import { uiSchema } from "./uiSchema";
import { useNodeStore } from "../../../stores/nodeStore";
import { BlockUIType } from "../../types";
-import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
+import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
-export const FormCreator = React.memo(
- ({
- jsonSchema,
- nodeId,
- uiType,
- showHandles = true,
- className,
- }: {
- jsonSchema: RJSFSchema;
- nodeId: string;
- uiType: BlockUIType;
- showHandles?: boolean;
- className?: string;
- }) => {
+interface FormCreatorProps {
+ jsonSchema: RJSFSchema;
+ nodeId: string;
+ uiType: BlockUIType;
+ showHandles?: boolean;
+ className?: string;
+}
+
+export const FormCreator: React.FC = React.memo(
+ ({ jsonSchema, nodeId, uiType, showHandles = true, className }) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const getHardCodedValues = useNodeStore(
@@ -48,7 +44,10 @@ export const FormCreator = React.memo(
: hardcodedValues;
return (
-
+
{
+ return Object.entries(schema).map(
+ ([key, fieldSchema]: [string, RJSFSchema]) => {
+ const fullKey = keyPrefix ? `${keyPrefix}_#_${key}` : key;
+ const fieldTitle = titlePrefix + (fieldSchema?.title || key);
+
+ const isConnected = isOutputConnected(nodeId, fullKey);
+ const shouldShow = isConnected || isOutputVisible;
+ const { displayType, colorClass, hexColor } =
+ getTypeDisplayInfo(fieldSchema);
+ const isBroken = brokenOutputs.has(fullKey);
+
+ return shouldShow ? (
+
+
+ {fieldSchema?.description && (
+
+
+
+
+
+
+
+ {fieldSchema?.description}
+
+
+ )}
+
+ {fieldTitle}
+
+
+ ({displayType})
+
+
+ {showHandles && (
+
+ )}
+
+
+ {/* Recursively render nested properties */}
+ {fieldSchema?.properties &&
+ renderOutputHandles(
+ fieldSchema.properties,
+ fullKey,
+ `${fieldTitle}.`,
+ )}
+
+ ) : null;
+ },
+ );
+ };
return (
-
+
- {
-
- {Object.entries(properties).map(([key, property]: [string, any]) => {
- const isConnected = isOutputConnected(nodeId, key);
- const shouldShow = isConnected || isOutputVisible;
- const { displayType, colorClass } = getTypeDisplayInfo(property);
-
- return shouldShow ? (
-
- {property?.description && (
-
-
-
-
-
-
-
- {property?.description}
-
-
- )}
-
- {property?.title || key}{" "}
-
-
- ({displayType})
-
-
-
-
- ) : null;
- })}
-
- }
+
+ {renderOutputHandles(properties)}
+
);
};
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 5572426dc7..46032a67ea 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
@@ -89,17 +89,53 @@ export function extractOptions(
// get display type and color for schema types [need for type display next to field name]
export const getTypeDisplayInfo = (schema: any) => {
+ if (
+ schema?.type === "array" &&
+ "format" in schema &&
+ schema.format === "table"
+ ) {
+ return {
+ displayType: "table",
+ colorClass: "!text-indigo-500",
+ hexColor: "#6366f1",
+ };
+ }
+
if (schema?.type === "string" && schema?.format) {
const formatMap: Record<
string,
- { displayType: string; colorClass: string }
+ { displayType: string; colorClass: string; hexColor: string }
> = {
- file: { displayType: "file", colorClass: "!text-green-500" },
- date: { displayType: "date", colorClass: "!text-blue-500" },
- time: { displayType: "time", colorClass: "!text-blue-500" },
- "date-time": { displayType: "datetime", colorClass: "!text-blue-500" },
- "long-text": { displayType: "text", colorClass: "!text-green-500" },
- "short-text": { displayType: "text", colorClass: "!text-green-500" },
+ file: {
+ displayType: "file",
+ colorClass: "!text-green-500",
+ hexColor: "#22c55e",
+ },
+ date: {
+ displayType: "date",
+ colorClass: "!text-blue-500",
+ hexColor: "#3b82f6",
+ },
+ time: {
+ displayType: "time",
+ colorClass: "!text-blue-500",
+ hexColor: "#3b82f6",
+ },
+ "date-time": {
+ displayType: "datetime",
+ colorClass: "!text-blue-500",
+ hexColor: "#3b82f6",
+ },
+ "long-text": {
+ displayType: "text",
+ colorClass: "!text-green-500",
+ hexColor: "#22c55e",
+ },
+ "short-text": {
+ displayType: "text",
+ colorClass: "!text-green-500",
+ hexColor: "#22c55e",
+ },
};
const formatInfo = formatMap[schema.format];
@@ -131,10 +167,23 @@ export const getTypeDisplayInfo = (schema: any) => {
any: "!text-gray-500",
};
+ const hexColorMap: Record
= {
+ string: "#22c55e",
+ number: "#3b82f6",
+ integer: "#3b82f6",
+ boolean: "#eab308",
+ object: "#a855f7",
+ array: "#6366f1",
+ null: "#6b7280",
+ any: "#6b7280",
+ };
+
const colorClass = colorMap[schema?.type] || "!text-gray-500";
+ const hexColor = hexColorMap[schema?.type] || "#6b7280";
return {
displayType,
colorClass,
+ hexColor,
};
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts
index ad1fab7c95..065e697828 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/uiSchema.ts
@@ -1,6 +1,6 @@
export const uiSchema = {
credentials: {
- "ui:field": "credentials",
+ "ui:field": "custom/credential_field",
provider: { "ui:widget": "hidden" },
type: { "ui:widget": "hidden" },
id: { "ui:autofocus": true },
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/useBrokenOutputs.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/useBrokenOutputs.ts
new file mode 100644
index 0000000000..61ef992a13
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/nodes/useBrokenOutputs.ts
@@ -0,0 +1,23 @@
+import { useMemo } from "react";
+import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
+
+/**
+ * Hook to get the set of broken output names for a node in resolution mode.
+ */
+export function useBrokenOutputs(nodeID: string): Set {
+ // Subscribe to the actual state values, not just methods
+ const isInResolution = useNodeStore((state) =>
+ state.nodesInResolutionMode.has(nodeID),
+ );
+ const resolutionData = useNodeStore((state) =>
+ state.nodeResolutionData.get(nodeID),
+ );
+
+ return useMemo(() => {
+ if (!isInResolution || !resolutionData) {
+ return new Set();
+ }
+
+ return new Set(resolutionData.incompatibilities.missingOutputs);
+ }, [isInResolution, resolutionData]);
+}
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..19e133ef7d
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/icons.ts
@@ -0,0 +1,7 @@
+// These are SVG Phosphor icons
+
+export const ICONS = {
+ ClickIcon: ` `,
+ Keyboard: ` `,
+ Drag: ` `,
+};
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..fac08ec145
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/tutorial/index.ts
@@ -0,0 +1,81 @@
+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";
+
+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();
+ });
+
+ tour.on("cancel", () => {
+ handleTutorialCancel(tour);
+ removeTutorialStyles();
+ clearPrefetchedBlocks();
+ });
+
+ 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/ControlPanelButton.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/ControlPanelButton.tsx
index b176a002a7..36834becf6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/ControlPanelButton.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/ControlPanelButton.tsx
@@ -24,7 +24,7 @@ export const ControlPanelButton: React.FC = ({
role={as === "div" ? "button" : undefined}
disabled={as === "button" ? disabled : undefined}
className={cn(
- "flex h-[4.25rem] w-[4.25rem] items-center justify-center whitespace-normal bg-white p-[1.38rem] text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
+ "flex w-auto items-center justify-center whitespace-normal bg-white px-4 py-4 text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
selected &&
"bg-violet-50 text-violet-700 hover:cursor-default hover:bg-violet-50 hover:text-violet-700 active:bg-violet-50 active:text-violet-700",
disabled && "cursor-not-allowed opacity-50 hover:cursor-not-allowed",
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}
+ >
@@ -28,7 +34,7 @@ export const BlockMenu = () => {
selected={blockMenuOpen}
className="rounded-none"
>
-
+
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/BlockMenuFilters.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/BlockMenuFilters.tsx
new file mode 100644
index 0000000000..ebcea9eee6
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/BlockMenuFilters.tsx
@@ -0,0 +1,57 @@
+import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
+import { FilterChip } from "../FilterChip";
+import { categories } from "./constants";
+import { FilterSheet } from "../FilterSheet/FilterSheet";
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+
+export const BlockMenuFilters = () => {
+ const {
+ filters,
+ addFilter,
+ removeFilter,
+ categoryCounts,
+ creators,
+ addCreator,
+ removeCreator,
+ } = useBlockMenuStore();
+
+ const handleFilterClick = (filter: GetV2BuilderSearchFilterAnyOfItem) => {
+ if (filters.includes(filter)) {
+ removeFilter(filter);
+ } else {
+ addFilter(filter);
+ }
+ };
+
+ const handleCreatorClick = (creator: string) => {
+ if (creators.includes(creator)) {
+ removeCreator(creator);
+ } else {
+ addCreator(creator);
+ }
+ };
+
+ return (
+
+
+ {creators.length > 0 &&
+ creators.map((creator) => (
+ handleCreatorClick(creator)}
+ />
+ ))}
+ {categories.map((category) => (
+ handleFilterClick(category.key)}
+ number={categoryCounts[category.key] ?? 0}
+ />
+ ))}
+
+ );
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/constants.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/constants.ts
new file mode 100644
index 0000000000..b438aae91b
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/constants.ts
@@ -0,0 +1,15 @@
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+import { CategoryKey } from "./types";
+
+export const categories: Array<{ key: CategoryKey; name: string }> = [
+ { key: GetV2BuilderSearchFilterAnyOfItem.blocks, name: "Blocks" },
+ {
+ key: GetV2BuilderSearchFilterAnyOfItem.integrations,
+ name: "Integrations",
+ },
+ {
+ key: GetV2BuilderSearchFilterAnyOfItem.marketplace_agents,
+ name: "Marketplace agents",
+ },
+ { key: GetV2BuilderSearchFilterAnyOfItem.my_agents, name: "My agents" },
+];
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/types.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/types.ts
new file mode 100644
index 0000000000..8fec9ef64d
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuFilters/types.ts
@@ -0,0 +1,26 @@
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+
+export type DefaultStateType =
+ | "suggestion"
+ | "all_blocks"
+ | "input_blocks"
+ | "action_blocks"
+ | "output_blocks"
+ | "integrations"
+ | "marketplace_agents"
+ | "my_agents";
+
+export type CategoryKey = GetV2BuilderSearchFilterAnyOfItem;
+
+export interface Filters {
+ categories: {
+ blocks: boolean;
+ integrations: boolean;
+ marketplace_agents: boolean;
+ my_agents: boolean;
+ providers: boolean;
+ };
+ createdBy: string[];
+}
+
+export type CategoryCounts = Record;
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 de339431e8..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
@@ -1,111 +1,17 @@
import { Text } from "@/components/atoms/Text/Text";
-import { useBlockMenuSearch } from "./useBlockMenuSearch";
-import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
-import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
-import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
-import { MarketplaceAgentBlock } from "../MarketplaceAgentBlock";
-import { Block } from "../Block";
-import { UGCAgentBlock } from "../UGCAgentBlock";
-import { getSearchItemType } from "./helper";
-import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { blockMenuContainerStyle } from "../style";
-import { cn } from "@/lib/utils";
-import { NoSearchResult } from "../NoSearchResult";
+import { BlockMenuFilters } from "../BlockMenuFilters/BlockMenuFilters";
+import { BlockMenuSearchContent } from "../BlockMenuSearchContent/BlockMenuSearchContent";
export const BlockMenuSearch = () => {
- const {
- searchResults,
- isFetchingNextPage,
- fetchNextPage,
- hasNextPage,
- searchLoading,
- handleAddLibraryAgent,
- handleAddMarketplaceAgent,
- addingLibraryAgentId,
- addingMarketplaceAgentSlug,
- } = useBlockMenuSearch();
- const { searchQuery } = useBlockMenuStore();
-
- if (searchLoading) {
- return (
-
-
-
- );
- }
-
- if (searchResults.length === 0) {
- return ;
- }
-
return (
-
+
+
Search results
- }
- className="space-y-2.5"
- >
- {searchResults.map((item: SearchResponseItemsItem, index: number) => {
- const { type, data } = getSearchItemType(item);
- // backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
- switch (type) {
- case "store_agent":
- return (
-
- handleAddMarketplaceAgent({
- creator_name: data.creator,
- slug: data.slug,
- })
- }
- />
- );
- case "block":
- return (
-
- );
-
- case "library_agent":
- return (
- handleAddLibraryAgent(data)}
- />
- );
-
- default:
- return null;
- }
- })}
-
+
);
};
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 (
{
+ const {
+ searchResults,
+ isFetchingNextPage,
+ fetchNextPage,
+ hasNextPage,
+ searchLoading,
+ handleAddLibraryAgent,
+ handleAddMarketplaceAgent,
+ addingLibraryAgentId,
+ addingMarketplaceAgentSlug,
+ } = useBlockMenuSearchContent();
+
+ const { searchQuery } = useBlockMenuStore();
+
+ if (searchLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (searchResults.length === 0) {
+ return
;
+ }
+
+ return (
+
}
+ className="space-y-2.5"
+ >
+ {searchResults.map((item: SearchResponseItemsItem, index: number) => {
+ const { type, data } = getSearchItemType(item);
+ // backend give support to these 3 types only [right now] - we need to give support to integration and ai agent types in follow up PRs
+ switch (type) {
+ case "store_agent":
+ return (
+
+ handleAddMarketplaceAgent({
+ creator_name: data.creator,
+ slug: data.slug,
+ })
+ }
+ />
+ );
+ case "block":
+ return (
+
+ );
+
+ case "library_agent":
+ return (
+ handleAddLibraryAgent(data)}
+ />
+ );
+
+ default:
+ return null;
+ }
+ })}
+
+ );
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/helper.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/helper.ts
similarity index 100%
rename from autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/helper.ts
rename to autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/helper.ts
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/useBlockMenuSearchContent.tsx
similarity index 83%
rename from autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts
rename to autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/useBlockMenuSearchContent.tsx
index beff80a984..9da9cb4cbc 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearch/useBlockMenuSearch.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/useBlockMenuSearchContent.tsx
@@ -23,9 +23,19 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { useToast } from "@/components/molecules/Toast/use-toast";
import * as Sentry from "@sentry/nextjs";
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+
+export const useBlockMenuSearchContent = () => {
+ const {
+ searchQuery,
+ searchId,
+ setSearchId,
+ filters,
+ setCreatorsList,
+ creators,
+ setCategoryCounts,
+ } = useBlockMenuStore();
-export const useBlockMenuSearch = () => {
- const { searchQuery, searchId, setSearchId } = useBlockMenuStore();
const { toast } = useToast();
const { addAgentToBuilder, addLibraryAgentToBuilder } =
useAddAgentToBuilder();
@@ -57,6 +67,8 @@ export const useBlockMenuSearch = () => {
page_size: 8,
search_query: searchQuery,
search_id: searchId,
+ filter: filters.length > 0 ? filters : undefined,
+ by_creator: creators.length > 0 ? creators : undefined,
},
{
query: { getNextPageParam: getPaginationNextPageNumber },
@@ -98,6 +110,26 @@ export const useBlockMenuSearch = () => {
}
}, [searchQueryData, searchId, setSearchId]);
+ // from all the results, we need to get all the unique creators
+ useEffect(() => {
+ if (!searchQueryData?.pages?.length) {
+ return;
+ }
+ const latestData = okData(searchQueryData.pages.at(-1));
+ setCategoryCounts(
+ (latestData?.total_items as Record<
+ GetV2BuilderSearchFilterAnyOfItem,
+ number
+ >) || {
+ blocks: 0,
+ integrations: 0,
+ marketplace_agents: 0,
+ my_agents: 0,
+ },
+ );
+ setCreatorsList(latestData?.items || []);
+ }, [searchQueryData]);
+
useEffect(() => {
if (searchId && !searchQuery) {
resetSearchSession();
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSidebar/BlockMenuSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSidebar/BlockMenuSidebar.tsx
index 16b0ccb8fb..1872e610b6 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSidebar/BlockMenuSidebar.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/BlockMenuSidebar/BlockMenuSidebar.tsx
@@ -101,6 +101,7 @@ export const BlockMenuSidebar = () => {
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/FilterChip.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterChip.tsx
index 69931958b3..23197ab612 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterChip.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterChip.tsx
@@ -1,7 +1,9 @@
import { Button } from "@/components/__legacy__/ui/button";
import { cn } from "@/lib/utils";
-import { X } from "lucide-react";
-import React, { ButtonHTMLAttributes } from "react";
+import { XIcon } from "@phosphor-icons/react";
+import { AnimatePresence, motion } from "framer-motion";
+
+import React, { ButtonHTMLAttributes, useState } from "react";
interface Props extends ButtonHTMLAttributes {
selected?: boolean;
@@ -16,39 +18,51 @@ export const FilterChip: React.FC = ({
className,
...rest
}) => {
+ const [isHovered, setIsHovered] = useState(false);
return (
-
-
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
className={cn(
- "font-sans text-sm font-medium leading-[1.375rem] text-zinc-600 group-hover:text-zinc-600 group-disabled:text-zinc-400",
- selected && "text-zinc-50",
+ "group w-fit space-x-1 rounded-[1.5rem] border border-zinc-300 bg-transparent px-[0.625rem] py-[0.375rem] shadow-none",
+ "hover:border-violet-500 hover:bg-transparent focus:ring-0 disabled:cursor-not-allowed",
+ selected && "border-0 bg-violet-700 hover:border",
+ className,
)}
+ {...rest}
>
- {name}
-
- {selected && (
- <>
-
-
-
- {number !== undefined && (
-
- {number > 100 ? "100+" : number}
-
+
- )}
-
+ >
+ {name}
+
+ {selected && !isHovered && (
+
+
+
+ )}
+ {number !== undefined && isHovered && (
+
+ {number > 100 ? "100+" : number}
+
+ )}
+
+
);
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/FilterSheet.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/FilterSheet.tsx
new file mode 100644
index 0000000000..dc7c428245
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/FilterSheet.tsx
@@ -0,0 +1,156 @@
+import { FilterChip } from "../FilterChip";
+import { cn } from "@/lib/utils";
+import { CategoryKey } from "../BlockMenuFilters/types";
+import { AnimatePresence, motion } from "framer-motion";
+import { XIcon } from "@phosphor-icons/react";
+import { Button } from "@/components/atoms/Button/Button";
+import { Text } from "@/components/atoms/Text/Text";
+import { Separator } from "@/components/__legacy__/ui/separator";
+import { Checkbox } from "@/components/__legacy__/ui/checkbox";
+import { useFilterSheet } from "./useFilterSheet";
+import { INITIAL_CREATORS_TO_SHOW } from "./constant";
+
+export function FilterSheet({
+ categories,
+}: {
+ categories: Array<{ key: CategoryKey; name: string }>;
+}) {
+ const {
+ isOpen,
+ localCategories,
+ localCreators,
+ displayedCreatorsCount,
+ handleLocalCategoryChange,
+ handleToggleShowMoreCreators,
+ handleLocalCreatorChange,
+ handleClearFilters,
+ handleCloseButton,
+ handleApplyFilters,
+ hasLocalActiveFilters,
+ visibleCreators,
+ creators,
+ handleOpenFilters,
+ hasActiveFilters,
+ } = useFilterSheet();
+
+ return (
+
+
+
+
+ {isOpen && (
+
+ {/* Top section */}
+
+ Filters
+
+
+
+
+
+
+
+ {/* Category section */}
+
+
Categories
+
+ {categories.map((category) => (
+
+
+ handleLocalCategoryChange(category.key)
+ }
+ className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
+ />
+
+ {category.name}
+
+
+ ))}
+
+
+
+ {/* Created by section */}
+
+
+ Created by
+
+
+ {visibleCreators.map((creator, i) => (
+
+ handleLocalCreatorChange(creator)}
+ className="border border-[#D4D4D4] shadow-none data-[state=checked]:border-none data-[state=checked]:bg-violet-700 data-[state=checked]:text-white"
+ />
+
+ {creator}
+
+
+ ))}
+
+ {creators.length > INITIAL_CREATORS_TO_SHOW && (
+
+ {displayedCreatorsCount < creators.length ? "More" : "Less"}
+
+ )}
+
+
+ {/* Footer section */}
+
+
+ Clear
+
+
+
+ Apply filters
+
+
+
+ )}
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/constant.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/constant.ts
new file mode 100644
index 0000000000..8e05dc1037
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/constant.ts
@@ -0,0 +1 @@
+export const INITIAL_CREATORS_TO_SHOW = 5;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/useFilterSheet.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/useFilterSheet.ts
new file mode 100644
index 0000000000..200671f4e7
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewBlockMenu/FilterSheet/useFilterSheet.ts
@@ -0,0 +1,100 @@
+import { useBlockMenuStore } from "@/app/(platform)/build/stores/blockMenuStore";
+import { useState } from "react";
+import { INITIAL_CREATORS_TO_SHOW } from "./constant";
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
+
+export const useFilterSheet = () => {
+ const { filters, creators_list, creators, setFilters, setCreators } =
+ useBlockMenuStore();
+
+ const [isOpen, setIsOpen] = useState(false);
+ const [localCategories, setLocalCategories] =
+ useState(filters);
+ const [localCreators, setLocalCreators] = useState(creators);
+ const [displayedCreatorsCount, setDisplayedCreatorsCount] = useState(
+ INITIAL_CREATORS_TO_SHOW,
+ );
+
+ const handleLocalCategoryChange = (
+ category: GetV2BuilderSearchFilterAnyOfItem,
+ ) => {
+ setLocalCategories((prev) => {
+ if (prev.includes(category)) {
+ return prev.filter((c) => c !== category);
+ }
+ return [...prev, category];
+ });
+ };
+
+ const hasActiveFilters = () => {
+ return filters.length > 0 || creators.length > 0;
+ };
+
+ const handleToggleShowMoreCreators = () => {
+ if (displayedCreatorsCount < creators.length) {
+ setDisplayedCreatorsCount(creators.length);
+ } else {
+ setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
+ }
+ };
+
+ const handleLocalCreatorChange = (creator: string) => {
+ setLocalCreators((prev) => {
+ if (prev.includes(creator)) {
+ return prev.filter((c) => c !== creator);
+ }
+ return [...prev, creator];
+ });
+ };
+
+ const handleClearFilters = () => {
+ setLocalCategories([]);
+ setLocalCreators([]);
+ setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
+ };
+
+ const handleCloseButton = () => {
+ setIsOpen(false);
+ setLocalCategories(filters);
+ setLocalCreators(creators);
+ setDisplayedCreatorsCount(INITIAL_CREATORS_TO_SHOW);
+ };
+
+ const handleApplyFilters = () => {
+ setFilters(localCategories);
+ setCreators(localCreators);
+ setIsOpen(false);
+ };
+
+ const handleOpenFilters = () => {
+ setIsOpen(true);
+ setLocalCategories(filters);
+ setLocalCreators(creators);
+ };
+
+ const hasLocalActiveFilters = () => {
+ return localCategories.length > 0 || localCreators.length > 0;
+ };
+
+ const visibleCreators = creators_list.slice(0, displayedCreatorsCount);
+
+ return {
+ creators,
+ isOpen,
+ setIsOpen,
+ localCategories,
+ localCreators,
+ displayedCreatorsCount,
+ setDisplayedCreatorsCount,
+ handleLocalCategoryChange,
+ handleToggleShowMoreCreators,
+ handleLocalCreatorChange,
+ handleClearFilters,
+ handleCloseButton,
+ handleOpenFilters,
+ handleApplyFilters,
+ hasLocalActiveFilters,
+ visibleCreators,
+ hasActiveFilters,
+ };
+};
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 (
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewSaveControl/NewSaveControl.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewSaveControl/NewSaveControl.tsx
index beae5c1705..6769490ab3 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewSaveControl/NewSaveControl.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewSaveControl/NewSaveControl.tsx
@@ -1,29 +1,36 @@
-import React from "react";
+import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
+import { Form, FormField } from "@/components/__legacy__/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
-import { Card, CardContent, CardFooter } from "@/components/__legacy__/ui/card";
+import { Button } from "@/components/atoms/Button/Button";
+import { Input } from "@/components/atoms/Input/Input";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
-import { useNewSaveControl } from "./useNewSaveControl";
-import { Form, FormField } from "@/components/__legacy__/ui/form";
-import { ControlPanelButton } from "../ControlPanelButton";
-import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { FloppyDiskIcon } from "@phosphor-icons/react";
-import { Input } from "@/components/atoms/Input/Input";
-import { Button } from "@/components/atoms/Button/Button";
+import { useControlPanelStore } from "../../../stores/controlPanelStore";
+import { ControlPanelButton } from "../ControlPanelButton";
+import { useNewSaveControl } from "./useNewSaveControl";
export const NewSaveControl = () => {
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}
+ >
@@ -33,7 +40,7 @@ export const NewSaveControl = () => {
selected={saveControlOpen}
className="rounded-none"
>
-
+
@@ -95,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/NewSearchGraph/GraphMenu/GraphMenu.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewSearchGraph/GraphMenu/GraphMenu.tsx
index c886919642..8ff96a598b 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewSearchGraph/GraphMenu/GraphMenu.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/NewControlPanel/NewSearchGraph/GraphMenu/GraphMenu.tsx
@@ -1,13 +1,13 @@
-import React from "react";
+import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
-import { GraphSearchContent } from "../GraphMenuContent/GraphContent";
+import React from "react";
import { ControlPanelButton } from "../../ControlPanelButton";
-import { CustomNode } from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
+import { GraphSearchContent } from "../GraphMenuContent/GraphContent";
import { useGraphMenu } from "./useGraphMenu";
interface GraphSearchMenuProps {
@@ -50,7 +50,7 @@ export const GraphSearchMenu: React.FC = ({
selected={blockMenuSelected === "search"}
className="rounded-none"
>
-
+
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 6f134056c8..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
@@ -1,12 +1,12 @@
import { Separator } from "@/components/__legacy__/ui/separator";
-import { ControlPanelButton } from "./ControlPanelButton";
-import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
+import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
import { useHistoryStore } from "../../stores/historyStore";
+import { ControlPanelButton } from "./ControlPanelButton";
import { useEffect } from "react";
@@ -42,8 +42,13 @@ export const UndoRedoButtons = () => {
<>
-
-
+
+
Undo
@@ -51,8 +56,13 @@ export const UndoRedoButtons = () => {
-
-
+
+
Redo
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx
index 5e4211ce5d..cc0c7ff765 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/RIghtSidebar.tsx
@@ -25,7 +25,7 @@ export const RightSidebar = () => {
>
- Flow Debug Panel
+ Graph Debug Panel
@@ -65,7 +65,7 @@ export const RightSidebar = () => {
{l.source_id}[{l.source_name}] → {l.sink_id}[{l.sink_name}]
- edge_id: {l.id}
+ edge.id: {l.id}
))}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx
index d3383cdc4f..f5451e6d4d 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/BlocksControl.tsx
@@ -12,7 +12,14 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/__legacy__/ui/popover";
-import { Block, BlockUIType, SpecialBlockID } from "@/lib/autogpt-server-api";
+import {
+ Block,
+ BlockIORootSchema,
+ BlockUIType,
+ GraphInputSchema,
+ GraphOutputSchema,
+ SpecialBlockID,
+} from "@/lib/autogpt-server-api";
import { MagnifyingGlassIcon, PlusIcon } from "@radix-ui/react-icons";
import { IconToyBrick } from "@/components/__legacy__/ui/icons";
import { getPrimaryCategoryColor } from "@/lib/utils";
@@ -24,8 +31,10 @@ import {
import { GraphMeta } from "@/lib/autogpt-server-api";
import jaro from "jaro-winkler";
-type _Block = Block & {
+type _Block = Omit & {
uiKey?: string;
+ inputSchema: BlockIORootSchema | GraphInputSchema;
+ outputSchema: BlockIORootSchema | GraphOutputSchema;
hardcodedValues?: Record;
_cached?: {
blockName: string;
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 9671109422..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
@@ -2,7 +2,7 @@ import React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/__legacy__/ui/button";
import { LogOut } from "lucide-react";
-import { ClockIcon } from "@phosphor-icons/react";
+import { ClockIcon, WarningIcon } from "@phosphor-icons/react";
import { IconPlay, IconSquare } from "@/components/__legacy__/ui/icons";
interface Props {
@@ -13,6 +13,7 @@ interface Props {
isRunning: boolean;
isDisabled: boolean;
className?: string;
+ resolutionModeActive?: boolean;
}
export const BuildActionBar: React.FC = ({
@@ -23,9 +24,30 @@ export const BuildActionBar: React.FC = ({
isRunning,
isDisabled,
className,
+ resolutionModeActive = false,
}) => {
const buttonClasses =
"flex items-center gap-2 text-sm font-medium md:text-lg";
+
+ // Show resolution mode message instead of action buttons
+ if (resolutionModeActive) {
+ return (
+
+
+
+
+ Remove incompatible connections to continue
+
+
+
+ );
+ }
+
return (
= ({
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/CustomEdge/CustomEdge.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomEdge/CustomEdge.tsx
index ce14add315..5ca5393d69 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomEdge/CustomEdge.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomEdge/CustomEdge.tsx
@@ -60,10 +60,16 @@ export function CustomEdge({
targetY - 5,
);
const { deleteElements } = useReactFlow
();
- const { visualizeBeads } = useContext(BuilderContext) ?? {
+ const builderContext = useContext(BuilderContext);
+ const { visualizeBeads } = builderContext ?? {
visualizeBeads: "no",
};
+ // Check if this edge is broken (during resolution mode)
+ const isBroken =
+ builderContext?.resolutionMode?.active &&
+ builderContext?.resolutionMode?.brokenEdgeIds?.includes(id);
+
const onEdgeRemoveClick = () => {
deleteElements({ edges: [{ id }] });
};
@@ -171,12 +177,27 @@ export function CustomEdge({
const middle = getPointForT(0.5);
+ // Determine edge color - red for broken edges
+ const baseColor = data?.edgeColor ?? "#555555";
+ const edgeColor = isBroken ? "#ef4444" : baseColor;
+ // Add opacity to hex color (99 = 60% opacity, 80 = 50% opacity)
+ const strokeColor = isBroken
+ ? `${edgeColor}99`
+ : selected
+ ? edgeColor
+ : `${edgeColor}80`;
+
return (
<>
;
+};
export type CustomNodeData = {
blockType: string;
@@ -80,7 +89,7 @@ export type CustomNodeData = {
inputSchema: BlockIORootSchema;
outputSchema: BlockIORootSchema;
hardcodedValues: { [key: string]: any };
- connections: ConnectionData;
+ connections: ConnectedEdge[];
isOutputOpen: boolean;
status?: NodeExecutionResult["status"];
/** executionResults contains outputs across multiple executions
@@ -127,20 +136,199 @@ export const CustomNode = React.memo(
let subGraphID = "";
- if (data.uiType === BlockUIType.AGENT) {
- // Display the graph's schema instead AgentExecutorBlock's schema.
- data.inputSchema = data.hardcodedValues?.input_schema || {};
- data.outputSchema = data.hardcodedValues?.output_schema || {};
- subGraphID = data.hardcodedValues?.graph_id || subGraphID;
- }
-
if (!builderContext) {
throw new Error(
"BuilderContext consumer must be inside FlowEditor component",
);
}
- const { libraryAgent, setIsAnyModalOpen, getNextNodeId } = builderContext;
+ const {
+ libraryAgent,
+ setIsAnyModalOpen,
+ getNextNodeId,
+ availableFlows,
+ resolutionMode,
+ enterResolutionMode,
+ } = builderContext;
+
+ // Check if this node is in resolution mode (moved up for schema merge logic)
+ const isInResolutionMode =
+ resolutionMode.active && resolutionMode.nodeId === id;
+
+ if (data.uiType === BlockUIType.AGENT) {
+ // Display the graph's schema instead AgentExecutorBlock's schema.
+ const currentInputSchema = data.hardcodedValues?.input_schema || {};
+ const currentOutputSchema = data.hardcodedValues?.output_schema || {};
+ subGraphID = data.hardcodedValues?.graph_id || subGraphID;
+
+ // During resolution mode, merge old connected inputs/outputs with new schema
+ if (isInResolutionMode && resolutionMode.pendingUpdate) {
+ const newInputSchema =
+ (resolutionMode.pendingUpdate.input_schema as BlockIORootSchema) ||
+ {};
+ const newOutputSchema =
+ (resolutionMode.pendingUpdate.output_schema as BlockIORootSchema) ||
+ {};
+
+ // Merge input schemas: start with new schema, add old connected inputs that are missing
+ const mergedInputProps = { ...newInputSchema.properties };
+ const incomp = resolutionMode.incompatibilities;
+ if (incomp && currentInputSchema.properties) {
+ // Add back missing inputs that have connections (so user can see/delete them)
+ incomp.missingInputs.forEach((inputName) => {
+ if (currentInputSchema.properties[inputName]) {
+ mergedInputProps[inputName] =
+ currentInputSchema.properties[inputName];
+ }
+ });
+ // Add back inputs with type mismatches (keep old type so connection still works visually)
+ incomp.inputTypeMismatches.forEach((mismatch) => {
+ if (currentInputSchema.properties[mismatch.name]) {
+ mergedInputProps[mismatch.name] =
+ currentInputSchema.properties[mismatch.name];
+ }
+ });
+ }
+
+ // Merge output schemas: start with new schema, add old connected outputs that are missing
+ const mergedOutputProps = { ...newOutputSchema.properties };
+ if (incomp && currentOutputSchema.properties) {
+ incomp.missingOutputs.forEach((outputName) => {
+ if (currentOutputSchema.properties[outputName]) {
+ mergedOutputProps[outputName] =
+ currentOutputSchema.properties[outputName];
+ }
+ });
+ }
+
+ data.inputSchema = {
+ ...newInputSchema,
+ properties: mergedInputProps,
+ };
+ data.outputSchema = {
+ ...newOutputSchema,
+ properties: mergedOutputProps,
+ };
+ } else {
+ data.inputSchema = currentInputSchema;
+ data.outputSchema = currentOutputSchema;
+ }
+ }
+
+ const setHardcodedValues = useCallback(
+ (values: any) => {
+ updateNodeData(id, { hardcodedValues: values });
+ },
+ [id, updateNodeData],
+ );
+
+ // Sub-agent update detection
+ const isAgentBlock = data.uiType === BlockUIType.AGENT;
+ const graphId = isAgentBlock ? data.hardcodedValues?.graph_id : undefined;
+ const graphVersion = isAgentBlock
+ ? data.hardcodedValues?.graph_version
+ : undefined;
+
+ const subAgentUpdate = useSubAgentUpdate(
+ id,
+ graphId,
+ graphVersion,
+ isAgentBlock
+ ? (data.hardcodedValues?.input_schema as GraphInputSchema)
+ : undefined,
+ isAgentBlock
+ ? (data.hardcodedValues?.output_schema as GraphOutputSchema)
+ : undefined,
+ data.connections,
+ availableFlows,
+ );
+
+ const [showIncompatibilityDialog, setShowIncompatibilityDialog] =
+ useState(false);
+
+ // Helper to check if a handle is broken (for resolution mode)
+ const isInputHandleBroken = useCallback(
+ (handleName: string): boolean => {
+ if (!isInResolutionMode || !resolutionMode.incompatibilities) {
+ return false;
+ }
+ const incomp = resolutionMode.incompatibilities;
+ return (
+ incomp.missingInputs.includes(handleName) ||
+ incomp.inputTypeMismatches.some((m) => m.name === handleName)
+ );
+ },
+ [isInResolutionMode, resolutionMode.incompatibilities],
+ );
+
+ const isOutputHandleBroken = useCallback(
+ (handleName: string): boolean => {
+ if (!isInResolutionMode || !resolutionMode.incompatibilities) {
+ return false;
+ }
+ return resolutionMode.incompatibilities.missingOutputs.includes(
+ handleName,
+ );
+ },
+ [isInResolutionMode, resolutionMode.incompatibilities],
+ );
+
+ // Handle update button click
+ const handleUpdateClick = useCallback(() => {
+ if (!subAgentUpdate.latestGraph) return;
+
+ if (subAgentUpdate.isCompatible) {
+ // Compatible update - directly apply
+ const updatedValues = createUpdatedAgentNodeInputs(
+ data.hardcodedValues,
+ subAgentUpdate.latestGraph,
+ );
+ setHardcodedValues(updatedValues);
+ toast({
+ title: "Agent updated",
+ description: `Updated to version ${subAgentUpdate.latestVersion}`,
+ });
+ } else {
+ // Incompatible update - show dialog
+ setShowIncompatibilityDialog(true);
+ }
+ }, [subAgentUpdate, data.hardcodedValues, setHardcodedValues]);
+
+ // Handle confirm incompatible update
+ const handleConfirmIncompatibleUpdate = useCallback(() => {
+ if (!subAgentUpdate.latestGraph || !subAgentUpdate.incompatibilities) {
+ return;
+ }
+
+ // Create the updated values but DON'T apply them yet
+ const updatedValues = createUpdatedAgentNodeInputs(
+ data.hardcodedValues,
+ subAgentUpdate.latestGraph,
+ );
+
+ // Get broken edge IDs
+ const brokenEdgeIds = getBrokenEdgeIDs(
+ data.connections,
+ subAgentUpdate.incompatibilities,
+ id,
+ );
+
+ // Enter resolution mode with pending update (don't apply schema yet)
+ enterResolutionMode(
+ id,
+ subAgentUpdate.incompatibilities,
+ brokenEdgeIds,
+ updatedValues,
+ );
+
+ setShowIncompatibilityDialog(false);
+ }, [
+ subAgentUpdate,
+ data.hardcodedValues,
+ data.connections,
+ id,
+ enterResolutionMode,
+ ]);
useEffect(() => {
if (data.executionResults || data.status) {
@@ -156,13 +344,6 @@ export const CustomNode = React.memo(
setIsAnyModalOpen?.(isModalOpen || isOutputModalOpen);
}, [isModalOpen, isOutputModalOpen, data, setIsAnyModalOpen]);
- const setHardcodedValues = useCallback(
- (values: any) => {
- updateNodeData(id, { hardcodedValues: values });
- },
- [id, updateNodeData],
- );
-
const handleTitleEdit = useCallback(() => {
setIsEditingTitle(true);
setTimeout(() => {
@@ -255,6 +436,7 @@ export const CustomNode = React.memo(
isConnected={isOutputHandleConnected(propKey)}
schema={fieldSchema}
side="right"
+ isBroken={isOutputHandleBroken(propKey)}
/>
{"properties" in fieldSchema &&
renderHandles(
@@ -385,6 +567,7 @@ export const CustomNode = React.memo(
isRequired={isRequired}
schema={propSchema}
side="left"
+ isBroken={isInputHandleBroken(propKey)}
/>
) : (
propKey !== "credentials" &&
@@ -873,6 +1056,22 @@ export const CustomNode = React.memo(
+ {/* Sub-agent Update Bar - shown below header */}
+ {isAgentBlock && (subAgentUpdate.hasUpdate || isInResolutionMode) && (
+
+ )}
+
{/* Body */}
{/* Input Handles */}
@@ -1044,9 +1243,24 @@ export const CustomNode = React.memo(
);
return (
-
- {nodeContent()}
-
+ <>
+
+ {nodeContent()}
+
+
+ {/* Incompatibility Dialog for sub-agent updates */}
+ {isAgentBlock && subAgentUpdate.incompatibilities && (
+
setShowIncompatibilityDialog(false)}
+ onConfirm={handleConfirmIncompatibleUpdate}
+ currentVersion={subAgentUpdate.currentVersion}
+ latestVersion={subAgentUpdate.latestVersion}
+ agentName={data.blockType || "Agent"}
+ incompatibilities={subAgentUpdate.incompatibilities}
+ />
+ )}
+ >
);
},
(prevProps, nextProps) => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/IncompatibilityDialog.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/IncompatibilityDialog.tsx
new file mode 100644
index 0000000000..951fe2eab5
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/IncompatibilityDialog.tsx
@@ -0,0 +1,244 @@
+import React from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/__legacy__/ui/dialog";
+import { Button } from "@/components/__legacy__/ui/button";
+import { AlertTriangle, XCircle, PlusCircle } from "lucide-react";
+import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
+import { beautifyString } from "@/lib/utils";
+import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
+
+interface IncompatibilityDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ currentVersion: number;
+ latestVersion: number;
+ agentName: string;
+ incompatibilities: IncompatibilityInfo;
+}
+
+export const IncompatibilityDialog: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+ currentVersion,
+ latestVersion,
+ agentName,
+ incompatibilities,
+}) => {
+ const hasMissingInputs = incompatibilities.missingInputs.length > 0;
+ const hasMissingOutputs = incompatibilities.missingOutputs.length > 0;
+ const hasNewInputs = incompatibilities.newInputs.length > 0;
+ const hasNewOutputs = incompatibilities.newOutputs.length > 0;
+ const hasNewRequired = incompatibilities.newRequiredInputs.length > 0;
+ const hasTypeMismatches = incompatibilities.inputTypeMismatches.length > 0;
+
+ const hasInputChanges = hasMissingInputs || hasNewInputs;
+ const hasOutputChanges = hasMissingOutputs || hasNewOutputs;
+
+ return (
+ !open && onClose()}>
+
+
+
+
+ Incompatible Update
+
+
+ Updating {beautifyString(agentName)} from v
+ {currentVersion} to v{latestVersion} will break some connections.
+
+
+
+
+ {/* Input changes - two column layout */}
+ {hasInputChanges && (
+
}
+ leftTitle="Removed"
+ leftItems={incompatibilities.missingInputs}
+ rightIcon={
}
+ rightTitle="Added"
+ rightItems={incompatibilities.newInputs}
+ />
+ )}
+
+ {/* Output changes - two column layout */}
+ {hasOutputChanges && (
+
}
+ leftTitle="Removed"
+ leftItems={incompatibilities.missingOutputs}
+ rightIcon={
}
+ rightTitle="Added"
+ rightItems={incompatibilities.newOutputs}
+ />
+ )}
+
+ {hasTypeMismatches && (
+
}
+ title="Type Changed"
+ description="These connected inputs have a different type:"
+ items={incompatibilities.inputTypeMismatches.map(
+ (m) => `${m.name} (${m.oldType} → ${m.newType})`,
+ )}
+ />
+ )}
+
+ {hasNewRequired && (
+
}
+ title="New Required Inputs"
+ description="These inputs are now required:"
+ items={incompatibilities.newRequiredInputs}
+ />
+ )}
+
+
+
+
+ If you proceed, you'll need to remove the broken connections
+ before you can save or run your agent.
+
+
+
+
+
+ Cancel
+
+
+ Update Anyway
+
+
+
+
+ );
+};
+
+interface TwoColumnSectionProps {
+ title: string;
+ leftIcon: React.ReactNode;
+ leftTitle: string;
+ leftItems: string[];
+ rightIcon: React.ReactNode;
+ rightTitle: string;
+ rightItems: string[];
+}
+
+const TwoColumnSection: React.FC = ({
+ title,
+ leftIcon,
+ leftTitle,
+ leftItems,
+ rightIcon,
+ rightTitle,
+ rightItems,
+}) => (
+
+
{title}
+
+ {/* Left column - Breaking changes */}
+
+
+ {leftIcon}
+ {leftTitle}
+
+
+ {leftItems.length > 0 ? (
+ leftItems.map((item) => (
+
+
+ {item}
+
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+ {/* Right column - Possible solutions */}
+
+
+ {rightIcon}
+ {rightTitle}
+
+
+ {rightItems.length > 0 ? (
+ rightItems.map((item) => (
+
+
+ {item}
+
+
+ ))
+ ) : (
+
+ None
+
+ )}
+
+
+
+
+);
+
+interface SingleColumnSectionProps {
+ icon: React.ReactNode;
+ title: string;
+ description: string;
+ items: string[];
+}
+
+const SingleColumnSection: React.FC = ({
+ icon,
+ title,
+ description,
+ items,
+}) => (
+
+
+ {icon}
+ {title}
+
+
+ {description}
+
+
+ {items.map((item) => (
+
+
+ {item}
+
+
+ ))}
+
+
+);
+
+export default IncompatibilityDialog;
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/SubAgentUpdateBar.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/SubAgentUpdateBar.tsx
new file mode 100644
index 0000000000..5f421d90d8
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/CustomNode/SubAgentUpdateBar.tsx
@@ -0,0 +1,130 @@
+import React from "react";
+import { Button } from "@/components/__legacy__/ui/button";
+import { ArrowUp, AlertTriangle, Info } from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/atoms/Tooltip/BaseTooltip";
+import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
+import { cn } from "@/lib/utils";
+
+interface SubAgentUpdateBarProps {
+ currentVersion: number;
+ latestVersion: number;
+ isCompatible: boolean;
+ incompatibilities: IncompatibilityInfo | null;
+ onUpdate: () => void;
+ isInResolutionMode?: boolean;
+}
+
+export const SubAgentUpdateBar: React.FC = ({
+ currentVersion,
+ latestVersion,
+ isCompatible,
+ incompatibilities,
+ onUpdate,
+ isInResolutionMode = false,
+}) => {
+ if (isInResolutionMode) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ Update available (v{currentVersion} → v{latestVersion})
+
+ {!isCompatible && (
+
+
+
+
+
+ Incompatible changes detected
+
+ Click Update to see details
+
+
+
+ )}
+
+
+ Update
+
+
+ );
+};
+
+interface ResolutionModeBarProps {
+ incompatibilities: IncompatibilityInfo | null;
+}
+
+const ResolutionModeBar: React.FC = ({
+ incompatibilities,
+}) => {
+ const formatIncompatibilities = () => {
+ if (!incompatibilities) return "No incompatibilities";
+
+ const items: string[] = [];
+
+ if (incompatibilities.missingInputs.length > 0) {
+ items.push(
+ `Missing inputs: ${incompatibilities.missingInputs.join(", ")}`,
+ );
+ }
+ if (incompatibilities.missingOutputs.length > 0) {
+ items.push(
+ `Missing outputs: ${incompatibilities.missingOutputs.join(", ")}`,
+ );
+ }
+ if (incompatibilities.newRequiredInputs.length > 0) {
+ items.push(
+ `New required inputs: ${incompatibilities.newRequiredInputs.join(", ")}`,
+ );
+ }
+ if (incompatibilities.inputTypeMismatches.length > 0) {
+ const mismatches = incompatibilities.inputTypeMismatches
+ .map((m) => `${m.name} (${m.oldType} → ${m.newType})`)
+ .join(", ");
+ items.push(`Type changed: ${mismatches}`);
+ }
+
+ return items.join("\n");
+ };
+
+ return (
+
+
+
+
+ Remove incompatible connections
+
+
+
+
+
+
+ Incompatible changes:
+ {formatIncompatibilities()}
+
+ Delete the red connections to continue
+
+
+
+
+
+ );
+};
+
+export default SubAgentUpdateBar;
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 0050c6cf64..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,22 +1,15 @@
-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";
import { Clipboard, Maximize2 } from "lucide-react";
import React, { FC, useMemo, useState } from "react";
import { Button } from "../../../../../components/__legacy__/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "../../../../../components/__legacy__/ui/dialog";
import { ContentRenderer } from "../../../../../components/__legacy__/ui/render";
import { ScrollArea } from "../../../../../components/__legacy__/ui/scroll-area";
import { Separator } from "../../../../../components/__legacy__/ui/separator";
@@ -120,138 +113,155 @@ const ExpandableOutputDialog: FC = ({
};
return (
-
-
-
-
-
-
- Full Output Preview
-
- {enableEnhancedOutputHandling && (
-
-
- Enhanced Rendering
-
-
-
- )}
-
-
- Execution ID: {execId}
-
- Pin:{" "}
- {beautifyString(pinName)}
-
-
-
-
- {useEnhancedRenderer && outputItems.length > 0 && (
-
-
({
- value: item.value,
- metadata: item.metadata,
- renderer: item.renderer,
- }))}
+
+
+
+ Full Output Preview
+
+ {enableEnhancedOutputHandling && (
+
+
+ Enhanced Rendering
+
+
)}
-
-
- {data.length > 0 ? (
- useEnhancedRenderer ? (
-
- {outputItems.map((item) => (
-
- ))}
-
- ) : (
-
- {data.map((item, index) => (
-
-
-
- Item {index + 1} of {data.length}
-
- {
- const itemData =
- typeof item === "object"
- ? JSON.stringify(item, null, 2)
- : String(item);
- navigator.clipboard
- .writeText(itemData)
- .then(() => {
- toast({
- title: `Item ${index + 1} copied to clipboard!`,
- duration: 2000,
- });
- });
- }}
- className="flex items-center gap-1"
- >
-
- Copy Item
-
-
-
-
-
-
-
- ))}
-
- )
- ) : (
-
- No data available
-
- )}
-
-
+ }
+ controlled={{
+ isOpen,
+ set: (open) => {
+ if (!open) onClose();
+ },
+ }}
+ onClose={onClose}
+ styling={{
+ maxWidth: "56rem",
+ width: "90vw",
+ height: "90vh",
+ }}
+ >
+
+
+
+
+ Execution ID: {execId}
+
+ Pin:{" "}
+ {beautifyString(pinName)}
+
+
-
-
- {data.length} item{data.length !== 1 ? "s" : ""} total
-
-
- {!useEnhancedRenderer && (
-
-
- Copy All
-
+
+ {useEnhancedRenderer && outputItems.length > 0 && (
+
+ ({
+ value: item.value,
+ metadata: item.metadata,
+ renderer: item.renderer,
+ }))}
+ />
+
)}
-
Close
+
+
+ {data.length > 0 ? (
+ useEnhancedRenderer ? (
+
+ {outputItems.map((item) => (
+
+ ))}
+
+ ) : (
+
+ {data.map((item, index) => (
+
+
+
+ Item {index + 1} of {data.length}
+
+ {
+ const itemData =
+ typeof item === "object"
+ ? JSON.stringify(item, null, 2)
+ : String(item);
+ navigator.clipboard
+ .writeText(itemData)
+ .then(() => {
+ toast({
+ title: `Item ${index + 1} copied to clipboard!`,
+ duration: 2000,
+ });
+ });
+ }}
+ className="flex items-center gap-1"
+ >
+
+ Copy Item
+
+
+
+
+
+
+
+ ))}
+
+ )
+ ) : (
+
+ No data available
+
+ )}
+
+
-
-
+
+
+
+ {data.length} item{data.length !== 1 ? "s" : ""} total
+
+
+ {!useEnhancedRenderer && (
+
+
+ Copy All
+
+ )}
+ Close
+
+
+
+
);
};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx
index 80a6fb022d..a54a9ef386 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/Flow/Flow.tsx
@@ -26,15 +26,17 @@ import {
applyNodeChanges,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
-import { CustomNode } from "../CustomNode/CustomNode";
+import { ConnectedEdge, CustomNode } from "../CustomNode/CustomNode";
import "./flow.css";
import {
BlockUIType,
formatEdgeID,
GraphExecutionID,
GraphID,
+ GraphMeta,
LibraryAgent,
} from "@/lib/autogpt-server-api";
+import { IncompatibilityInfo } from "../../../hooks/useSubAgentUpdate/types";
import { Key, storage } from "@/services/storage/local-storage";
import { findNewlyAddedBlockCoordinates, getTypeColor } from "@/lib/utils";
import { history } from "../history";
@@ -72,12 +74,30 @@ import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle";
// It helps to prevent spamming the history with small movements especially when pressing on a input in a block
const MINIMUM_MOVE_BEFORE_LOG = 50;
+export type ResolutionModeState = {
+ active: boolean;
+ nodeId: string | null;
+ incompatibilities: IncompatibilityInfo | null;
+ brokenEdgeIds: string[];
+ pendingUpdate: Record | null; // The hardcoded values to apply after resolution
+};
+
type BuilderContextType = {
libraryAgent: LibraryAgent | null;
visualizeBeads: "no" | "static" | "animate";
setIsAnyModalOpen: (isOpen: boolean) => void;
getNextNodeId: () => string;
getNodeTitle: (nodeID: string) => string | null;
+ availableFlows: GraphMeta[];
+ resolutionMode: ResolutionModeState;
+ enterResolutionMode: (
+ nodeId: string,
+ incompatibilities: IncompatibilityInfo,
+ brokenEdgeIds: string[],
+ pendingUpdate: Record,
+ ) => void;
+ exitResolutionMode: () => void;
+ applyPendingUpdate: () => void;
};
export type NodeDimension = {
@@ -172,6 +192,92 @@ const FlowEditor: React.FC<{
// It stores the dimension of all nodes with position as well
const [nodeDimensions, setNodeDimensions] = useState({});
+ // Resolution mode state for sub-agent incompatible updates
+ const [resolutionMode, setResolutionMode] = useState({
+ active: false,
+ nodeId: null,
+ incompatibilities: null,
+ brokenEdgeIds: [],
+ pendingUpdate: null,
+ });
+
+ const enterResolutionMode = useCallback(
+ (
+ nodeId: string,
+ incompatibilities: IncompatibilityInfo,
+ brokenEdgeIds: string[],
+ pendingUpdate: Record,
+ ) => {
+ setResolutionMode({
+ active: true,
+ nodeId,
+ incompatibilities,
+ brokenEdgeIds,
+ pendingUpdate,
+ });
+ },
+ [],
+ );
+
+ const exitResolutionMode = useCallback(() => {
+ setResolutionMode({
+ active: false,
+ nodeId: null,
+ incompatibilities: null,
+ brokenEdgeIds: [],
+ pendingUpdate: null,
+ });
+ }, []);
+
+ // Apply pending update after resolution mode completes
+ const applyPendingUpdate = useCallback(() => {
+ if (!resolutionMode.nodeId || !resolutionMode.pendingUpdate) return;
+
+ const node = nodes.find((n) => n.id === resolutionMode.nodeId);
+ if (node) {
+ const pendingUpdate = resolutionMode.pendingUpdate as {
+ [key: string]: any;
+ };
+ setNodes((nds) =>
+ nds.map((n) =>
+ n.id === resolutionMode.nodeId
+ ? { ...n, data: { ...n.data, hardcodedValues: pendingUpdate } }
+ : n,
+ ),
+ );
+ }
+ exitResolutionMode();
+ toast({
+ title: "Update complete",
+ description: "Agent has been updated to the new version.",
+ });
+ }, [resolutionMode, nodes, setNodes, exitResolutionMode, toast]);
+
+ // Check if all broken edges have been removed and auto-apply pending update
+ useEffect(() => {
+ if (!resolutionMode.active || resolutionMode.brokenEdgeIds.length === 0) {
+ return;
+ }
+
+ const currentEdgeIds = new Set(edges.map((e) => e.id));
+ const remainingBrokenEdges = resolutionMode.brokenEdgeIds.filter((id) =>
+ currentEdgeIds.has(id),
+ );
+
+ if (remainingBrokenEdges.length === 0) {
+ // All broken edges have been removed, apply pending update
+ applyPendingUpdate();
+ } else if (
+ remainingBrokenEdges.length !== resolutionMode.brokenEdgeIds.length
+ ) {
+ // Update the list of broken edges
+ setResolutionMode((prev) => ({
+ ...prev,
+ brokenEdgeIds: remainingBrokenEdges,
+ }));
+ }
+ }, [edges, resolutionMode, applyPendingUpdate]);
+
// Set page title with or without graph name
useEffect(() => {
document.title = savedAgent
@@ -431,17 +537,19 @@ const FlowEditor: React.FC<{
...node.data.connections.filter(
(conn) =>
!removedEdges.some(
- (removedEdge) => removedEdge.id === conn.edge_id,
+ (removedEdge) => removedEdge.id === conn.id,
),
),
// Add node connections for added edges
- ...addedEdges.map((addedEdge) => ({
- edge_id: addedEdge.item.id,
- source: addedEdge.item.source,
- target: addedEdge.item.target,
- sourceHandle: addedEdge.item.sourceHandle!,
- targetHandle: addedEdge.item.targetHandle!,
- })),
+ ...addedEdges.map(
+ (addedEdge): ConnectedEdge => ({
+ id: addedEdge.item.id,
+ source: addedEdge.item.source,
+ target: addedEdge.item.target,
+ sourceHandle: addedEdge.item.sourceHandle!,
+ targetHandle: addedEdge.item.targetHandle!,
+ }),
+ ),
],
},
}));
@@ -467,13 +575,15 @@ const FlowEditor: React.FC<{
data: {
...node.data,
connections: [
- ...replaceEdges.map((replaceEdge) => ({
- edge_id: replaceEdge.item.id,
- source: replaceEdge.item.source,
- target: replaceEdge.item.target,
- sourceHandle: replaceEdge.item.sourceHandle!,
- targetHandle: replaceEdge.item.targetHandle!,
- })),
+ ...replaceEdges.map(
+ (replaceEdge): ConnectedEdge => ({
+ id: replaceEdge.item.id,
+ source: replaceEdge.item.source,
+ target: replaceEdge.item.target,
+ sourceHandle: replaceEdge.item.sourceHandle!,
+ targetHandle: replaceEdge.item.targetHandle!,
+ }),
+ ),
],
},
})),
@@ -890,8 +1000,23 @@ const FlowEditor: React.FC<{
setIsAnyModalOpen,
getNextNodeId,
getNodeTitle,
+ availableFlows,
+ resolutionMode,
+ enterResolutionMode,
+ exitResolutionMode,
+ applyPendingUpdate,
}),
- [libraryAgent, visualizeBeads, getNextNodeId, getNodeTitle],
+ [
+ libraryAgent,
+ visualizeBeads,
+ getNextNodeId,
+ getNodeTitle,
+ availableFlows,
+ resolutionMode,
+ enterResolutionMode,
+ applyPendingUpdate,
+ exitResolutionMode,
+ ],
);
return (
@@ -991,6 +1116,7 @@ const FlowEditor: React.FC<{
onClickScheduleButton={handleScheduleButton}
isDisabled={!savedAgent}
isRunning={isRunning}
+ resolutionModeActive={resolutionMode.active}
/>
) : (
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeHandle.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeHandle.tsx
index 1377df62a0..932e50c499 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeHandle.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeHandle.tsx
@@ -1,6 +1,11 @@
import { BlockIOSubSchema } from "@/lib/autogpt-server-api/types";
-import { cn } from "@/lib/utils";
-import { beautifyString, getTypeBgColor, getTypeTextColor } from "@/lib/utils";
+import {
+ cn,
+ beautifyString,
+ getTypeBgColor,
+ getTypeTextColor,
+ getEffectiveType,
+} from "@/lib/utils";
import { FC, memo, useCallback } from "react";
import { Handle, Position } from "@xyflow/react";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
@@ -13,6 +18,7 @@ type HandleProps = {
side: "left" | "right";
title?: string;
className?: string;
+ isBroken?: boolean;
};
// Move the constant out of the component to avoid re-creation on every render.
@@ -27,18 +33,23 @@ const TYPE_NAME: Record = {
};
// Extract and memoize the Dot component so that it doesn't re-render unnecessarily.
-const Dot: FC<{ isConnected: boolean; type?: string }> = memo(
- ({ isConnected, type }) => {
- const color = isConnected
- ? getTypeBgColor(type || "any")
- : "border-gray-300 dark:border-gray-600";
+const Dot: FC<{ isConnected: boolean; type?: string; isBroken?: boolean }> =
+ memo(({ isConnected, type, isBroken }) => {
+ const color = isBroken
+ ? "border-red-500 bg-red-100 dark:bg-red-900/30"
+ : isConnected
+ ? getTypeBgColor(type || "any")
+ : "border-gray-300 dark:border-gray-600";
return (
);
- },
-);
+ });
Dot.displayName = "Dot";
const NodeHandle: FC = ({
@@ -49,24 +60,34 @@ const NodeHandle: FC = ({
side,
title,
className,
+ isBroken = false,
}) => {
- const typeClass = `text-sm ${getTypeTextColor(schema.type || "any")} ${
+ // Extract effective type from schema (handles anyOf/oneOf/allOf wrappers)
+ const effectiveType = getEffectiveType(schema);
+
+ const typeClass = `text-sm ${getTypeTextColor(effectiveType || "any")} ${
side === "left" ? "text-left" : "text-right"
}`;
const label = (
-
+
{title || schema.title || beautifyString(keyName.toLowerCase())}
{isRequired ? "*" : ""}
-
- ({TYPE_NAME[schema.type as keyof typeof TYPE_NAME] || "any"})
+
+ ({TYPE_NAME[effectiveType as keyof typeof TYPE_NAME] || "any"})
);
@@ -84,7 +105,7 @@ const NodeHandle: FC
= ({
return (
= ({
data-testid={`input-handle-${keyName}`}
position={Position.Left}
id={keyName}
- className="group -ml-[38px]"
+ className={cn("group -ml-[38px]", isBroken && "cursor-not-allowed")}
+ isConnectable={!isBroken}
>
-
+
{label}
@@ -106,7 +132,10 @@ const NodeHandle: FC
= ({
return (
= ({
data-testid={`output-handle-${keyName}`}
position={Position.Right}
id={keyName}
- className="group -mr-[38px]"
+ className={cn("group -mr-[38px]", isBroken && "cursor-not-allowed")}
+ isConnectable={!isBroken}
>
{label}
-
+
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 683a854c21..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
@@ -1,8 +1,8 @@
import {
- ConnectionData,
+ ConnectedEdge,
CustomNodeData,
} from "@/app/(platform)/build/components/legacy-builder/CustomNode/CustomNode";
-import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
+import { NodeTableInput } from "@/app/(platform)/build/components/legacy-builder/NodeTableInput";
import { Button } from "@/components/__legacy__/ui/button";
import { Calendar } from "@/components/__legacy__/ui/calendar";
import { LocalValuedInput } from "@/components/__legacy__/ui/input";
@@ -27,8 +27,8 @@ 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 { NodeTableInput } from "@/components/node-table-input";
import {
BlockIOArraySubSchema,
BlockIOBooleanSubSchema,
@@ -65,7 +65,7 @@ type NodeObjectInputTreeProps = {
selfKey?: string;
schema: BlockIORootSchema | BlockIOObjectSubSchema;
object?: { [key: string]: any };
- connections: ConnectionData;
+ connections: ConnectedEdge[];
handleInputClick: (key: string) => void;
handleInputChange: (key: string, value: any) => void;
errors: { [key: string]: string | undefined };
@@ -585,7 +585,7 @@ const NodeOneOfDiscriminatorField: FC<{
currentValue?: any;
defaultValue?: any;
errors: { [key: string]: string | undefined };
- connections: ConnectionData;
+ connections: ConnectedEdge[];
handleInputChange: (key: string, value: any) => void;
handleInputClick: (key: string) => void;
className?: string;
diff --git a/autogpt_platform/frontend/src/components/node-table-input.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeTableInput.tsx
similarity index 95%
rename from autogpt_platform/frontend/src/components/node-table-input.tsx
rename to autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeTableInput.tsx
index 9c1a8003ee..07a5b38520 100644
--- a/autogpt_platform/frontend/src/components/node-table-input.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/NodeTableInput.tsx
@@ -1,15 +1,16 @@
-import React, { FC, useCallback, useEffect, useState } from "react";
+import { FC, useCallback, useEffect, useState } from "react";
-import { PlusIcon, XIcon } from "@phosphor-icons/react";
-import { cn } from "@/lib/utils";
import NodeHandle from "@/app/(platform)/build/components/legacy-builder/NodeHandle";
-import {
+import type {
BlockIOTableSubSchema,
- TableRow,
TableCellValue,
+ TableRow,
} from "@/lib/autogpt-server-api/types";
-import { Input } from "./atoms/Input/Input";
-import { Button } from "./atoms/Button/Button";
+import type { ConnectedEdge } from "./CustomNode/CustomNode";
+import { cn } from "@/lib/utils";
+import { PlusIcon, XIcon } from "@phosphor-icons/react";
+import { Button } from "@/components/atoms/Button/Button";
+import { Input } from "@/components/atoms/Input/Input";
interface NodeTableInputProps {
/** Unique identifier for the node in the builder graph */
@@ -25,13 +26,7 @@ interface NodeTableInputProps {
/** Validation errors mapped by field key */
errors: { [key: string]: string | undefined };
/** Graph connections between nodes in the builder */
- connections: {
- edge_id: string;
- source: string;
- sourceHandle: string;
- target: string;
- targetHandle: string;
- }[];
+ connections: ConnectedEdge[];
/** Callback when table data changes */
handleInputChange: (key: string, value: TableRow[]) => void;
/** Callback when input field is clicked (for builder selection) */
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/RunnerInputUI.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/RunnerInputUI.tsx
index bff21c46f2..15983be9f5 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/RunnerInputUI.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/RunnerInputUI.tsx
@@ -1,17 +1,11 @@
-import React, { useCallback } from "react";
+import { useCallback } from "react";
+import { AgentRunDraftView } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view";
+import { Dialog } from "@/components/molecules/Dialog/Dialog";
import type {
CredentialsMetaInput,
GraphMeta,
} from "@/lib/autogpt-server-api/types";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@/components/__legacy__/ui/dialog";
-import { AgentRunDraftView } from "@/app/(platform)/library/agents/[id]/components/OldAgentLibraryView/components/agent-run-draft-view";
interface RunInputDialogProps {
isOpen: boolean;
@@ -70,21 +64,33 @@ export function RunnerInputDialog({
);
return (
-
-
-
- Run your agent
- {graph.name}
-
-
-
+ {
+ if (!open) doClose();
+ },
+ }}
+ onClose={doClose}
+ styling={{
+ maxWidth: "56rem",
+ width: "90vw",
+ }}
+ >
+
+
+
);
}
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/components/legacy-builder/useCopyPaste.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/useCopyPaste.ts
index a4f604506f..13628441fa 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/useCopyPaste.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/components/legacy-builder/useCopyPaste.ts
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { Node, Edge, useReactFlow } from "@xyflow/react";
import { Key, storage } from "@/services/storage/local-storage";
+import { ConnectedEdge } from "./CustomNode/CustomNode";
interface CopyableData {
nodes: Node[];
@@ -111,13 +112,15 @@ export function useCopyPaste(getNextNodeId: () => string) {
(edge: Edge) =>
edge.source === node.id || edge.target === node.id,
)
- .map((edge: Edge) => ({
- edge_id: edge.id,
- source: edge.source,
- target: edge.target,
- sourceHandle: edge.sourceHandle,
- targetHandle: edge.targetHandle,
- }));
+ .map(
+ (edge: Edge): ConnectedEdge => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: edge.sourceHandle!,
+ targetHandle: edge.targetHandle!,
+ }),
+ );
return {
...node,
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts
index d0b488f26c..505303cc1e 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts
@@ -15,6 +15,11 @@ import { useEdgeStore } from "../stores/edgeStore";
import { graphsEquivalent } from "../components/NewControlPanel/NewSaveControl/helpers";
import { useGraphStore } from "../stores/graphStore";
import { useShallow } from "zustand/react/shallow";
+import {
+ draftService,
+ clearTempFlowId,
+ getTempFlowId,
+} from "@/services/builder-draft/draft-service";
export type SaveGraphOptions = {
showToast?: boolean;
@@ -52,12 +57,19 @@ export const useSaveGraph = ({
const { mutateAsync: createNewGraph, isPending: isCreating } =
usePostV1CreateNewGraph({
mutation: {
- onSuccess: (response) => {
+ onSuccess: async (response) => {
const data = response.data as GraphModel;
setQueryStates({
flowID: data.id,
flowVersion: data.version,
});
+
+ const tempFlowId = getTempFlowId();
+ if (tempFlowId) {
+ await draftService.deleteDraft(tempFlowId);
+ clearTempFlowId();
+ }
+
onSuccess?.(data);
if (showToast) {
toast({
@@ -82,12 +94,18 @@ export const useSaveGraph = ({
const { mutateAsync: updateGraph, isPending: isUpdating } =
usePutV1UpdateGraphVersion({
mutation: {
- onSuccess: (response) => {
+ onSuccess: async (response) => {
const data = response.data as GraphModel;
setQueryStates({
flowID: data.id,
flowVersion: data.version,
});
+
+ // Clear the draft for this flow after successful save
+ if (data.id) {
+ await draftService.deleteDraft(data.id);
+ }
+
onSuccess?.(data);
if (showToast) {
toast({
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/helpers.ts
new file mode 100644
index 0000000000..aece7e9811
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/helpers.ts
@@ -0,0 +1,104 @@
+import { GraphInputSchema } from "@/lib/autogpt-server-api";
+import { GraphMetaLike, IncompatibilityInfo } from "./types";
+
+// Helper type for schema properties - the generated types are too loose
+type SchemaProperties = Record;
+type SchemaRequired = string[];
+
+// Helper to safely extract schema properties
+export function getSchemaProperties(schema: unknown): SchemaProperties {
+ if (
+ schema &&
+ typeof schema === "object" &&
+ "properties" in schema &&
+ typeof schema.properties === "object" &&
+ schema.properties !== null
+ ) {
+ return schema.properties as SchemaProperties;
+ }
+ return {};
+}
+
+export function getSchemaRequired(schema: unknown): SchemaRequired {
+ if (
+ schema &&
+ typeof schema === "object" &&
+ "required" in schema &&
+ Array.isArray(schema.required)
+ ) {
+ return schema.required as SchemaRequired;
+ }
+ return [];
+}
+
+/**
+ * Creates the updated agent node inputs for a sub-agent node
+ */
+export function createUpdatedAgentNodeInputs(
+ currentInputs: Record,
+ latestSubGraphVersion: GraphMetaLike,
+): Record {
+ return {
+ ...currentInputs,
+ graph_version: latestSubGraphVersion.version,
+ input_schema: latestSubGraphVersion.input_schema,
+ output_schema: latestSubGraphVersion.output_schema,
+ };
+}
+
+/** Generic edge type that works with both builders:
+ * - New builder uses CustomEdge with (formally) optional handles
+ * - Legacy builder uses ConnectedEdge type with required handles */
+export type EdgeLike = {
+ id: string;
+ source: string;
+ target: string;
+ sourceHandle?: string | null;
+ targetHandle?: string | null;
+};
+
+/**
+ * Determines which edges are broken after an incompatible update.
+ * Works with both legacy ConnectedEdge and new CustomEdge.
+ */
+export function getBrokenEdgeIDs(
+ connections: EdgeLike[],
+ incompatibilities: IncompatibilityInfo,
+ nodeID: string,
+): string[] {
+ const brokenEdgeIDs: string[] = [];
+ const typeMismatchInputNames = new Set(
+ incompatibilities.inputTypeMismatches.map((m) => m.name),
+ );
+
+ connections.forEach((conn) => {
+ // Check if this connection uses a missing input (node is target)
+ if (
+ conn.target === nodeID &&
+ conn.targetHandle &&
+ incompatibilities.missingInputs.includes(conn.targetHandle)
+ ) {
+ brokenEdgeIDs.push(conn.id);
+ }
+
+ // Check if this connection uses an input with a type mismatch (node is target)
+ if (
+ conn.target === nodeID &&
+ conn.targetHandle &&
+ typeMismatchInputNames.has(conn.targetHandle)
+ ) {
+ brokenEdgeIDs.push(conn.id);
+ }
+
+ // Check if this connection uses a missing output (node is source)
+ if (
+ conn.source === nodeID &&
+ conn.sourceHandle &&
+ incompatibilities.missingOutputs.includes(conn.sourceHandle)
+ ) {
+ brokenEdgeIDs.push(conn.id);
+ }
+ });
+
+ return brokenEdgeIDs;
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/index.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/index.ts
new file mode 100644
index 0000000000..7dd1ffa3b0
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/index.ts
@@ -0,0 +1,2 @@
+export { useSubAgentUpdate } from "./useSubAgentUpdate";
+export { createUpdatedAgentNodeInputs, getBrokenEdgeIDs } from "./helpers";
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/types.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/types.ts
new file mode 100644
index 0000000000..83f83155db
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/types.ts
@@ -0,0 +1,27 @@
+import type { GraphMeta as LegacyGraphMeta } from "@/lib/autogpt-server-api";
+import type { GraphMeta as GeneratedGraphMeta } from "@/app/api/__generated__/models/graphMeta";
+
+export type SubAgentUpdateInfo = {
+ hasUpdate: boolean;
+ currentVersion: number;
+ latestVersion: number;
+ latestGraph: T | null;
+ isCompatible: boolean;
+ incompatibilities: IncompatibilityInfo | null;
+};
+
+// Union type for GraphMeta that works with both legacy and new builder
+export type GraphMetaLike = LegacyGraphMeta | GeneratedGraphMeta;
+
+export type IncompatibilityInfo = {
+ missingInputs: string[]; // Connected inputs that no longer exist
+ missingOutputs: string[]; // Connected outputs that no longer exist
+ newInputs: string[]; // Inputs that exist in new version but not in current
+ newOutputs: string[]; // Outputs that exist in new version but not in current
+ newRequiredInputs: string[]; // New required inputs not in current version or not required
+ inputTypeMismatches: Array<{
+ name: string;
+ oldType: string;
+ newType: string;
+ }>; // Connected inputs where the type has changed
+};
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/useSubAgentUpdate.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/useSubAgentUpdate.ts
new file mode 100644
index 0000000000..315e337cd6
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSubAgentUpdate/useSubAgentUpdate.ts
@@ -0,0 +1,160 @@
+import { useMemo } from "react";
+import { GraphInputSchema, GraphOutputSchema } from "@/lib/autogpt-server-api";
+import { getEffectiveType } from "@/lib/utils";
+import { EdgeLike, getSchemaProperties, getSchemaRequired } from "./helpers";
+import {
+ GraphMetaLike,
+ IncompatibilityInfo,
+ SubAgentUpdateInfo,
+} from "./types";
+
+/**
+ * Checks if a newer version of a sub-agent is available and determines compatibility
+ */
+export function useSubAgentUpdate(
+ nodeID: string,
+ graphID: string | undefined,
+ graphVersion: number | undefined,
+ currentInputSchema: GraphInputSchema | undefined,
+ currentOutputSchema: GraphOutputSchema | undefined,
+ connections: EdgeLike[],
+ availableGraphs: T[],
+): SubAgentUpdateInfo {
+ // Find the latest version of the same graph
+ const latestGraph = useMemo(() => {
+ if (!graphID) return null;
+ return availableGraphs.find((graph) => graph.id === graphID) || null;
+ }, [graphID, availableGraphs]);
+
+ // Check if there's an update available
+ const hasUpdate = useMemo(() => {
+ if (!latestGraph || graphVersion === undefined) return false;
+ return latestGraph.version! > graphVersion;
+ }, [latestGraph, graphVersion]);
+
+ // Get connected input and output handles for this specific node
+ const connectedHandles = useMemo(() => {
+ const inputHandles = new Set();
+ const outputHandles = new Set();
+
+ connections.forEach((conn) => {
+ // If this node is the target, the targetHandle is an input on this node
+ if (conn.target === nodeID && conn.targetHandle) {
+ inputHandles.add(conn.targetHandle);
+ }
+ // If this node is the source, the sourceHandle is an output on this node
+ if (conn.source === nodeID && conn.sourceHandle) {
+ outputHandles.add(conn.sourceHandle);
+ }
+ });
+
+ return { inputHandles, outputHandles };
+ }, [connections, nodeID]);
+
+ // Check schema compatibility
+ const compatibilityResult = useMemo((): {
+ isCompatible: boolean;
+ incompatibilities: IncompatibilityInfo | null;
+ } => {
+ if (!hasUpdate || !latestGraph) {
+ return { isCompatible: true, incompatibilities: null };
+ }
+
+ const newInputProps = getSchemaProperties(latestGraph.input_schema);
+ const newOutputProps = getSchemaProperties(latestGraph.output_schema);
+ const newRequiredInputs = getSchemaRequired(latestGraph.input_schema);
+
+ const currentInputProps = getSchemaProperties(currentInputSchema);
+ const currentOutputProps = getSchemaProperties(currentOutputSchema);
+ const currentRequiredInputs = getSchemaRequired(currentInputSchema);
+
+ const incompatibilities: IncompatibilityInfo = {
+ missingInputs: [],
+ missingOutputs: [],
+ newInputs: [],
+ newOutputs: [],
+ newRequiredInputs: [],
+ inputTypeMismatches: [],
+ };
+
+ // Check for missing connected inputs and type mismatches
+ connectedHandles.inputHandles.forEach((inputHandle) => {
+ if (!(inputHandle in newInputProps)) {
+ incompatibilities.missingInputs.push(inputHandle);
+ } else {
+ // Check for type mismatch on connected inputs
+ const currentProp = currentInputProps[inputHandle];
+ const newProp = newInputProps[inputHandle];
+ const currentType = getEffectiveType(currentProp);
+ const newType = getEffectiveType(newProp);
+
+ if (currentType && newType && currentType !== newType) {
+ incompatibilities.inputTypeMismatches.push({
+ name: inputHandle,
+ oldType: currentType,
+ newType: newType,
+ });
+ }
+ }
+ });
+
+ // Check for missing connected outputs
+ connectedHandles.outputHandles.forEach((outputHandle) => {
+ if (!(outputHandle in newOutputProps)) {
+ incompatibilities.missingOutputs.push(outputHandle);
+ }
+ });
+
+ // Check for new required inputs that didn't exist or weren't required before
+ newRequiredInputs.forEach((requiredInput) => {
+ const existedBefore = requiredInput in currentInputProps;
+ const wasRequiredBefore = currentRequiredInputs.includes(
+ requiredInput as string,
+ );
+
+ if (!existedBefore || !wasRequiredBefore) {
+ incompatibilities.newRequiredInputs.push(requiredInput as string);
+ }
+ });
+
+ // Check for new inputs that don't exist in the current version
+ Object.keys(newInputProps).forEach((inputName) => {
+ if (!(inputName in currentInputProps)) {
+ incompatibilities.newInputs.push(inputName);
+ }
+ });
+
+ // Check for new outputs that don't exist in the current version
+ Object.keys(newOutputProps).forEach((outputName) => {
+ if (!(outputName in currentOutputProps)) {
+ incompatibilities.newOutputs.push(outputName);
+ }
+ });
+
+ const hasIncompatibilities =
+ incompatibilities.missingInputs.length > 0 ||
+ incompatibilities.missingOutputs.length > 0 ||
+ incompatibilities.newRequiredInputs.length > 0 ||
+ incompatibilities.inputTypeMismatches.length > 0;
+
+ return {
+ isCompatible: !hasIncompatibilities,
+ incompatibilities: hasIncompatibilities ? incompatibilities : null,
+ };
+ }, [
+ hasUpdate,
+ latestGraph,
+ currentInputSchema,
+ currentOutputSchema,
+ connectedHandles,
+ ]);
+
+ return {
+ hasUpdate,
+ currentVersion: graphVersion || 0,
+ latestVersion: latestGraph?.version || 0,
+ latestGraph,
+ isCompatible: compatibilityResult.isCompatible,
+ incompatibilities: compatibilityResult.incompatibilities,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/page.tsx b/autogpt_platform/frontend/src/app/(platform)/build/page.tsx
index f60c863657..f1d62ee5fb 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/page.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/build/page.tsx
@@ -8,8 +8,8 @@ import { ReactFlowProvider } from "@xyflow/react";
import { useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { BuilderViewTabs } from "./components/BuilderViewTabs/BuilderViewTabs";
-import { useBuilderView } from "./components/BuilderViewTabs/useBuilderViewTabs";
import { Flow } from "./components/FlowEditor/Flow/Flow";
+import { useBuilderView } from "./useBuilderView";
function BuilderContent() {
const query = useSearchParams();
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/blockMenuStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/blockMenuStore.ts
index ea50a03979..31b9eda338 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/blockMenuStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/blockMenuStore.ts
@@ -1,12 +1,30 @@
import { create } from "zustand";
import { DefaultStateType } from "../components/NewControlPanel/NewBlockMenu/types";
+import { SearchResponseItemsItem } from "@/app/api/__generated__/models/searchResponseItemsItem";
+import { getSearchItemType } from "../components/NewControlPanel/NewBlockMenu/BlockMenuSearchContent/helper";
+import { StoreAgent } from "@/app/api/__generated__/models/storeAgent";
+import { GetV2BuilderSearchFilterAnyOfItem } from "@/app/api/__generated__/models/getV2BuilderSearchFilterAnyOfItem";
type BlockMenuStore = {
searchQuery: string;
searchId: string | undefined;
defaultState: DefaultStateType;
integration: string | undefined;
+ filters: GetV2BuilderSearchFilterAnyOfItem[];
+ creators: string[];
+ creators_list: string[];
+ categoryCounts: Record;
+ setCategoryCounts: (
+ counts: Record,
+ ) => void;
+ setCreatorsList: (searchData: SearchResponseItemsItem[]) => void;
+ addCreator: (creator: string) => void;
+ setCreators: (creators: string[]) => void;
+ removeCreator: (creator: string) => void;
+ addFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
+ setFilters: (filters: GetV2BuilderSearchFilterAnyOfItem[]) => void;
+ removeFilter: (filter: GetV2BuilderSearchFilterAnyOfItem) => void;
setSearchQuery: (query: string) => void;
setSearchId: (id: string | undefined) => void;
setDefaultState: (state: DefaultStateType) => void;
@@ -19,11 +37,44 @@ export const useBlockMenuStore = create((set) => ({
searchId: undefined,
defaultState: DefaultStateType.SUGGESTION,
integration: undefined,
+ filters: [],
+ creators: [], // creator filters that are applied to the search results
+ creators_list: [], // all creators that are available to filter by
+ categoryCounts: {
+ blocks: 0,
+ integrations: 0,
+ marketplace_agents: 0,
+ my_agents: 0,
+ },
+ setCategoryCounts: (counts) => set({ categoryCounts: counts }),
+ setCreatorsList: (searchData) => {
+ const marketplaceAgents = searchData.filter((item) => {
+ return getSearchItemType(item).type === "store_agent";
+ }) as StoreAgent[];
+
+ const newCreators = marketplaceAgents.map((agent) => agent.creator);
+
+ set((state) => ({
+ creators_list: Array.from(
+ new Set([...state.creators_list, ...newCreators]),
+ ),
+ }));
+ },
+ setCreators: (creators) => set({ creators }),
+ setFilters: (filters) => set({ filters }),
setSearchQuery: (query) => set({ searchQuery: query }),
setSearchId: (id) => set({ searchId: id }),
setDefaultState: (state) => set({ defaultState: state }),
setIntegration: (integration) => set({ integration }),
+ addFilter: (filter) =>
+ set((state) => ({ filters: [...state.filters, filter] })),
+ removeFilter: (filter) =>
+ set((state) => ({ filters: state.filters.filter((f) => f !== filter) })),
+ addCreator: (creator) =>
+ set((state) => ({ creators: [...state.creators, creator] })),
+ removeCreator: (creator) =>
+ set((state) => ({ creators: state.creators.filter((c) => c !== creator) })),
reset: () =>
set({
searchQuery: "",
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 0d0e4202fb..6a45b9e1e2 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/edgeStore.ts
@@ -4,6 +4,9 @@ import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
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[];
@@ -13,6 +16,8 @@ type EdgeStore = {
removeEdge: (edgeId: string) => void;
upsertMany: (edges: CustomEdge[]) => void;
+ removeEdgesByHandlePrefix: (nodeId: string, handlePrefix: string) => void;
+
getNodeEdges: (nodeId: string) => CustomEdge[];
isInputConnected: (nodeId: string, handle: string) => boolean;
isOutputConnected: (nodeId: string, handle: string) => boolean;
@@ -50,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) => {
@@ -79,11 +95,27 @@ export const useEdgeStore = create((set, get) => ({
return { edges: Array.from(byKey.values()) };
}),
+ removeEdgesByHandlePrefix: (nodeId, handlePrefix) =>
+ set((state) => ({
+ edges: state.edges.filter(
+ (e) =>
+ !(
+ e.target === nodeId &&
+ e.targetHandle &&
+ e.targetHandle.startsWith(handlePrefix)
+ ),
+ ),
+ })),
+
getNodeEdges: (nodeId) =>
get().edges.filter((e) => e.source === nodeId || e.target === nodeId),
- isInputConnected: (nodeId, handle) =>
- get().edges.some((e) => e.target === nodeId && e.targetHandle === handle),
+ isInputConnected: (nodeId, handle) => {
+ const cleanedHandle = cleanUpHandleId(handle);
+ return get().edges.some(
+ (e) => e.target === nodeId && e.targetHandle === cleanedHandle,
+ );
+ },
isOutputConnected: (nodeId, handle) =>
get().edges.some((e) => e.source === nodeId && e.sourceHandle === handle),
@@ -105,20 +137,21 @@ export const useEdgeStore = create((set, get) => ({
targetNodeId: string,
executionResult: NodeExecutionResult,
) => {
- set((state) => ({
- edges: state.edges.map((edge) => {
+ set((state) => {
+ let hasChanges = false;
+
+ const newEdges = state.edges.map((edge) => {
if (edge.target !== targetNodeId) {
return edge;
}
- const beadData =
- edge.data?.beadData ??
- new Map();
+ const beadData = new Map(edge.data?.beadData ?? new Map());
- if (
- edge.targetHandle &&
- edge.targetHandle in executionResult.input_data
- ) {
+ const inputValue = edge.targetHandle
+ ? executionResult.input_data[edge.targetHandle]
+ : undefined;
+
+ if (inputValue !== undefined && inputValue !== null) {
beadData.set(executionResult.node_exec_id, executionResult.status);
}
@@ -136,6 +169,11 @@ export const useEdgeStore = create((set, get) => ({
beadUp = beadDown + 1;
}
+ if (edge.data?.beadUp === beadUp && edge.data?.beadDown === beadDown) {
+ return edge;
+ }
+
+ hasChanges = true;
return {
...edge,
data: {
@@ -145,8 +183,10 @@ export const useEdgeStore = create((set, get) => ({
beadData,
},
};
- }),
- }));
+ });
+
+ return hasChanges ? { edges: newEdges } : state;
+ });
},
resetEdgeBeads: () => {
diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/graphStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/graphStore.ts
index 44142881a8..6961884732 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/stores/graphStore.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/graphStore.ts
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
+import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
interface GraphStore {
graphExecutionStatus: AgentExecutionStatus | undefined;
@@ -17,6 +18,10 @@ interface GraphStore {
outputSchema: Record | null,
) => void;
+ // Available graphs; used for sub-graph updates
+ availableSubGraphs: GraphMeta[];
+ setAvailableSubGraphs: (graphs: GraphMeta[]) => void;
+
hasInputs: () => boolean;
hasCredentials: () => boolean;
hasOutputs: () => boolean;
@@ -29,6 +34,7 @@ export const useGraphStore = create((set, get) => ({
inputSchema: null,
credentialsInputSchema: null,
outputSchema: null,
+ availableSubGraphs: [],
setGraphExecutionStatus: (status: AgentExecutionStatus | undefined) => {
set({
@@ -46,6 +52,8 @@ export const useGraphStore = create((set, get) => ({
setGraphSchemas: (inputSchema, credentialsInputSchema, outputSchema) =>
set({ inputSchema, credentialsInputSchema, outputSchema }),
+ setAvailableSubGraphs: (graphs) => set({ availableSubGraphs: graphs }),
+
hasOutputs: () => {
const { outputSchema } = get();
return Object.keys(outputSchema?.properties ?? {}).length > 0;
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 2f41c3bb46..5502a8780d 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,
@@ -13,6 +14,29 @@ import { useHistoryStore } from "./historyStore";
import { useEdgeStore } from "./edgeStore";
import { BlockUIType } from "../components/types";
import { pruneEmptyValues } from "@/lib/utils";
+import {
+ ensurePathExists,
+ parseHandleIdToPath,
+} from "@/components/renderers/InputRenderer/helpers";
+import { IncompatibilityInfo } from "../hooks/useSubAgentUpdate/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
@@ -21,9 +45,12 @@ 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;
setNodes: (nodes: CustomNode[]) => void;
onNodesChange: (changes: NodeChange[]) => void;
@@ -61,27 +88,59 @@ type NodeStore = {
backendId: string,
errors: { [key: string]: string },
) => void;
- clearAllNodeErrors: () => void; // Add this
+
+ syncHardcodedValuesWithHandleIds: (nodeId: string) => void;
+
+ setCredentialsOptional: (nodeId: string, optional: boolean) => void;
+ clearAllNodeErrors: () => void;
+
+ nodesInResolutionMode: Set;
+ brokenEdgeIDs: Map>;
+ nodeResolutionData: Map;
+ setNodeResolutionMode: (
+ nodeID: string,
+ inResolution: boolean,
+ resolutionData?: NodeResolutionData,
+ ) => void;
+ isNodeInResolutionMode: (nodeID: string) => boolean;
+ getNodeResolutionData: (nodeID: string) => NodeResolutionData | undefined;
+ setBrokenEdgeIDs: (nodeID: string, edgeIDs: string[]) => void;
+ removeBrokenEdgeID: (nodeID: string, edgeID: string) => void;
+ isEdgeBroken: (edgeID: string) => boolean;
+ clearResolutionState: () => void;
+
+ isInputBroken: (nodeID: string, handleID: string) => boolean;
+ getInputTypeMismatch: (
+ nodeID: string,
+ handleID: string,
+ ) => string | undefined;
};
export const useNodeStore = create((set, get) => ({
nodes: [],
setNodes: (nodes) => set({ nodes }),
nodeCounter: 0,
+ setNodeCounter: (nodeCounter) => set({ nodeCounter }),
nodeAdvancedStates: {},
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) {
@@ -91,12 +150,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) {
@@ -108,20 +172,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);
}
},
@@ -135,6 +202,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,
@@ -168,21 +240,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) => ({
@@ -220,6 +295,9 @@ export const useNodeStore = create((set, get) => ({
...(node.data.metadata?.customized_name !== undefined && {
customized_name: node.data.metadata.customized_name,
}),
+ ...(node.data.metadata?.credentials_optional !== undefined && {
+ credentials_optional: node.data.metadata.credentials_optional,
+ }),
},
};
},
@@ -305,4 +383,156 @@ export const useNodeStore = create((set, get) => ({
})),
}));
},
+
+ syncHardcodedValuesWithHandleIds: (nodeId: string) => {
+ const node = get().nodes.find((n) => n.id === nodeId);
+ if (!node) return;
+
+ const handleIds = useEdgeStore.getState().getAllHandleIdsOfANode(nodeId);
+ const additionalHandles = handleIds.filter((h) => h.includes("_#_"));
+
+ if (additionalHandles.length === 0) return;
+
+ const hardcodedValues = JSON.parse(
+ JSON.stringify(node.data.hardcodedValues || {}),
+ );
+
+ let modified = false;
+
+ additionalHandles.forEach((handleId) => {
+ const segments = parseHandleIdToPath(handleId);
+ if (ensurePathExists(hardcodedValues, segments)) {
+ modified = true;
+ }
+ });
+
+ if (modified) {
+ set((state) => ({
+ nodes: state.nodes.map((n) =>
+ n.id === nodeId ? { ...n, data: { ...n.data, hardcodedValues } } : n,
+ ),
+ }));
+ }
+ },
+
+ setCredentialsOptional: (nodeId: string, optional: boolean) => {
+ const prevState = {
+ nodes: get().nodes,
+ edges: useEdgeStore.getState().edges,
+ };
+
+ set((state) => ({
+ nodes: state.nodes.map((n) =>
+ n.id === nodeId
+ ? {
+ ...n,
+ data: {
+ ...n.data,
+ metadata: {
+ ...n.data.metadata,
+ credentials_optional: optional,
+ },
+ },
+ }
+ : n,
+ ),
+ }));
+
+ useHistoryStore.getState().pushState(prevState);
+ },
+
+ // Sub-agent resolution mode state
+ nodesInResolutionMode: new Set(),
+ brokenEdgeIDs: new Map>(),
+ nodeResolutionData: new Map(),
+
+ setNodeResolutionMode: (
+ nodeID: string,
+ inResolution: boolean,
+ resolutionData?: NodeResolutionData,
+ ) => {
+ set((state) => {
+ const newNodesSet = new Set(state.nodesInResolutionMode);
+ const newResolutionDataMap = new Map(state.nodeResolutionData);
+ const newBrokenEdgeIDs = new Map(state.brokenEdgeIDs);
+
+ if (inResolution) {
+ newNodesSet.add(nodeID);
+ if (resolutionData) {
+ newResolutionDataMap.set(nodeID, resolutionData);
+ }
+ } else {
+ newNodesSet.delete(nodeID);
+ newResolutionDataMap.delete(nodeID);
+ newBrokenEdgeIDs.delete(nodeID); // Clean up broken edges when exiting resolution mode
+ }
+
+ return {
+ nodesInResolutionMode: newNodesSet,
+ nodeResolutionData: newResolutionDataMap,
+ brokenEdgeIDs: newBrokenEdgeIDs,
+ };
+ });
+ },
+
+ isNodeInResolutionMode: (nodeID: string) => {
+ return get().nodesInResolutionMode.has(nodeID);
+ },
+
+ getNodeResolutionData: (nodeID: string) => {
+ return get().nodeResolutionData.get(nodeID);
+ },
+
+ setBrokenEdgeIDs: (nodeID: string, edgeIDs: string[]) => {
+ set((state) => {
+ const newMap = new Map(state.brokenEdgeIDs);
+ newMap.set(nodeID, new Set(edgeIDs));
+ return { brokenEdgeIDs: newMap };
+ });
+ },
+
+ removeBrokenEdgeID: (nodeID: string, edgeID: string) => {
+ set((state) => {
+ const newMap = new Map(state.brokenEdgeIDs);
+ const nodeSet = new Set(newMap.get(nodeID) || []);
+ nodeSet.delete(edgeID);
+ newMap.set(nodeID, nodeSet);
+ return { brokenEdgeIDs: newMap };
+ });
+ },
+
+ isEdgeBroken: (edgeID: string) => {
+ // Check across all nodes
+ const brokenEdgeIDs = get().brokenEdgeIDs;
+ for (const edgeSet of brokenEdgeIDs.values()) {
+ if (edgeSet.has(edgeID)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ clearResolutionState: () => {
+ set({
+ nodesInResolutionMode: new Set(),
+ brokenEdgeIDs: new Map>(),
+ nodeResolutionData: new Map(),
+ });
+ },
+
+ // Helper functions for input renderers
+ isInputBroken: (nodeID: string, handleID: string) => {
+ const resolutionData = get().nodeResolutionData.get(nodeID);
+ if (!resolutionData) return false;
+ return resolutionData.incompatibilities.missingInputs.includes(handleID);
+ },
+
+ getInputTypeMismatch: (nodeID: string, handleID: string) => {
+ const resolutionData = get().nodeResolutionData.get(nodeID);
+ if (!resolutionData) return undefined;
+ const mismatch = resolutionData.incompatibilities.inputTypeMismatches.find(
+ (m) => m.name === handleID,
+ );
+ return mismatch?.newType;
+ },
}));
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/components/BuilderViewTabs/useBuilderViewTabs.ts b/autogpt_platform/frontend/src/app/(platform)/build/useBuilderView.ts
similarity index 95%
rename from autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/useBuilderViewTabs.ts
rename to autogpt_platform/frontend/src/app/(platform)/build/useBuilderView.ts
index ac02becca5..e0e524ddf8 100644
--- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderViewTabs/useBuilderViewTabs.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/build/useBuilderView.ts
@@ -1,7 +1,7 @@
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo } from "react";
-import { BuilderView } from "./BuilderViewTabs";
+import { BuilderView } from "./components/BuilderViewTabs/BuilderViewTabs";
export function useBuilderView() {
const isNewFlowEditorEnabled = useGetFlag(Flag.NEW_FLOW_EDITOR);
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/Chat.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/Chat.tsx
new file mode 100644
index 0000000000..461c885dc3
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/Chat.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { Button } from "@/components/atoms/Button/Button";
+import { Text } from "@/components/atoms/Text/Text";
+import { cn } from "@/lib/utils";
+import { List } from "@phosphor-icons/react";
+import React, { useState } from "react";
+import { ChatContainer } from "./components/ChatContainer/ChatContainer";
+import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
+import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState";
+import { SessionsDrawer } from "./components/SessionsDrawer/SessionsDrawer";
+import { useChat } from "./useChat";
+
+export interface ChatProps {
+ className?: string;
+ headerTitle?: React.ReactNode;
+ showHeader?: boolean;
+ showSessionInfo?: boolean;
+ showNewChatButton?: boolean;
+ onNewChat?: () => void;
+ headerActions?: React.ReactNode;
+}
+
+export function Chat({
+ className,
+ headerTitle = "AutoGPT Copilot",
+ showHeader = true,
+ showSessionInfo = true,
+ showNewChatButton = true,
+ onNewChat,
+ headerActions,
+}: ChatProps) {
+ const {
+ messages,
+ isLoading,
+ isCreating,
+ error,
+ sessionId,
+ createSession,
+ clearSession,
+ loadSession,
+ } = useChat();
+
+ const [isSessionsDrawerOpen, setIsSessionsDrawerOpen] = useState(false);
+
+ const handleNewChat = () => {
+ clearSession();
+ onNewChat?.();
+ };
+
+ const handleSelectSession = async (sessionId: string) => {
+ try {
+ await loadSession(sessionId);
+ } catch (err) {
+ console.error("Failed to load session:", err);
+ }
+ };
+
+ return (
+
+ {/* Header */}
+ {showHeader && (
+
+ )}
+
+ {/* 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 && (
+
+ )}
+
+
+ {/* Sessions Drawer */}
+
setIsSessionsDrawerOpen(false)}
+ onSelectSession={handleSelectSession}
+ currentSessionId={sessionId}
+ />
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx
similarity index 65%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx
index 125f834e05..582b24de5e 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AgentCarouselMessage/AgentCarouselMessage.tsx
@@ -1,15 +1,16 @@
-import React from "react";
-import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import { Card } from "@/components/atoms/Card/Card";
-import { List, Robot, ArrowRight } from "@phosphor-icons/react";
+import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
+import { ArrowRight, List, Robot } from "@phosphor-icons/react";
+import Image from "next/image";
export interface Agent {
id: string;
name: string;
description: string;
version?: number;
+ image_url?: string;
}
export interface AgentCarouselMessageProps {
@@ -30,7 +31,7 @@ export function AgentCarouselMessage({
return (
@@ -40,13 +41,10 @@ export function AgentCarouselMessage({
-
+
Found {displayCount} {displayCount === 1 ? "Agent" : "Agents"}
-
+
Select an agent to view details or run it
@@ -57,40 +55,49 @@ export function AgentCarouselMessage({
{agents.map((agent) => (
-
-
+
+ {agent.image_url ? (
+
+ ) : (
+
+
+
+ )}
{agent.name}
{agent.version && (
-
+
v{agent.version}
)}
-
+
{agent.description}
{onSelectAgent && (
onSelectAgent(agent.id)}
variant="ghost"
- className="mt-2 flex items-center gap-1 p-0 text-sm text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-200"
+ className="mt-2 flex items-center gap-1 p-0 text-sm text-purple-600 hover:text-purple-800"
>
View details
@@ -103,10 +110,7 @@ export function AgentCarouselMessage({
{totalCount && totalCount > agents.length && (
-
+
Showing {agents.length} of {totalCount} results
)}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AgentInputsSetup/AgentInputsSetup.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AgentInputsSetup/AgentInputsSetup.tsx
new file mode 100644
index 0000000000..3ef71eca09
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AgentInputsSetup/AgentInputsSetup.tsx
@@ -0,0 +1,246 @@
+"use client";
+
+import { Button } from "@/components/atoms/Button/Button";
+import { Card } from "@/components/atoms/Card/Card";
+import { Text } from "@/components/atoms/Text/Text";
+import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
+import { RunAgentInputs } from "@/components/contextual/RunAgentInputs/RunAgentInputs";
+
+import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
+import {
+ BlockIOCredentialsSubSchema,
+ BlockIOSubSchema,
+} from "@/lib/autogpt-server-api/types";
+import { cn, isEmpty } from "@/lib/utils";
+import { PlayIcon, WarningIcon } from "@phosphor-icons/react";
+import { useMemo } from "react";
+import { useAgentInputsSetup } from "./useAgentInputsSetup";
+
+type LibraryAgentInputSchemaProperties = LibraryAgent["input_schema"] extends {
+ properties: infer P;
+}
+ ? P extends Record
+ ? P
+ : Record
+ : Record;
+
+type LibraryAgentCredentialsInputSchemaProperties =
+ LibraryAgent["credentials_input_schema"] extends {
+ properties: infer P;
+ }
+ ? P extends Record
+ ? P
+ : Record
+ : Record;
+
+interface Props {
+ agentName?: string;
+ inputSchema: LibraryAgentInputSchemaProperties | Record;
+ credentialsSchema?:
+ | LibraryAgentCredentialsInputSchemaProperties
+ | Record;
+ message: string;
+ requiredFields?: string[];
+ onRun: (
+ inputs: Record,
+ credentials: Record,
+ ) => void;
+ onCancel?: () => void;
+ className?: string;
+}
+
+export function AgentInputsSetup({
+ agentName,
+ inputSchema,
+ credentialsSchema,
+ message,
+ requiredFields,
+ onRun,
+ onCancel,
+ className,
+}: Props) {
+ const { inputValues, setInputValue, credentialsValues, setCredentialsValue } =
+ useAgentInputsSetup();
+
+ const inputSchemaObj = useMemo(() => {
+ if (!inputSchema) return { properties: {}, required: [] };
+ if ("properties" in inputSchema && "type" in inputSchema) {
+ return inputSchema as {
+ properties: Record;
+ required?: string[];
+ };
+ }
+ return { properties: inputSchema as Record, required: [] };
+ }, [inputSchema]);
+
+ const credentialsSchemaObj = useMemo(() => {
+ if (!credentialsSchema) return { properties: {}, required: [] };
+ if ("properties" in credentialsSchema && "type" in credentialsSchema) {
+ return credentialsSchema as {
+ properties: Record;
+ required?: string[];
+ };
+ }
+ return {
+ properties: credentialsSchema as Record,
+ required: [],
+ };
+ }, [credentialsSchema]);
+
+ const agentInputFields = useMemo(() => {
+ const properties = inputSchemaObj.properties || {};
+ return Object.fromEntries(
+ Object.entries(properties).filter(
+ ([_, subSchema]: [string, any]) => !subSchema.hidden,
+ ),
+ );
+ }, [inputSchemaObj]);
+
+ const agentCredentialsInputFields = useMemo(() => {
+ return credentialsSchemaObj.properties || {};
+ }, [credentialsSchemaObj]);
+
+ const inputFields = Object.entries(agentInputFields);
+ const credentialFields = Object.entries(agentCredentialsInputFields);
+
+ const defaultsFromSchema = useMemo(() => {
+ const defaults: Record = {};
+ Object.entries(agentInputFields).forEach(([key, schema]) => {
+ if ("default" in schema && schema.default !== undefined) {
+ defaults[key] = schema.default;
+ }
+ });
+ return defaults;
+ }, [agentInputFields]);
+
+ const defaultsFromCredentialsSchema = useMemo(() => {
+ const defaults: Record = {};
+ Object.entries(agentCredentialsInputFields).forEach(([key, schema]) => {
+ if ("default" in schema && schema.default !== undefined) {
+ defaults[key] = schema.default;
+ }
+ });
+ return defaults;
+ }, [agentCredentialsInputFields]);
+
+ const mergedInputValues = useMemo(() => {
+ return { ...defaultsFromSchema, ...inputValues };
+ }, [defaultsFromSchema, inputValues]);
+
+ const mergedCredentialsValues = useMemo(() => {
+ return { ...defaultsFromCredentialsSchema, ...credentialsValues };
+ }, [defaultsFromCredentialsSchema, credentialsValues]);
+
+ const allRequiredInputsAreSet = useMemo(() => {
+ const requiredInputs = new Set(
+ requiredFields || (inputSchemaObj.required as string[]) || [],
+ );
+ const nonEmptyInputs = new Set(
+ Object.keys(mergedInputValues).filter(
+ (k) => !isEmpty(mergedInputValues[k]),
+ ),
+ );
+ const missing = [...requiredInputs].filter(
+ (input) => !nonEmptyInputs.has(input),
+ );
+ return missing.length === 0;
+ }, [inputSchemaObj.required, mergedInputValues, requiredFields]);
+
+ const allCredentialsAreSet = useMemo(() => {
+ const requiredCredentials = new Set(
+ (credentialsSchemaObj.required as string[]) || [],
+ );
+ if (requiredCredentials.size === 0) {
+ return true;
+ }
+ const missing = [...requiredCredentials].filter((key) => {
+ const cred = mergedCredentialsValues[key];
+ return !cred || !cred.id;
+ });
+ return missing.length === 0;
+ }, [credentialsSchemaObj.required, mergedCredentialsValues]);
+
+ const canRun = allRequiredInputsAreSet && allCredentialsAreSet;
+
+ function handleRun() {
+ if (canRun) {
+ onRun(mergedInputValues, mergedCredentialsValues);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {agentName ? `Configure ${agentName}` : "Agent Configuration"}
+
+
+ {message}
+
+
+ {inputFields.length > 0 && (
+
+ {inputFields.map(([key, inputSubSchema]) => (
+ setInputValue(key, value)}
+ />
+ ))}
+
+ )}
+
+ {credentialFields.length > 0 && (
+
+ {credentialFields.map(([key, schema]) => {
+ const requiredCredentials = new Set(
+ (credentialsSchemaObj.required as string[]) || [],
+ );
+ return (
+
+ setCredentialsValue(key, value)
+ }
+ siblingInputs={mergedInputValues}
+ isOptional={!requiredCredentials.has(key)}
+ />
+ );
+ })}
+
+ )}
+
+
+
+
+ Run Agent
+
+ {onCancel && (
+
+ Cancel
+
+ )}
+
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AgentInputsSetup/useAgentInputsSetup.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AgentInputsSetup/useAgentInputsSetup.ts
new file mode 100644
index 0000000000..e36a3f3c5d
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AgentInputsSetup/useAgentInputsSetup.ts
@@ -0,0 +1,38 @@
+import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
+import { useState } from "react";
+
+export function useAgentInputsSetup() {
+ const [inputValues, setInputValues] = useState>({});
+ const [credentialsValues, setCredentialsValues] = useState<
+ Record
+ >({});
+
+ function setInputValue(key: string, value: any) {
+ setInputValues((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ }
+
+ function setCredentialsValue(key: string, value?: CredentialsMetaInput) {
+ if (value) {
+ setCredentialsValues((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ } else {
+ setCredentialsValues((prev) => {
+ const next = { ...prev };
+ delete next[key];
+ return next;
+ });
+ }
+ }
+
+ return {
+ inputValues,
+ setInputValue,
+ credentialsValues,
+ setCredentialsValue,
+ };
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/AuthPromptWidget/AuthPromptWidget.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AuthPromptWidget/AuthPromptWidget.tsx
similarity index 84%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/AuthPromptWidget/AuthPromptWidget.tsx
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AuthPromptWidget/AuthPromptWidget.tsx
index 885a06e92a..33f02e660f 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/AuthPromptWidget/AuthPromptWidget.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/AuthPromptWidget/AuthPromptWidget.tsx
@@ -1,10 +1,9 @@
"use client";
-import React from "react";
-import { useRouter } from "next/navigation";
import { Button } from "@/components/atoms/Button/Button";
-import { SignInIcon, UserPlusIcon, ShieldIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
+import { ShieldIcon, SignInIcon, UserPlusIcon } from "@phosphor-icons/react";
+import { useRouter } from "next/navigation";
export interface AuthPromptWidgetProps {
message: string;
@@ -54,8 +53,8 @@ export function AuthPromptWidget({
return (
-
+
Authentication Required
-
+
Sign in to set up and manage agents
-
-
- {message}
-
+
+
{message}
{agentInfo && (
-
+
Ready to set up:{" "}
{agentInfo.name}
@@ -114,7 +111,7 @@ export function AuthPromptWidget({
-
+
Your chat session will be preserved after signing in
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/ChatContainer.tsx
new file mode 100644
index 0000000000..6f7a0e8f51
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/ChatContainer.tsx
@@ -0,0 +1,88 @@
+import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
+import { cn } from "@/lib/utils";
+import { useCallback } from "react";
+import { usePageContext } from "../../usePageContext";
+import { ChatInput } from "../ChatInput/ChatInput";
+import { MessageList } from "../MessageList/MessageList";
+import { QuickActionsWelcome } from "../QuickActionsWelcome/QuickActionsWelcome";
+import { useChatContainer } from "./useChatContainer";
+
+export interface ChatContainerProps {
+ sessionId: string | null;
+ initialMessages: SessionDetailResponse["messages"];
+ className?: string;
+}
+
+export function ChatContainer({
+ sessionId,
+ initialMessages,
+ className,
+}: ChatContainerProps) {
+ const { messages, streamingChunks, isStreaming, sendMessage } =
+ useChatContainer({
+ sessionId,
+ initialMessages,
+ });
+ const { capturePageContext } = usePageContext();
+
+ // Wrap sendMessage to automatically capture page context
+ const sendMessageWithContext = useCallback(
+ async (content: string, isUserMessage: boolean = true) => {
+ const context = capturePageContext();
+ await sendMessage(content, isUserMessage, context);
+ },
+ [sendMessage, capturePageContext],
+ );
+
+ 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/createStreamEventDispatcher.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/createStreamEventDispatcher.ts
similarity index 95%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/createStreamEventDispatcher.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/createStreamEventDispatcher.ts
index b8421c3386..844f126d49 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/createStreamEventDispatcher.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/createStreamEventDispatcher.ts
@@ -1,14 +1,14 @@
import { toast } from "sonner";
-import type { StreamChunk } from "@/app/(platform)/chat/useChatStream";
+import { StreamChunk } from "../../useChatStream";
import type { HandlerDependencies } from "./useChatContainer.handlers";
import {
+ handleError,
+ handleLoginNeeded,
+ handleStreamEnd,
handleTextChunk,
handleTextEnded,
handleToolCallStart,
handleToolResponse,
- handleLoginNeeded,
- handleStreamEnd,
- handleError,
} from "./useChatContainer.handlers";
export function createStreamEventDispatcher(
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/helpers.ts
similarity index 68%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/helpers.ts
index 3a94dab1ea..cd05563369 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/helpers.ts
@@ -1,5 +1,24 @@
-import type { ChatMessageData } from "@/app/(platform)/chat/components/ChatMessage/useChatMessage";
import type { ToolResult } from "@/types/chat";
+import type { ChatMessageData } from "../ChatMessage/useChatMessage";
+
+export function removePageContext(content: string): string {
+ // Remove "Page URL: ..." pattern at start of line (case insensitive, handles various formats)
+ let cleaned = content.replace(/^\s*Page URL:\s*[^\n\r]*/gim, "");
+
+ // Find "User Message:" marker at start of line to preserve the actual user message
+ const userMessageMatch = cleaned.match(/^\s*User Message:\s*([\s\S]*)$/im);
+ if (userMessageMatch) {
+ // If we found "User Message:", extract everything after it
+ cleaned = userMessageMatch[1];
+ } else {
+ // If no "User Message:" marker, remove "Page Content:" and everything after it at start of line
+ cleaned = cleaned.replace(/^\s*Page Content:[\s\S]*$/gim, "");
+ }
+
+ // Clean up extra whitespace and newlines
+ cleaned = cleaned.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
+ return cleaned;
+}
export function createUserMessage(content: string): ChatMessageData {
return {
@@ -63,6 +82,7 @@ export function isAgentArray(value: unknown): value is Array<{
name: string;
description: string;
version?: number;
+ image_url?: string;
}> {
if (!Array.isArray(value)) {
return false;
@@ -77,7 +97,8 @@ export function isAgentArray(value: unknown): value is Array<{
typeof item.name === "string" &&
"description" in item &&
typeof item.description === "string" &&
- (!("version" in item) || typeof item.version === "number"),
+ (!("version" in item) || typeof item.version === "number") &&
+ (!("image_url" in item) || typeof item.image_url === "string"),
);
}
@@ -232,6 +253,7 @@ export function isSetupInfo(value: unknown): value is {
export function extractCredentialsNeeded(
parsedResult: Record
,
+ toolName: string = "run_agent",
): ChatMessageData | null {
try {
const setupInfo = parsedResult?.setup_info as
@@ -244,7 +266,7 @@ export function extractCredentialsNeeded(
| Record>
| undefined;
if (missingCreds && Object.keys(missingCreds).length > 0) {
- const agentName = (setupInfo?.agent_name as string) || "this agent";
+ const agentName = (setupInfo?.agent_name as string) || "this block";
const credentials = Object.values(missingCreds).map((credInfo) => ({
provider: (credInfo.provider as string) || "unknown",
providerName:
@@ -264,7 +286,7 @@ export function extractCredentialsNeeded(
}));
return {
type: "credentials_needed",
- toolName: "run_agent",
+ toolName,
credentials,
message: `To run ${agentName}, you need to add ${credentials.length === 1 ? "credentials" : `${credentials.length} credentials`}.`,
agentName,
@@ -277,3 +299,92 @@ export function extractCredentialsNeeded(
return null;
}
}
+
+export function extractInputsNeeded(
+ parsedResult: Record,
+ toolName: string = "run_agent",
+): ChatMessageData | null {
+ try {
+ const setupInfo = parsedResult?.setup_info as
+ | Record
+ | undefined;
+ const requirements = setupInfo?.requirements as
+ | Record
+ | undefined;
+ const inputs = requirements?.inputs as
+ | Array>
+ | undefined;
+ const credentials = requirements?.credentials as
+ | Array>
+ | undefined;
+
+ if (!inputs || inputs.length === 0) {
+ return null;
+ }
+
+ const agentName = (setupInfo?.agent_name as string) || "this agent";
+ const agentId = parsedResult?.graph_id as string | undefined;
+ const graphVersion = parsedResult?.graph_version as number | undefined;
+
+ const properties: Record = {};
+ const requiredProps: string[] = [];
+ inputs.forEach((input) => {
+ const name = input.name as string;
+ if (name) {
+ properties[name] = {
+ title: input.name as string,
+ description: (input.description as string) || "",
+ type: (input.type as string) || "string",
+ default: input.default,
+ enum: input.options,
+ format: input.format,
+ };
+ if ((input.required as boolean) === true) {
+ requiredProps.push(name);
+ }
+ }
+ });
+
+ const inputSchema: Record = {
+ type: "object",
+ properties,
+ };
+ if (requiredProps.length > 0) {
+ inputSchema.required = requiredProps;
+ }
+
+ const credentialsSchema: Record = {};
+ if (credentials && credentials.length > 0) {
+ credentials.forEach((cred) => {
+ const id = cred.id as string;
+ if (id) {
+ credentialsSchema[id] = {
+ type: "object",
+ properties: {},
+ credentials_provider: [cred.provider as string],
+ credentials_types: [(cred.type as string) || "api_key"],
+ credentials_scopes: cred.scopes as string[] | undefined,
+ };
+ }
+ });
+ }
+
+ return {
+ type: "inputs_needed",
+ toolName,
+ agentName,
+ agentId,
+ graphVersion,
+ inputSchema,
+ credentialsSchema:
+ Object.keys(credentialsSchema).length > 0
+ ? credentialsSchema
+ : undefined,
+ message: `Please provide the required inputs to run ${agentName}.`,
+ timestamp: new Date(),
+ };
+ } catch (err) {
+ console.error("Failed to extract inputs 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/Chat/components/ChatContainer/useChatContainer.handlers.ts
similarity index 85%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/useChatContainer.handlers.ts
index fdbecb5d61..064b847064 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/useChatContainer.handlers.ts
@@ -1,13 +1,18 @@
-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";
+import type { Dispatch, MutableRefObject, SetStateAction } from "react";
+import { StreamChunk } from "../../useChatStream";
+import type { ChatMessageData } from "../ChatMessage/useChatMessage";
+import {
+ extractCredentialsNeeded,
+ extractInputsNeeded,
+ parseToolResponse,
+} from "./helpers";
export interface HandlerDependencies {
setHasTextChunks: Dispatch>;
setStreamingChunks: Dispatch>;
streamingChunksRef: MutableRefObject;
setMessages: Dispatch>;
+ setIsStreamingInitiated: Dispatch>;
sessionId: string;
}
@@ -100,11 +105,18 @@ export function handleToolResponse(
parsedResult = null;
}
if (
- chunk.tool_name === "run_agent" &&
+ (chunk.tool_name === "run_agent" || chunk.tool_name === "run_block") &&
chunk.success &&
parsedResult?.type === "setup_requirements"
) {
- const credentialsMessage = extractCredentialsNeeded(parsedResult);
+ const inputsMessage = extractInputsNeeded(parsedResult, chunk.tool_name);
+ if (inputsMessage) {
+ deps.setMessages((prev) => [...prev, inputsMessage]);
+ }
+ const credentialsMessage = extractCredentialsNeeded(
+ parsedResult,
+ chunk.tool_name,
+ );
if (credentialsMessage) {
deps.setMessages((prev) => [...prev, credentialsMessage]);
}
@@ -197,10 +209,15 @@ export function handleStreamEnd(
deps.setStreamingChunks([]);
deps.streamingChunksRef.current = [];
deps.setHasTextChunks(false);
+ deps.setIsStreamingInitiated(false);
console.log("[Stream End] Stream complete, messages in local state");
}
-export function handleError(chunk: StreamChunk, _deps: HandlerDependencies) {
+export function handleError(chunk: StreamChunk, deps: HandlerDependencies) {
const errorMessage = chunk.message || chunk.content || "An error occurred";
console.error("Stream error:", errorMessage);
+ deps.setIsStreamingInitiated(false);
+ deps.setHasTextChunks(false);
+ deps.setStreamingChunks([]);
+ deps.streamingChunksRef.current = [];
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/useChatContainer.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/useChatContainer.ts
new file mode 100644
index 0000000000..8e7dee7718
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatContainer/useChatContainer.ts
@@ -0,0 +1,206 @@
+import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
+import { useCallback, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
+import { useChatStream } from "../../useChatStream";
+import type { ChatMessageData } from "../ChatMessage/useChatMessage";
+import { createStreamEventDispatcher } from "./createStreamEventDispatcher";
+import {
+ createUserMessage,
+ filterAuthMessages,
+ isToolCallArray,
+ isValidMessage,
+ parseToolResponse,
+ removePageContext,
+} from "./helpers";
+
+interface Args {
+ sessionId: string | null;
+ initialMessages: SessionDetailResponse["messages"];
+}
+
+export function useChatContainer({ sessionId, initialMessages }: Args) {
+ const [messages, setMessages] = useState([]);
+ const [streamingChunks, setStreamingChunks] = useState([]);
+ const [hasTextChunks, setHasTextChunks] = useState(false);
+ const [isStreamingInitiated, setIsStreamingInitiated] = useState(false);
+ const streamingChunksRef = useRef([]);
+ const { error, sendMessage: sendStreamMessage } = useChatStream();
+ const isStreaming = isStreamingInitiated || hasTextChunks;
+
+ const allMessages = useMemo(() => {
+ const processedInitialMessages: ChatMessageData[] = [];
+ // Map to track tool calls by their ID so we can look up tool names for tool responses
+ const toolCallMap = new Map();
+
+ for (const msg of initialMessages) {
+ if (!isValidMessage(msg)) {
+ console.warn("Invalid message structure from backend:", msg);
+ continue;
+ }
+
+ let content = String(msg.content || "");
+ const role = String(msg.role || "assistant").toLowerCase();
+ const toolCalls = msg.tool_calls;
+ const timestamp = msg.timestamp
+ ? new Date(msg.timestamp as string)
+ : undefined;
+
+ // Remove page context from user messages when loading existing sessions
+ if (role === "user") {
+ content = removePageContext(content);
+ // Skip user messages that become empty after removing page context
+ if (!content.trim()) {
+ continue;
+ }
+ processedInitialMessages.push({
+ type: "message",
+ role: "user",
+ content,
+ timestamp,
+ });
+ continue;
+ }
+
+ // Handle assistant messages first (before tool messages) to build tool call map
+ if (role === "assistant") {
+ // Strip tags from content
+ content = content
+ .replace(/[\s\S]*?<\/thinking>/gi, "")
+ .trim();
+
+ // If assistant has tool calls, create tool_call messages for each
+ if (toolCalls && isToolCallArray(toolCalls) && toolCalls.length > 0) {
+ for (const toolCall of toolCalls) {
+ const toolName = toolCall.function.name;
+ const toolId = toolCall.id;
+ // Store tool name for later lookup
+ toolCallMap.set(toolId, toolName);
+
+ try {
+ const args = JSON.parse(toolCall.function.arguments || "{}");
+ processedInitialMessages.push({
+ type: "tool_call",
+ toolId,
+ toolName,
+ arguments: args,
+ timestamp,
+ });
+ } catch (err) {
+ console.warn("Failed to parse tool call arguments:", err);
+ processedInitialMessages.push({
+ type: "tool_call",
+ toolId,
+ toolName,
+ arguments: {},
+ timestamp,
+ });
+ }
+ }
+ // Only add assistant message if there's content after stripping thinking tags
+ if (content.trim()) {
+ processedInitialMessages.push({
+ type: "message",
+ role: "assistant",
+ content,
+ timestamp,
+ });
+ }
+ } else if (content.trim()) {
+ // Assistant message without tool calls, but with content
+ processedInitialMessages.push({
+ type: "message",
+ role: "assistant",
+ content,
+ timestamp,
+ });
+ }
+ continue;
+ }
+
+ // Handle tool messages - look up tool name from tool call map
+ if (role === "tool") {
+ const toolCallId = (msg.tool_call_id as string) || "";
+ const toolName = toolCallMap.get(toolCallId) || "unknown";
+ const toolResponse = parseToolResponse(
+ content,
+ toolCallId,
+ toolName,
+ timestamp,
+ );
+ if (toolResponse) {
+ processedInitialMessages.push(toolResponse);
+ }
+ continue;
+ }
+
+ // Handle other message types (system, etc.)
+ if (content.trim()) {
+ processedInitialMessages.push({
+ type: "message",
+ role: role as "user" | "assistant" | "system",
+ content,
+ timestamp,
+ });
+ }
+ }
+
+ return [...processedInitialMessages, ...messages];
+ }, [initialMessages, messages]);
+
+ const sendMessage = useCallback(
+ async function sendMessage(
+ content: string,
+ isUserMessage: boolean = true,
+ context?: { url: string; content: string },
+ ) {
+ 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);
+ setIsStreamingInitiated(true);
+ const dispatcher = createStreamEventDispatcher({
+ setHasTextChunks,
+ setStreamingChunks,
+ streamingChunksRef,
+ setMessages,
+ sessionId,
+ setIsStreamingInitiated,
+ });
+ try {
+ await sendStreamMessage(
+ sessionId,
+ content,
+ dispatcher,
+ isUserMessage,
+ context,
+ );
+ } catch (err) {
+ console.error("Failed to send message:", err);
+ setIsStreamingInitiated(false);
+ 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/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx
new file mode 100644
index 0000000000..4b9da57286
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatCredentialsSetup/ChatCredentialsSetup.tsx
@@ -0,0 +1,149 @@
+import { Text } from "@/components/atoms/Text/Text";
+import { CredentialsInput } from "@/components/contextual/CredentialsInput/CredentialsInput";
+import type { BlockIOCredentialsSubSchema } from "@/lib/autogpt-server-api";
+import { cn } from "@/lib/utils";
+import { CheckIcon, RobotIcon, 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,
+}: 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/ChatCredentialsSetup/useChatCredentialsSetup.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatCredentialsSetup/useChatCredentialsSetup.ts
similarity index 100%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/ChatCredentialsSetup/useChatCredentialsSetup.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatCredentialsSetup/useChatCredentialsSetup.ts
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatErrorState/ChatErrorState.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatErrorState/ChatErrorState.tsx
similarity index 100%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/ChatErrorState/ChatErrorState.tsx
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatErrorState/ChatErrorState.tsx
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatInput/ChatInput.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatInput/ChatInput.tsx
new file mode 100644
index 0000000000..3101174a11
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatInput/ChatInput.tsx
@@ -0,0 +1,64 @@
+import { Input } from "@/components/atoms/Input/Input";
+import { cn } from "@/lib/utils";
+import { ArrowUpIcon } from "@phosphor-icons/react";
+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 inputId = "chat-input";
+ const { value, setValue, handleKeyDown, handleSend } = useChatInput({
+ onSend,
+ disabled,
+ maxRows: 5,
+ inputId,
+ });
+
+ return (
+
+
setValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ disabled={disabled}
+ rows={1}
+ wrapperClassName="mb-0 relative"
+ className="pr-12"
+ />
+
+ Press Enter to send, Shift+Enter for new line
+
+
+
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatInput/useChatInput.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatInput/useChatInput.ts
similarity index 65%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/ChatInput/useChatInput.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatInput/useChatInput.ts
index 2efae95483..08cf565daa 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatInput/useChatInput.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatInput/useChatInput.ts
@@ -1,21 +1,22 @@
-import { KeyboardEvent, useCallback, useState, useRef, useEffect } from "react";
+import { KeyboardEvent, useCallback, useEffect, useState } from "react";
interface UseChatInputArgs {
onSend: (message: string) => void;
disabled?: boolean;
maxRows?: number;
+ inputId?: string;
}
export function useChatInput({
onSend,
disabled = false,
maxRows = 5,
+ inputId = "chat-input",
}: UseChatInputArgs) {
const [value, setValue] = useState("");
- const textareaRef = useRef(null);
useEffect(() => {
- const textarea = textareaRef.current;
+ const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
if (!textarea) return;
textarea.style.height = "auto";
const lineHeight = parseInt(
@@ -27,23 +28,25 @@ export function useChatInput({
textarea.style.height = `${newHeight}px`;
textarea.style.overflowY =
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
- }, [value, maxRows]);
+ }, [value, maxRows, inputId]);
const handleSend = useCallback(() => {
if (disabled || !value.trim()) return;
onSend(value.trim());
setValue("");
- if (textareaRef.current) {
- textareaRef.current.style.height = "auto";
+ const textarea = document.getElementById(inputId) as HTMLTextAreaElement;
+ if (textarea) {
+ textarea.style.height = "auto";
}
- }, [value, onSend, disabled]);
+ }, [value, onSend, disabled, inputId]);
const handleKeyDown = useCallback(
- (event: KeyboardEvent) => {
+ (event: KeyboardEvent) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
+ // Shift+Enter allows default behavior (new line) - no need to handle explicitly
},
[handleSend],
);
@@ -53,6 +56,5 @@ export function useChatInput({
setValue,
handleKeyDown,
handleSend,
- textareaRef,
};
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatLoadingState/ChatLoadingState.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatLoadingState/ChatLoadingState.tsx
new file mode 100644
index 0000000000..c0cdb33c50
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatLoadingState/ChatLoadingState.tsx
@@ -0,0 +1,19 @@
+import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
+import { cn } from "@/lib/utils";
+
+export interface ChatLoadingStateProps {
+ message?: string;
+ className?: string;
+}
+
+export function ChatLoadingState({ className }: ChatLoadingStateProps) {
+ return (
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatMessage/ChatMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatMessage/ChatMessage.tsx
new file mode 100644
index 0000000000..69a1ab63fb
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatMessage/ChatMessage.tsx
@@ -0,0 +1,341 @@
+"use client";
+
+import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
+import Avatar, {
+ AvatarFallback,
+ AvatarImage,
+} from "@/components/atoms/Avatar/Avatar";
+import { Button } from "@/components/atoms/Button/Button";
+import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
+import { cn } from "@/lib/utils";
+import {
+ ArrowClockwise,
+ CheckCircleIcon,
+ CheckIcon,
+ CopyIcon,
+ RobotIcon,
+} from "@phosphor-icons/react";
+import { useRouter } from "next/navigation";
+import { useCallback, useState } from "react";
+import { getToolActionPhrase } from "../../helpers";
+import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessage";
+import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget";
+import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup";
+import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage";
+import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
+import { MessageBubble } from "../MessageBubble/MessageBubble";
+import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage";
+import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage";
+import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage";
+import { useChatMessage, type ChatMessageData } from "./useChatMessage";
+export interface ChatMessageProps {
+ message: ChatMessageData;
+ className?: string;
+ onDismissLogin?: () => void;
+ onDismissCredentials?: () => void;
+ onSendMessage?: (content: string, isUserMessage?: boolean) => void;
+ agentOutput?: ChatMessageData;
+}
+
+export function ChatMessage({
+ message,
+ className,
+ onDismissCredentials,
+ onSendMessage,
+ agentOutput,
+}: ChatMessageProps) {
+ const { user } = useSupabase();
+ const router = useRouter();
+ const [copied, setCopied] = useState(false);
+ const {
+ isUser,
+ isToolCall,
+ isToolResponse,
+ isLoginNeeded,
+ isCredentialsNeeded,
+ } = useChatMessage(message);
+
+ const { data: profile } = useGetV2GetUserProfile({
+ query: {
+ select: (res) => (res.status === 200 ? res.data : null),
+ enabled: isUser && !!user,
+ queryKey: ["/api/store/profile", user?.id],
+ },
+ });
+
+ 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();
+ }
+ }
+
+ const handleCopy = useCallback(async () => {
+ if (message.type !== "message") return;
+
+ try {
+ await navigator.clipboard.writeText(message.content);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ console.error("Failed to copy:", error);
+ }
+ }, [message]);
+
+ const handleTryAgain = useCallback(() => {
+ if (message.type !== "message" || !onSendMessage) return;
+ onSendMessage(message.content, message.role === "user");
+ }, [message, onSendMessage]);
+
+ const handleViewExecution = useCallback(() => {
+ if (message.type === "execution_started" && message.libraryAgentLink) {
+ router.push(message.libraryAgentLink);
+ }
+ }, [message, router]);
+
+ // 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 no_results messages - use dedicated component, not ToolResponseMessage
+ if (message.type === "no_results") {
+ return (
+
+
+
+ );
+ }
+
+ // Render agent_carousel messages - use dedicated component, not ToolResponseMessage
+ if (message.type === "agent_carousel") {
+ return (
+
+ );
+ }
+
+ // Render execution_started messages - use dedicated component, not ToolResponseMessage
+ if (message.type === "execution_started") {
+ return (
+
+
+
+ );
+ }
+
+ // Render tool response messages (but skip agent_output if it's being rendered inside assistant message)
+ if (isToolResponse && message.type === "tool_response") {
+ // Check if this is an agent_output that should be rendered inside assistant message
+ if (message.result) {
+ let parsedResult: Record | null = null;
+ try {
+ parsedResult =
+ typeof message.result === "string"
+ ? JSON.parse(message.result)
+ : (message.result as Record);
+ } catch {
+ parsedResult = null;
+ }
+ if (parsedResult?.type === "agent_output") {
+ // Skip rendering - this will be rendered inside the assistant message
+ return null;
+ }
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ // Render regular chat messages
+ if (message.type === "message") {
+ return (
+
+
+ {!isUser && (
+
+ )}
+
+
+
+
+ {agentOutput &&
+ agentOutput.type === "tool_response" &&
+ !isUser && (
+
+
+
+ )}
+
+
+ {isUser && onSendMessage && (
+
+
+
+ )}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {isUser && (
+
+
+
+
+ {profile?.username?.charAt(0)?.toUpperCase() || "U"}
+
+
+
+ )}
+
+
+ );
+ }
+
+ // Fallback for unknown message types
+ return null;
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/useChatMessage.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatMessage/useChatMessage.ts
similarity index 88%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/useChatMessage.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatMessage/useChatMessage.ts
index ae4f48f35b..9a597d4b26 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/useChatMessage.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ChatMessage/useChatMessage.ts
@@ -1,5 +1,5 @@
-import { formatDistanceToNow } from "date-fns";
import type { ToolArguments, ToolResult } from "@/types/chat";
+import { formatDistanceToNow } from "date-fns";
export type ChatMessageData =
| {
@@ -65,6 +65,7 @@ export type ChatMessageData =
name: string;
description: string;
version?: number;
+ image_url?: string;
}>;
totalCount?: number;
timestamp?: string | Date;
@@ -77,6 +78,17 @@ export type ChatMessageData =
message?: string;
libraryAgentLink?: string;
timestamp?: string | Date;
+ }
+ | {
+ type: "inputs_needed";
+ toolName: string;
+ agentName?: string;
+ agentId?: string;
+ graphVersion?: number;
+ inputSchema: Record;
+ credentialsSchema?: Record;
+ message: string;
+ timestamp?: string | Date;
};
export function useChatMessage(message: ChatMessageData) {
@@ -96,5 +108,6 @@ export function useChatMessage(message: ChatMessageData) {
isNoResults: message.type === "no_results",
isAgentCarousel: message.type === "agent_carousel",
isExecutionStarted: message.type === "execution_started",
+ isInputsNeeded: message.type === "inputs_needed",
};
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx
similarity index 67%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx
index 77c7f8fe9b..1ac3b440e0 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ExecutionStartedMessage/ExecutionStartedMessage.tsx
@@ -1,8 +1,7 @@
-import React from "react";
-import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
-import { CheckCircle, Play, ArrowSquareOut } from "@phosphor-icons/react";
+import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
+import { ArrowSquareOut, CheckCircle, Play } from "@phosphor-icons/react";
export interface ExecutionStartedMessageProps {
executionId: string;
@@ -22,7 +21,7 @@ export function ExecutionStartedMessage({
return (
@@ -32,48 +31,33 @@ export function ExecutionStartedMessage({
-
+
Execution Started
-
+
{message}
{/* Details */}
-
+
{agentName && (
-
+
Agent:
-
+
{agentName}
)}
-
+
Execution ID:
-
+
{executionId.slice(0, 16)}...
@@ -94,7 +78,7 @@ export function ExecutionStartedMessage({
)}
-
+
Your agent is now running. You can monitor its progress in the monitor
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/MarkdownContent/MarkdownContent.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MarkdownContent/MarkdownContent.tsx
similarity index 80%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/MarkdownContent/MarkdownContent.tsx
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MarkdownContent/MarkdownContent.tsx
index e82e29c438..51a0794090 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/MarkdownContent/MarkdownContent.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MarkdownContent/MarkdownContent.tsx
@@ -1,9 +1,9 @@
"use client";
+import { cn } from "@/lib/utils";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
-import { cn } from "@/lib/utils";
interface MarkdownContentProps {
content: string;
@@ -41,7 +41,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
if (isInline) {
return (
{children}
@@ -49,17 +49,14 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
);
}
return (
-
+
{children}
);
},
pre: ({ children, ...props }) => (
{children}
@@ -70,7 +67,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
href={href}
target="_blank"
rel="noopener noreferrer"
- className="text-purple-600 underline decoration-1 underline-offset-2 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300"
+ className="text-purple-600 underline decoration-1 underline-offset-2 hover:text-purple-700"
{...props}
>
{children}
@@ -126,7 +123,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
@@ -136,57 +133,42 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
},
blockquote: ({ children, ...props }) => (
{children}
),
h1: ({ children, ...props }) => (
-
+
{children}
),
h2: ({ children, ...props }) => (
-
+
{children}
),
h3: ({ children, ...props }) => (
{children}
),
h4: ({ children, ...props }) => (
-
+
{children}
),
h5: ({ children, ...props }) => (
-
+
{children}
),
h6: ({ children, ...props }) => (
-
+
{children}
),
@@ -196,15 +178,12 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
),
hr: ({ ...props }) => (
-
+
),
table: ({ children, ...props }) => (
{children}
@@ -213,7 +192,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
),
th: ({ children, ...props }) => (
{children}
@@ -221,7 +200,7 @@ export function MarkdownContent({ content, className }: MarkdownContentProps) {
),
td: ({ children, ...props }) => (
{children}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MessageBubble/MessageBubble.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MessageBubble/MessageBubble.tsx
new file mode 100644
index 0000000000..98b50f3d28
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MessageBubble/MessageBubble.tsx
@@ -0,0 +1,56 @@
+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) {
+ const userTheme = {
+ bg: "bg-slate-900",
+ border: "border-slate-800",
+ gradient: "from-slate-900/30 via-slate-800/20 to-transparent",
+ text: "text-slate-50",
+ };
+
+ const assistantTheme = {
+ bg: "bg-slate-50/20",
+ border: "border-slate-100",
+ gradient: "from-slate-200/20 via-slate-300/10 to-transparent",
+ text: "text-slate-900",
+ };
+
+ const theme = variant === "user" ? userTheme : assistantTheme;
+
+ return (
+
+ {/* Gradient flare background */}
+
+
+ {children}
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MessageList/MessageList.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MessageList/MessageList.tsx
new file mode 100644
index 0000000000..22b51c0a92
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MessageList/MessageList.tsx
@@ -0,0 +1,121 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { ChatMessage } from "../ChatMessage/ChatMessage";
+import type { ChatMessageData } from "../ChatMessage/useChatMessage";
+import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
+import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
+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) => {
+ // Check if current message is an agent_output tool_response
+ // and if previous message is an assistant message
+ let agentOutput: ChatMessageData | undefined;
+
+ if (message.type === "tool_response" && message.result) {
+ let parsedResult: Record
| null = null;
+ try {
+ parsedResult =
+ typeof message.result === "string"
+ ? JSON.parse(message.result)
+ : (message.result as Record);
+ } catch {
+ parsedResult = null;
+ }
+ if (parsedResult?.type === "agent_output") {
+ const prevMessage = messages[index - 1];
+ if (
+ prevMessage &&
+ prevMessage.type === "message" &&
+ prevMessage.role === "assistant"
+ ) {
+ // This agent output will be rendered inside the previous assistant message
+ // Skip rendering this message separately
+ return null;
+ }
+ }
+ }
+
+ // Check if next message is an agent_output tool_response to include in current assistant message
+ if (message.type === "message" && message.role === "assistant") {
+ const nextMessage = messages[index + 1];
+ if (
+ nextMessage &&
+ nextMessage.type === "tool_response" &&
+ nextMessage.result
+ ) {
+ let parsedResult: Record | null = null;
+ try {
+ parsedResult =
+ typeof nextMessage.result === "string"
+ ? JSON.parse(nextMessage.result)
+ : (nextMessage.result as Record);
+ } catch {
+ parsedResult = null;
+ }
+ if (parsedResult?.type === "agent_output") {
+ agentOutput = nextMessage;
+ }
+ }
+ }
+
+ return (
+
+ );
+ })}
+
+ {/* Render thinking message when streaming but no chunks yet */}
+ {isStreaming && streamingChunks.length === 0 && }
+
+ {/* 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/MessageList/useMessageList.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MessageList/useMessageList.ts
similarity index 100%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/MessageList/useMessageList.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/MessageList/useMessageList.ts
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/NoResultsMessage/NoResultsMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/NoResultsMessage/NoResultsMessage.tsx
similarity index 72%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/NoResultsMessage/NoResultsMessage.tsx
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/NoResultsMessage/NoResultsMessage.tsx
index 38eac24bce..b6adc8b93c 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/components/NoResultsMessage/NoResultsMessage.tsx
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/NoResultsMessage/NoResultsMessage.tsx
@@ -1,7 +1,6 @@
-import React from "react";
import { Text } from "@/components/atoms/Text/Text";
-import { MagnifyingGlass, X } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
+import { MagnifyingGlass, X } from "@phosphor-icons/react";
export interface NoResultsMessageProps {
message: string;
@@ -17,26 +16,26 @@ export function NoResultsMessage({
return (
{/* Icon */}
-
+
-
{/* Content */}
-
+
No Results Found
-
+
{message}
@@ -44,17 +43,14 @@ export function NoResultsMessage({
{/* Suggestions */}
{suggestions.length > 0 && (
-
+
Try these suggestions:
-
+
{suggestions.map((suggestion, index) => (
•
{suggestion}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx
new file mode 100644
index 0000000000..dd76fd9fb6
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/QuickActionsWelcome/QuickActionsWelcome.tsx
@@ -0,0 +1,94 @@
+"use client";
+
+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) => {
+ // Use slate theme for all cards
+ const theme = {
+ bg: "bg-slate-50/10",
+ border: "border-slate-100",
+ hoverBg: "hover:bg-slate-50/20",
+ hoverBorder: "hover:border-slate-200",
+ gradient: "from-slate-200/20 via-slate-300/10 to-transparent",
+ text: "text-slate-900",
+ hoverText: "group-hover:text-slate-900",
+ };
+
+ return (
+
onActionClick(action)}
+ disabled={disabled}
+ className={cn(
+ "group relative overflow-hidden rounded-xl border p-5 text-left backdrop-blur-xl",
+ "transition-all duration-200",
+ theme.bg,
+ theme.border,
+ theme.hoverBg,
+ theme.hoverBorder,
+ "hover:shadow-sm",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/50 focus-visible:ring-offset-2",
+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:shadow-none",
+ )}
+ >
+ {/* Gradient flare background */}
+
+
+
+ {action}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/SessionsDrawer/SessionsDrawer.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/SessionsDrawer/SessionsDrawer.tsx
new file mode 100644
index 0000000000..74aa709a46
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/SessionsDrawer/SessionsDrawer.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
+import { Text } from "@/components/atoms/Text/Text";
+import { scrollbarStyles } from "@/components/styles/scrollbars";
+import { cn } from "@/lib/utils";
+import { X } from "@phosphor-icons/react";
+import { formatDistanceToNow } from "date-fns";
+import { Drawer } from "vaul";
+
+interface SessionsDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSelectSession: (sessionId: string) => void;
+ currentSessionId?: string | null;
+}
+
+export function SessionsDrawer({
+ isOpen,
+ onClose,
+ onSelectSession,
+ currentSessionId,
+}: SessionsDrawerProps) {
+ const { data, isLoading } = useGetV2ListSessions(
+ { limit: 100 },
+ {
+ query: {
+ enabled: isOpen,
+ },
+ },
+ );
+
+ const sessions =
+ data?.status === 200
+ ? data.data.sessions.filter((session) => {
+ // Filter out sessions without messages (sessions that were never updated)
+ // If updated_at equals created_at, the session was created but never had messages
+ return session.updated_at !== session.created_at;
+ })
+ : [];
+
+ function handleSelectSession(sessionId: string) {
+ onSelectSession(sessionId);
+ onClose();
+ }
+
+ return (
+ !open && onClose()}
+ direction="right"
+ >
+
+
+
+
+
+
+ Chat Sessions
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+
+ Loading sessions...
+
+
+ ) : sessions.length === 0 ? (
+
+
+ No sessions found
+
+
+ ) : (
+
+ {sessions.map((session) => {
+ const isActive = session.id === currentSessionId;
+ const updatedAt = session.updated_at
+ ? formatDistanceToNow(new Date(session.updated_at), {
+ addSuffix: true,
+ })
+ : "";
+
+ return (
+
handleSelectSession(session.id)}
+ className={cn(
+ "w-full rounded-lg border p-3 text-left transition-colors",
+ isActive
+ ? "border-indigo-500 bg-zinc-50"
+ : "border-zinc-200 bg-zinc-100/50 hover:border-zinc-300 hover:bg-zinc-50",
+ )}
+ >
+
+
+ {session.title || "Untitled Chat"}
+
+
+ {session.id.slice(0, 8)}...
+ {updatedAt && • }
+ {updatedAt}
+
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/StreamingMessage/StreamingMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/StreamingMessage/StreamingMessage.tsx
new file mode 100644
index 0000000000..2a6e3d5822
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/StreamingMessage/StreamingMessage.tsx
@@ -0,0 +1,42 @@
+import { cn } from "@/lib/utils";
+import { RobotIcon } from "@phosphor-icons/react";
+import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
+import { MessageBubble } from "../MessageBubble/MessageBubble";
+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 (
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/StreamingMessage/useStreamingMessage.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/StreamingMessage/useStreamingMessage.ts
similarity index 100%
rename from autogpt_platform/frontend/src/app/(platform)/chat/components/StreamingMessage/useStreamingMessage.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/StreamingMessage/useStreamingMessage.ts
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ThinkingMessage/ThinkingMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ThinkingMessage/ThinkingMessage.tsx
new file mode 100644
index 0000000000..d8adddf416
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ThinkingMessage/ThinkingMessage.tsx
@@ -0,0 +1,70 @@
+import { cn } from "@/lib/utils";
+import { RobotIcon } from "@phosphor-icons/react";
+import { useEffect, useRef, useState } from "react";
+import { MessageBubble } from "../MessageBubble/MessageBubble";
+
+export interface ThinkingMessageProps {
+ className?: string;
+}
+
+export function ThinkingMessage({ className }: ThinkingMessageProps) {
+ const [showSlowLoader, setShowSlowLoader] = useState(false);
+ const timerRef = useRef(null);
+
+ useEffect(() => {
+ if (timerRef.current === null) {
+ timerRef.current = setTimeout(() => {
+ setShowSlowLoader(true);
+ }, 8000);
+ }
+
+ return () => {
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ };
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ {showSlowLoader ? (
+
+
+
+ Taking a bit longer to think, wait a moment please
+
+
+ ) : (
+
+ Thinking...
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ToolCallMessage/ToolCallMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ToolCallMessage/ToolCallMessage.tsx
new file mode 100644
index 0000000000..97590ae0cf
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ToolCallMessage/ToolCallMessage.tsx
@@ -0,0 +1,24 @@
+import { Text } from "@/components/atoms/Text/Text";
+import { cn } from "@/lib/utils";
+import { WrenchIcon } from "@phosphor-icons/react";
+import { getToolActionPhrase } from "../../helpers";
+
+export interface ToolCallMessageProps {
+ toolName: string;
+ className?: string;
+}
+
+export function ToolCallMessage({ toolName, className }: ToolCallMessageProps) {
+ return (
+
+
+
+ {getToolActionPhrase(toolName)}...
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx
new file mode 100644
index 0000000000..b84204c3ff
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/components/ToolResponseMessage/ToolResponseMessage.tsx
@@ -0,0 +1,260 @@
+import { Text } from "@/components/atoms/Text/Text";
+import "@/components/contextual/OutputRenderers";
+import {
+ globalRegistry,
+ OutputItem,
+} from "@/components/contextual/OutputRenderers";
+import { cn } from "@/lib/utils";
+import type { ToolResult } from "@/types/chat";
+import { WrenchIcon } from "@phosphor-icons/react";
+import { getToolActionPhrase } from "../../helpers";
+
+export interface ToolResponseMessageProps {
+ toolName: string;
+ result?: ToolResult;
+ success?: boolean;
+ className?: string;
+}
+
+export function ToolResponseMessage({
+ toolName,
+ result,
+ success: _success = true,
+ className,
+}: ToolResponseMessageProps) {
+ if (!result) {
+ return (
+
+
+
+ {getToolActionPhrase(toolName)}...
+
+
+ );
+ }
+
+ 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 === "agent_output") {
+ const execution = parsedResult.execution as
+ | {
+ outputs?: Record;
+ }
+ | null
+ | undefined;
+ const outputs = execution?.outputs || {};
+ const message = parsedResult.message as string | undefined;
+
+ return (
+
+
+
+
+ {getToolActionPhrase(toolName)}
+
+
+ {message && (
+
+
+ {message}
+
+
+ )}
+ {Object.keys(outputs).length > 0 && (
+
+ {Object.entries(outputs).map(([outputName, values]) =>
+ values.map((value, index) => {
+ const renderer = globalRegistry.getRenderer(value);
+ if (renderer) {
+ return (
+
+ );
+ }
+ return (
+
+
+ {outputName}
+
+
+ {JSON.stringify(value, null, 2)}
+
+
+ );
+ }),
+ )}
+
+ )}
+
+ );
+ }
+
+ if (responseType === "block_output" && parsedResult.outputs) {
+ const outputs = parsedResult.outputs as Record;
+
+ return (
+
+
+
+
+ {getToolActionPhrase(toolName)}
+
+
+
+ {Object.entries(outputs).map(([outputName, values]) =>
+ values.map((value, index) => {
+ const renderer = globalRegistry.getRenderer(value);
+ if (renderer) {
+ return (
+
+ );
+ }
+ return (
+
+
+ {outputName}
+
+
+ {JSON.stringify(value, null, 2)}
+
+
+ );
+ }),
+ )}
+
+
+ );
+ }
+
+ // Handle other response types with a message field (e.g., understanding_updated)
+ if (parsedResult.message && typeof parsedResult.message === "string") {
+ // Format tool name from snake_case to Title Case
+ const formattedToolName = toolName
+ .split("_")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
+ .join(" ");
+
+ // Clean up message - remove incomplete user_name references
+ let cleanedMessage = parsedResult.message;
+ // Remove "Updated understanding with: user_name" pattern if user_name is just a placeholder
+ cleanedMessage = cleanedMessage.replace(
+ /Updated understanding with:\s*user_name\.?\s*/gi,
+ "",
+ );
+ // Remove standalone user_name references
+ cleanedMessage = cleanedMessage.replace(/\buser_name\b\.?\s*/gi, "");
+ cleanedMessage = cleanedMessage.trim();
+
+ // Only show message if it has content after cleaning
+ if (!cleanedMessage) {
+ return (
+
+
+
+ {formattedToolName}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {formattedToolName}
+
+
+
+
+ {cleanedMessage}
+
+
+
+ );
+ }
+ }
+
+ const renderer = globalRegistry.getRenderer(result);
+ if (renderer) {
+ return (
+
+
+
+
+ {getToolActionPhrase(toolName)}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {getToolActionPhrase(toolName)}...
+
+
+ );
+}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/helpers.ts
similarity index 92%
rename from autogpt_platform/frontend/src/app/(platform)/chat/helpers.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/helpers.ts
index 5a1e5eb93f..0fade56b73 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/helpers.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/helpers.ts
@@ -64,10 +64,3 @@ export function getToolCompletionPhrase(toolName: string): string {
`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/useChatPage.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChat.ts
similarity index 78%
rename from autogpt_platform/frontend/src/app/(platform)/chat/useChatPage.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChat.ts
index 4f1db5471a..8445d68b3f 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/useChatPage.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChat.ts
@@ -1,17 +1,12 @@
"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";
+import { useEffect, useRef } from "react";
+import { toast } from "sonner";
+import { useChatSession } from "./useChatSession";
+import { useChatStream } from "./useChatStream";
-export function useChatPage() {
- const router = useRouter();
- const searchParams = useSearchParams();
- const urlSessionId =
- searchParams.get("session_id") || searchParams.get("session");
+export function useChat() {
const hasCreatedSessionRef = useRef(false);
const hasClaimedSessionRef = useRef(false);
const { user } = useSupabase();
@@ -25,29 +20,24 @@ export function useChatPage() {
isCreating,
error,
createSession,
- refreshSession,
claimSession,
clearSession: clearSessionBase,
+ loadSession,
} = useChatSession({
- urlSessionId,
+ urlSessionId: null,
autoCreate: false,
});
useEffect(
function autoCreateSession() {
- if (
- !urlSessionId &&
- !hasCreatedSessionRef.current &&
- !isCreating &&
- !sessionIdFromHook
- ) {
+ if (!hasCreatedSessionRef.current && !isCreating && !sessionIdFromHook) {
hasCreatedSessionRef.current = true;
createSession().catch((_err) => {
hasCreatedSessionRef.current = false;
});
}
},
- [urlSessionId, isCreating, sessionIdFromHook, createSession],
+ [isCreating, sessionIdFromHook, createSession],
);
useEffect(
@@ -111,7 +101,6 @@ export function useChatPage() {
clearSessionBase();
hasCreatedSessionRef.current = false;
hasClaimedSessionRef.current = false;
- router.push("/chat");
}
return {
@@ -121,8 +110,8 @@ export function useChatPage() {
isCreating,
error,
createSession,
- refreshSession,
clearSession,
+ loadSession,
sessionId: sessionIdFromHook,
};
}
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChatDrawer.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChatDrawer.ts
new file mode 100644
index 0000000000..62e1a5a569
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChatDrawer.ts
@@ -0,0 +1,17 @@
+"use client";
+
+import { create } from "zustand";
+
+interface ChatDrawerState {
+ isOpen: boolean;
+ open: () => void;
+ close: () => void;
+ toggle: () => void;
+}
+
+export const useChatDrawer = create((set) => ({
+ isOpen: false,
+ open: () => set({ isOpen: true }),
+ close: () => set({ isOpen: false }),
+ toggle: () => set((state) => ({ isOpen: !state.isOpen })),
+}));
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/useChatSession.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChatSession.ts
similarity index 89%
rename from autogpt_platform/frontend/src/app/(platform)/chat/useChatSession.ts
rename to autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChatSession.ts
index 99f4efc093..a54dc9e32a 100644
--- a/autogpt_platform/frontend/src/app/(platform)/chat/useChatSession.ts
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChatSession.ts
@@ -1,17 +1,18 @@
-import { useCallback, useEffect, useState, useRef, useMemo } from "react";
-import { useQueryClient } from "@tanstack/react-query";
-import { toast } from "sonner";
import {
- usePostV2CreateSession,
+ getGetV2GetSessionQueryKey,
+ getGetV2GetSessionQueryOptions,
postV2CreateSession,
useGetV2GetSession,
usePatchV2SessionAssignUser,
- getGetV2GetSessionQueryKey,
+ usePostV2CreateSession,
} 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";
+import { isValidUUID } from "@/lib/utils";
+import { Key, storage } from "@/services/storage/local-storage";
+import { useQueryClient } from "@tanstack/react-query";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { toast } from "sonner";
interface UseChatSessionArgs {
urlSessionId?: string | null;
@@ -155,10 +156,22 @@ export function useChatSession({
async function loadSession(id: string) {
try {
setError(null);
+ // Invalidate the query cache for this session to force a fresh fetch
+ await queryClient.invalidateQueries({
+ queryKey: getGetV2GetSessionQueryKey(id),
+ });
+ // Set sessionId after invalidation to ensure the hook refetches
setSessionId(id);
storage.set(Key.CHAT_SESSION_ID, id);
- const result = await refetch();
- if (!result.data || result.isError) {
+ // Force fetch with fresh data (bypass cache)
+ const queryOptions = getGetV2GetSessionQueryOptions(id, {
+ query: {
+ staleTime: 0, // Force fresh fetch
+ retry: 1,
+ },
+ });
+ const result = await queryClient.fetchQuery(queryOptions);
+ if (!result || ("status" in result && result.status !== 200)) {
console.warn("Session not found on server, clearing local state");
storage.clean(Key.CHAT_SESSION_ID);
setSessionId(null);
@@ -171,7 +184,7 @@ export function useChatSession({
throw error;
}
},
- [refetch],
+ [queryClient],
);
const refreshSession = useCallback(
diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChatStream.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChatStream.ts
new file mode 100644
index 0000000000..1471a13a71
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/useChatStream.ts
@@ -0,0 +1,371 @@
+import type { ToolArguments, ToolResult } from "@/types/chat";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+
+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;
+}
+
+type VercelStreamChunk =
+ | { type: "start"; messageId: string }
+ | { type: "finish" }
+ | { type: "text-start"; id: string }
+ | { type: "text-delta"; id: string; delta: string }
+ | { type: "text-end"; id: string }
+ | { type: "tool-input-start"; toolCallId: string; toolName: string }
+ | {
+ type: "tool-input-available";
+ toolCallId: string;
+ toolName: string;
+ input: ToolArguments;
+ }
+ | {
+ type: "tool-output-available";
+ toolCallId: string;
+ toolName?: string;
+ output: ToolResult;
+ success?: boolean;
+ }
+ | {
+ type: "usage";
+ promptTokens: number;
+ completionTokens: number;
+ totalTokens: number;
+ }
+ | {
+ type: "error";
+ errorText: string;
+ code?: string;
+ details?: Record;
+ };
+
+const LEGACY_STREAM_TYPES = new Set([
+ "text_chunk",
+ "text_ended",
+ "tool_call",
+ "tool_call_start",
+ "tool_response",
+ "login_needed",
+ "need_login",
+ "credentials_needed",
+ "error",
+ "usage",
+ "stream_end",
+]);
+
+function isLegacyStreamChunk(
+ chunk: StreamChunk | VercelStreamChunk,
+): chunk is StreamChunk {
+ return LEGACY_STREAM_TYPES.has(chunk.type as StreamChunk["type"]);
+}
+
+function normalizeStreamChunk(
+ chunk: StreamChunk | VercelStreamChunk,
+): StreamChunk | null {
+ if (isLegacyStreamChunk(chunk)) {
+ return chunk;
+ }
+ switch (chunk.type) {
+ case "text-delta":
+ return { type: "text_chunk", content: chunk.delta };
+ case "text-end":
+ return { type: "text_ended" };
+ case "tool-input-available":
+ return {
+ type: "tool_call_start",
+ tool_id: chunk.toolCallId,
+ tool_name: chunk.toolName,
+ arguments: chunk.input,
+ };
+ case "tool-output-available":
+ return {
+ type: "tool_response",
+ tool_id: chunk.toolCallId,
+ tool_name: chunk.toolName,
+ result: chunk.output,
+ success: chunk.success ?? true,
+ };
+ case "usage":
+ return {
+ type: "usage",
+ promptTokens: chunk.promptTokens,
+ completionTokens: chunk.completionTokens,
+ totalTokens: chunk.totalTokens,
+ };
+ case "error":
+ return {
+ type: "error",
+ message: chunk.errorText,
+ code: chunk.code,
+ details: chunk.details,
+ };
+ case "finish":
+ return { type: "stream_end" };
+ case "start":
+ case "text-start":
+ case "tool-input-start":
+ return null;
+ }
+}
+
+export function useChatStream() {
+ const [isStreaming, setIsStreaming] = useState(false);
+ const [error, setError] = useState(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 (retryTimeoutRef.current) {
+ clearTimeout(retryTimeoutRef.current);
+ retryTimeoutRef.current = null;
+ }
+ setIsStreaming(false);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ stopStreaming();
+ };
+ }, [stopStreaming]);
+
+ const sendMessage = useCallback(
+ async (
+ sessionId: string,
+ message: string,
+ onChunk: (chunk: StreamChunk) => void,
+ isUserMessage: boolean = true,
+ context?: { url: string; content: string },
+ isRetry: boolean = false,
+ ) => {
+ stopStreaming();
+
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
+
+ if (abortController.signal.aborted) {
+ return Promise.reject(new Error("Request aborted"));
+ }
+
+ if (!isRetry) {
+ retryCountRef.current = 0;
+ }
+ setIsStreaming(true);
+ setError(null);
+
+ try {
+ const url = `/api/chat/sessions/${sessionId}/stream`;
+ const body = JSON.stringify({
+ message,
+ is_user_message: isUserMessage,
+ context: context || null,
+ });
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "text/event-stream",
+ },
+ body,
+ signal: abortController.signal,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(errorText || `HTTP ${response.status}`);
+ }
+
+ if (!response.body) {
+ throw new Error("Response body is null");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ return new Promise((resolve, reject) => {
+ let didDispatchStreamEnd = false;
+
+ function dispatchStreamEnd() {
+ if (didDispatchStreamEnd) return;
+ didDispatchStreamEnd = true;
+ onChunk({ type: "stream_end" });
+ }
+
+ const cleanup = () => {
+ reader.cancel().catch(() => {
+ // Ignore cancel errors
+ });
+ };
+
+ async function readStream() {
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ cleanup();
+ dispatchStreamEnd();
+ retryCountRef.current = 0;
+ stopStreaming();
+ resolve();
+ return;
+ }
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ if (line.startsWith("data: ")) {
+ const data = line.slice(6);
+ if (data === "[DONE]") {
+ cleanup();
+ dispatchStreamEnd();
+ retryCountRef.current = 0;
+ stopStreaming();
+ resolve();
+ return;
+ }
+
+ try {
+ const rawChunk = JSON.parse(data) as
+ | StreamChunk
+ | VercelStreamChunk;
+ const chunk = normalizeStreamChunk(rawChunk);
+ if (!chunk) {
+ continue;
+ }
+
+ // Call the chunk handler
+ onChunk(chunk);
+
+ // Handle stream lifecycle
+ if (chunk.type === "stream_end") {
+ didDispatchStreamEnd = true;
+ cleanup();
+ retryCountRef.current = 0;
+ stopStreaming();
+ resolve();
+ return;
+ } else if (chunk.type === "error") {
+ cleanup();
+ reject(
+ new Error(
+ chunk.message || chunk.content || "Stream error",
+ ),
+ );
+ return;
+ }
+ } catch (err) {
+ // Skip invalid JSON lines
+ console.warn("Failed to parse SSE chunk:", err, data);
+ }
+ }
+ }
+ }
+ } catch (err) {
+ if (err instanceof Error && err.name === "AbortError") {
+ cleanup();
+ return;
+ }
+
+ const streamError =
+ err instanceof Error ? err : new Error("Failed to read stream");
+
+ 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,
+ context,
+ true,
+ ).catch((_err) => {
+ // Retry failed
+ });
+ }, retryDelay);
+ } else {
+ setError(streamError);
+ toast.error("Connection Failed", {
+ description:
+ "Unable to connect to chat service. Please try again.",
+ });
+ cleanup();
+ dispatchStreamEnd();
+ retryCountRef.current = 0;
+ stopStreaming();
+ reject(streamError);
+ }
+ }
+ }
+
+ readStream();
+ });
+ } 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)/chat/components/Chat/usePageContext.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/usePageContext.ts
new file mode 100644
index 0000000000..c567422a5c
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/Chat/usePageContext.ts
@@ -0,0 +1,98 @@
+import { useCallback } from "react";
+
+export interface PageContext {
+ url: string;
+ content: string;
+}
+
+const MAX_CONTENT_CHARS = 10000;
+
+/**
+ * Hook to capture the current page context (URL + full page content)
+ * Privacy-hardened: removes sensitive inputs and enforces content size limits
+ */
+export function usePageContext() {
+ const capturePageContext = useCallback((): PageContext => {
+ if (typeof window === "undefined" || typeof document === "undefined") {
+ return { url: "", content: "" };
+ }
+
+ const url = window.location.href;
+
+ // Clone document to avoid modifying the original
+ const clone = document.cloneNode(true) as Document;
+
+ // Remove script, style, and noscript elements
+ const scripts = clone.querySelectorAll("script, style, noscript");
+ scripts.forEach((el) => el.remove());
+
+ // Remove sensitive elements and their content
+ const sensitiveSelectors = [
+ "input",
+ "textarea",
+ "[contenteditable]",
+ 'input[type="password"]',
+ 'input[type="email"]',
+ 'input[type="tel"]',
+ 'input[type="search"]',
+ 'input[type="hidden"]',
+ "form",
+ "[data-sensitive]",
+ "[data-sensitive='true']",
+ ];
+
+ sensitiveSelectors.forEach((selector) => {
+ const elements = clone.querySelectorAll(selector);
+ elements.forEach((el) => {
+ // For form elements, remove the entire element
+ if (el.tagName === "FORM") {
+ el.remove();
+ } else {
+ // For inputs and textareas, clear their values but keep the element structure
+ if (
+ el instanceof HTMLInputElement ||
+ el instanceof HTMLTextAreaElement
+ ) {
+ el.value = "";
+ el.textContent = "";
+ } else {
+ // For other sensitive elements, remove them entirely
+ el.remove();
+ }
+ }
+ });
+ });
+
+ // Strip any remaining input values that might have been missed
+ const allInputs = clone.querySelectorAll("input, textarea");
+ allInputs.forEach((el) => {
+ if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
+ el.value = "";
+ el.textContent = "";
+ }
+ });
+
+ // Get text content from body
+ const body = clone.body;
+ const content = body?.textContent || body?.innerText || "";
+
+ // Clean up whitespace
+ let cleanedContent = content
+ .replace(/\s+/g, " ")
+ .replace(/\n\s*\n/g, "\n")
+ .trim();
+
+ // Enforce maximum content size
+ if (cleanedContent.length > MAX_CONTENT_CHARS) {
+ cleanedContent =
+ cleanedContent.substring(0, MAX_CONTENT_CHARS) + "... [truncated]";
+ }
+
+ return {
+ url,
+ content: cleanedContent,
+ };
+ }, []);
+
+ return { capturePageContext };
+}
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/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