feat(frontend): add interactive tutorial for the new builder interface (#11458)

### Changes 🏗️

This PR adds a comprehensive interactive tutorial for the new Builder UI
to help users learn how to create agents. Key changes include:

- Added a tutorial button to the canvas controls that launches a
step-by-step guide
- Created a Shepherd.js-based tutorial with multiple steps covering:
    - Adding blocks from the Block Menu
    - Understanding input and output handles
    - Configuring block values
    - Connecting blocks together
    - Saving and running agents
- Added data-id attributes to key UI elements for tutorial targeting
- Implemented tutorial state management with a new tutorialStore
- Added helper functions for tutorial navigation and block manipulation
- Created CSS styles for tutorial tooltips and highlights
- Integrated with the Run Input dialog to support tutorial flow
- Added prefetching of tutorial blocks for better performance


https://github.com/user-attachments/assets/3db964b3-855c-4fcc-aa5f-6cd74ab33d7d


### Checklist 📋

#### For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
    - [x] Complete the tutorial from start to finish
    - [x] Test tutorial on different screen sizes
    - [x] Verify all tutorial steps work correctly
    - [x] Ensure tutorial can be canceled and restarted
- [x] Check that tutorial doesn't interfere with normal builder
functionality
This commit is contained in:
Abhimanyu Yadav
2026-01-15 13:17:27 +05:30
committed by GitHub
parent 5ac941fe2f
commit 631f1bd50a
52 changed files with 2649 additions and 42 deletions

View File

@@ -10,7 +10,10 @@ export const BuilderActions = memo(() => {
flowID: parseAsString,
});
return (
<div className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-4 rounded-full bg-white p-2 px-2 shadow-lg">
<div
data-id="builder-actions"
className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-4 rounded-full bg-white p-2 px-2 shadow-lg"
>
<AgentOutputs flowID={flowID} />
<RunGraph flowID={flowID} />
<ScheduleGraph flowID={flowID} />

View File

@@ -79,6 +79,7 @@ export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
<Button
variant="outline"
size="icon"
data-id="agent-outputs-button"
disabled={!flowID || !hasOutputs()}
>
<BookOpenIcon className="size-4" />

View File

@@ -31,6 +31,7 @@ export const RunGraph = ({ flowID }: { flowID: string | null }) => {
<Button
size="icon"
variant={isGraphRunning ? "destructive" : "primary"}
data-id={isGraphRunning ? "stop-graph-button" : "run-graph-button"}
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
disabled={!flowID || isExecutingGraph || isTerminatingGraph}
loading={isExecutingGraph || isTerminatingGraph || isSaving}

View File

@@ -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,
};
};

View File

@@ -8,6 +8,8 @@ import { Text } from "@/components/atoms/Text/Text";
import { FormRenderer } from "@/components/renderers/InputRenderer/FormRenderer";
import { useRunInputDialog } from "./useRunInputDialog";
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
import { useTutorialStore } from "@/app/(platform)/build/stores/tutorialStore";
import { useEffect } from "react";
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 (
<>
<Dialog
@@ -48,16 +65,16 @@ export const RunInputDialog = ({
styling={{ maxWidth: "600px", minWidth: "600px" }}
>
<Dialog.Content>
<div className="space-y-6 p-1">
<div className="space-y-6 p-1" data-id="run-input-dialog-content">
{/* Credentials Section */}
{hasCredentials() && (
<div>
<div data-id="run-input-credentials-section">
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Credentials
</Text>
</div>
<div className="px-2">
<div className="px-2" data-id="run-input-credentials-form">
<FormRenderer
jsonSchema={credentialsSchema as RJSFSchema}
handleChange={(v) => handleCredentialChange(v.formData)}
@@ -75,27 +92,32 @@ export const RunInputDialog = ({
{/* Inputs Section */}
{hasInputs() && (
<div>
<div data-id="run-input-inputs-section">
<div className="mb-4">
<Text variant="h4" className="text-gray-900">
Inputs
</Text>
</div>
<FormRenderer
jsonSchema={inputSchema as RJSFSchema}
handleChange={(v) => handleInputChange(v.formData)}
uiSchema={uiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
<div data-id="run-input-inputs-form">
<FormRenderer
jsonSchema={inputSchema as RJSFSchema}
handleChange={(v) => handleInputChange(v.formData)}
uiSchema={uiSchema}
initialValues={{}}
formContext={{
showHandles: false,
size: "large",
}}
/>
</div>
</div>
)}
{/* Action Button */}
<div className="flex justify-end pt-2">
<div
className="flex justify-end pt-2"
data-id="run-input-actions-section"
>
{purpose === "run" && (
<Button
variant="primary"
@@ -103,6 +125,7 @@ export const RunInputDialog = ({
className="group h-fit min-w-0 gap-2"
onClick={handleManualRun}
loading={isExecutingGraph}
data-id="run-input-manual-run-button"
>
{!isExecutingGraph && (
<PlayIcon className="size-5 transition-transform group-hover:scale-110" />
@@ -116,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"
>
<ClockIcon className="size-5 transition-transform group-hover:scale-110" />
<span className="font-semibold">Schedule Run</span>

View File

@@ -26,6 +26,7 @@ export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
<Button
variant="outline"
size="icon"
data-id="schedule-graph-button"
onClick={handleScheduleGraph}
disabled={!flowID}
>

View File

@@ -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: <PlusIcon className="size-4" />,
label: "Zoom In",
onClick: () => zoomIn(),
className: "h-10 w-10 border-none",
},
{
id: "zoom-out-button",
icon: <MinusIcon className="size-4" />,
label: "Zoom Out",
onClick: () => zoomOut(),
className: "h-10 w-10 border-none",
},
{
id: "tutorial-button",
icon: isTutorialLoading ? (
<CircleNotchIcon className="size-4 animate-spin" />
) : (
<ChalkboardIcon className="size-4" />
),
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: <FrameCornersIcon className="size-4" />,
label: "Fit View",
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
className: "h-10 w-10 border-none",
},
{
id: "lock-button",
icon: !isLocked ? (
<LockOpenIcon className="size-4" />
) : (
@@ -55,15 +98,20 @@ export const CustomControls = memo(
];
return (
<div className="absolute bottom-4 left-4 z-10 flex flex-col items-center gap-2 rounded-full bg-white px-1 py-2 shadow-lg">
{controls.map((control, index) => (
<Tooltip key={index} delayDuration={300}>
<div
data-id="custom-controls"
className="absolute bottom-4 left-4 z-10 flex flex-col items-center gap-2 rounded-full bg-white px-1 py-2 shadow-lg"
>
{controls.map((control) => (
<Tooltip key={control.id} delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="icon"
size={"small"}
onClick={control.onClick}
className={control.className}
data-id={control.id}
disabled={"disabled" in control ? control.disabled : false}
>
{control.icon}
<span className="sr-only">{control.label}</span>

View File

@@ -26,6 +26,7 @@ const InputNodeHandle = ({
position={Position.Left}
id={cleanedHandleId}
className={"-ml-6 mr-2"}
data-tutorial-id={`input-handler-${nodeId}-${cleanedHandleId}`}
>
<div className="pointer-events-none">
<CircleIcon
@@ -62,6 +63,7 @@ const OutputNodeHandle = ({
position={Position.Right}
id={field_name}
className={"-mr-2 ml-2"}
data-tutorial-id={`output-handler-${nodeId}-${field_name}`}
>
<div className="pointer-events-none">
<CircleIcon

View File

@@ -27,6 +27,7 @@ export const NodeContainer = ({
status && nodeStyleBasedOnStatus[status],
hasErrors ? nodeStyleBasedOnStatus[AgentExecutionStatus.FAILED] : "",
)}
data-id={`custom-node-${nodeId}`}
>
{children}
</div>

View File

@@ -23,7 +23,10 @@ export const NodeDataRenderer = ({ nodeId }: { nodeId: string }) => {
}
return (
<div className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4">
<div
data-tutorial-id={`node-output`}
className="flex flex-col gap-3 rounded-b-xl border-t border-zinc-200 px-4 py-4"
>
<div className="flex items-center justify-between">
<Text variant="body-medium" className="!font-semibold text-slate-700">
Node Output

View File

@@ -44,7 +44,10 @@ export const FormCreator: React.FC<FormCreatorProps> = React.memo(
: hardcodedValues;
return (
<div className={className}>
<div
className={className}
data-id={`form-creator-container-${nodeId}-node`}
>
<FormRenderer
jsonSchema={jsonSchema}
handleChange={handleChange}

View File

@@ -52,7 +52,11 @@ export const OutputHandler = ({
const isBroken = brokenOutputs.has(fullKey);
return shouldShow ? (
<div key={fullKey} className="flex flex-col items-end gap-2">
<div
key={fullKey}
className="flex flex-col items-end gap-2"
data-tutorial-id={`output-handler-${nodeId}-${fieldTitle}`}
>
<div className="relative flex items-center gap-2">
{fieldSchema?.description && (
<TooltipProvider>

View File

@@ -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;

View File

@@ -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<string, BlockInfo> = new Map();
export const prefetchTutorialBlocks = async (): Promise<void> => {
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;
};

View File

@@ -0,0 +1,83 @@
import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants";
import { useNodeStore } from "../../../../stores/nodeStore";
export const waitForNodeOnCanvas = (
timeout = 10000,
): Promise<Element | null> => {
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<boolean> => {
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();
}
};

View File

@@ -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;
});
};

View File

@@ -0,0 +1,180 @@
import { TUTORIAL_CONFIG, TUTORIAL_SELECTORS } from "../constants";
export const waitForElement = (
selector: string,
timeout = 10000,
): Promise<Element> => {
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<void> => {
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<Element | null> => {
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<Element | null> => {
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);
};
};

View File

@@ -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" });
}
};

View File

@@ -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";

View File

@@ -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 }));
}
};

View File

@@ -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 !== "-");
};

View File

@@ -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");
};

View File

@@ -0,0 +1,7 @@
// These are SVG Phosphor icons
export const ICONS = {
ClickIcon: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M88,24V16a8,8,0,0,1,16,0v8a8,8,0,0,1-16,0ZM16,104h8a8,8,0,0,0,0-16H16a8,8,0,0,0,0,16ZM124.42,39.16a8,8,0,0,0,10.74-3.58l8-16a8,8,0,0,0-14.31-7.16l-8,16A8,8,0,0,0,124.42,39.16Zm-96,81.69-16,8a8,8,0,0,0,7.16,14.31l16-8a8,8,0,1,0-7.16-14.31ZM219.31,184a16,16,0,0,1,0,22.63l-12.68,12.68a16,16,0,0,1-22.63,0L132.7,168,115,214.09c0,.1-.08.21-.13.32a15.83,15.83,0,0,1-14.6,9.59l-.79,0a15.83,15.83,0,0,1-14.41-11L32.8,52.92A16,16,0,0,1,52.92,32.8L213,85.07a16,16,0,0,1,1.41,29.8l-.32.13L168,132.69ZM208,195.31,156.69,144h0a16,16,0,0,1,4.93-26l.32-.14,45.95-17.64L48,48l52.2,159.86,17.65-46c0-.11.08-.22.13-.33a16,16,0,0,1,11.69-9.34,16.72,16.72,0,0,1,3-.28,16,16,0,0,1,11.3,4.69L195.31,208Z"></path></svg>`,
Keyboard: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M224,48H32A16,16,0,0,0,16,64V192a16,16,0,0,0,16,16H224a16,16,0,0,0,16-16V64A16,16,0,0,0,224,48Zm0,144H32V64H224V192Zm-16-64a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,128Zm0-32a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16H200A8,8,0,0,1,208,96ZM72,160a8,8,0,0,1-8,8H56a8,8,0,0,1,0-16h8A8,8,0,0,1,72,160Zm96,0a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,160Zm40,0a8,8,0,0,1-8,8h-8a8,8,0,0,1,0-16h8A8,8,0,0,1,208,160Z"></path></svg>`,
Drag: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#000000" viewBox="0 0 256 256"><path d="M188,80a27.79,27.79,0,0,0-13.36,3.4,28,28,0,0,0-46.64-11A28,28,0,0,0,80,92v20H68a28,28,0,0,0-28,28v12a88,88,0,0,0,176,0V108A28,28,0,0,0,188,80Zm12,72a72,72,0,0,1-144,0V140a12,12,0,0,1,12-12H80v24a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V92a12,12,0,0,1,24,0v28a8,8,0,0,0,16,0V108a12,12,0,0,1,24,0Z"></path></svg>`,
};

View File

@@ -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();
};

View File

@@ -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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Excellent! This is your <strong>Calculator Block</strong>.</p>
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">Let's explore how blocks work.</p>
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">On the <strong>left side</strong> of the block are <strong>input handles</strong>.</p>
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">These are where data flows <em>into</em> the block from other blocks.</p>
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">On the <strong>right side</strong> is the <strong>output handle</strong>.</p>
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">This is where the result flows <em>out</em> to connect to other blocks.</p>
${banner(ICONS.Drag, "You can drag from output to input handler to connect blocks", "info")}
</div>
`,
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(),
},
],
},
];

View File

@@ -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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Let's start by opening the Block Menu.</p>
${banner(ICONS.ClickIcon, "Click this button to open the menu", "action")}
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">This is the <strong>Block Menu</strong> — your toolbox for building agents.</p>
<p class="text-sm font-medium leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">Here you'll find:</p>
<ul>
<li><strong>Input Blocks</strong> — Entry points for data</li>
<li><strong>Action Blocks</strong> — Processing and AI operations</li>
<li><strong>Output Blocks</strong> — Results and responses</li>
<li><strong>Integrations</strong> — Third-party service blocks</li>
<li><strong>Library Agents</strong> — Your personal agents</li>
<li><strong>Marketplace Agents</strong> — Community agents</li>
</ul>
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Let's add a Calculator block to start.</p>
${banner(ICONS.Keyboard, "Type Calculator in the search bar", "action")}
<p class="text-xs font-normal leading-[1.125rem] text-zinc-500 m-0" style="margin-top: 0.5rem;">The search will filter blocks as you type.</p>
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">You should see the <strong>Calculator</strong> block in the results.</p>
${banner(ICONS.ClickIcon, "Click on the Calculator block to add it", "action")}
<div class="bg-zinc-100 ring-1 ring-zinc-200 rounded-2xl p-2 px-4 mt-2 flex items-start gap-2 text-sm font-medium text-zinc-600">
<span class="flex-shrink-0">${ICONS.Drag}</span>
<span>You can also drag blocks onto the canvas</span>
</div>
</div>
`,
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;
},
},
},
];

View File

@@ -0,0 +1,51 @@
import { StepOptions } from "shepherd.js";
export const createCompletionSteps = (tour: any): StepOptions[] => [
{
id: "congratulations",
title: "Congratulations! 🎉",
text: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">You have successfully created and run your first agent flow!</p>
<div class="mt-3 p-3 bg-green-50 ring-1 ring-green-200 rounded-2xl">
<p class="text-sm font-medium text-green-600 m-0">You learned how to:</p>
<ul class="text-[0.8125rem] text-green-600 m-0 pl-4 mt-2 space-y-1">
<li>• Add blocks from the Block Menu</li>
<li>• Understand input and output handles</li>
<li>• Configure block values</li>
<li>• Connect blocks together</li>
<li>• Save and run your agent</li>
<li>• View execution status and output</li>
</ul>
</div>
<p class="text-sm font-medium leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.75rem;">Happy building! 🚀</p>
</div>
`,
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(),
},
],
},
];

View File

@@ -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 = () => `
<div id="requirements-box" class="mt-3 p-3 bg-amber-50 ring-1 ring-amber-200 rounded-2xl">
<p id="requirements-title" class="text-sm font-medium text-amber-600 m-0 mb-2">⚠️ Required to continue:</p>
<ul id="requirements-list" class="text-[0.8125rem] text-amber-600 m-0 pl-4 space-y-1">
<li id="req-a" class="flex items-center gap-2">
<span class="req-icon">○</span> Enter a number in field <strong>A</strong> (e.g., 10)
</li>
<li id="req-b" class="flex items-center gap-2">
<span class="req-icon">○</span> Enter a number in field <strong>B</strong> (e.g., 5)
</li>
<li id="req-op" class="flex items-center gap-2">
<span class="req-icon">○</span> Select an <strong>Operation</strong> (Add, Multiply, etc.)
</li>
</ul>
</div>
`;
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Now let's configure the block with actual values.</p>
${getRequirementsHtml()}
${banner(ICONS.ClickIcon, "Fill in all the required fields above", "action")}
</div>
`,
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",
},
],
},
];

View File

@@ -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) => `
<div id="${id}" class="mt-3 p-2 ${isConnected ? "bg-green-50 ring-1 ring-green-200" : "bg-amber-50 ring-1 ring-amber-200"} rounded-2xl text-center text-sm ${isConnected ? "text-green-600" : "text-amber-600"}">
${isConnected ? "✅ Connected!" : "Waiting for connection..."}
</div>
`;
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Now, let's connect the <strong>Result output</strong> of the first Calculator to the <strong>input (A)</strong> of the second Calculator.</p>
<div class="mt-3 p-3 bg-blue-50 ring-1 ring-blue-200 rounded-2xl">
<p class="text-sm font-medium text-blue-600 m-0 mb-2">Drag from the Result output:</p>
<p class="text-[0.8125rem] text-blue-600 m-0">Click and drag from the <strong>Result</strong> output pin (right side) of the <strong>first Calculator block</strong>.</p>
</div>
${getConnectionStatusHtml("connection-status-output", false)}
${banner(ICONS.Drag, "Drag from the Result output pin", "action")}
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Now, connect to the <strong>input (A)</strong> of the second Calculator block.</p>
<div class="mt-3 p-3 bg-blue-50 ring-1 ring-blue-200 rounded-2xl">
<p class="text-sm font-medium text-blue-600 m-0 mb-2">Drop on the A input:</p>
<p class="text-[0.8125rem] text-blue-600 m-0">Drag to the <strong>A</strong> input handle (left side) of the <strong>second Calculator block</strong>.</p>
</div>
${getConnectionStatusHtml("connection-status-input", false)}
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Excellent! Your Calculator blocks are now connected:</p>
<div class="mt-3 p-3 bg-green-50 ring-1 ring-green-200 rounded-2xl">
<div class="flex items-center justify-center gap-2 text-sm font-medium text-green-600">
<span>Calculator 1</span>
<span>→</span>
<span>Calculator 2</span>
</div>
<p class="text-[0.75rem] text-green-500 m-0 mt-2 text-center italic">The result of Calculator 1 flows into Calculator 2's input A</p>
</div>
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.75rem;">Now let's save and run your agent!</p>
</div>
`,
beforeShowPromise: async () => {
fitViewToScreen();
return Promise.resolve();
},
buttons: [
{
text: "Save My Agent",
action: () => tour.next(),
},
],
},
];
};

View File

@@ -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),
];

View File

@@ -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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Your agent is saved and ready! Now let's <strong>run it</strong> to see it in action.</p>
${banner(ICONS.ClickIcon, "Click the Run button", "action")}
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Here's the <strong>output</strong> of your block!</p>
<div class="mt-3 p-3 bg-blue-50 ring-1 ring-blue-200 rounded-2xl">
<p class="text-sm font-medium text-blue-600 m-0">Latest Output:</p>
<p class="text-[0.8125rem] text-blue-600 m-0 mt-1">After each run, you can see the result of each block at the bottom of the block.</p>
</div>
<div class="mt-2 p-2 bg-zinc-100 ring-1 ring-zinc-200 rounded-xl">
<p class="text-[0.8125rem] text-zinc-600 m-0">The output shows:</p>
<ul class="text-[0.8125rem] text-zinc-500 m-0 mt-1 pl-4">
<li>• The calculated result</li>
<li>• Execution timestamp</li>
</ul>
</div>
</div>
`,
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(),
},
],
},
];

View File

@@ -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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Before running, we need to <strong>save</strong> your agent.</p>
${banner(ICONS.ClickIcon, "Click the Save button", "action")}
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Give your agent a <strong>name</strong> and optional description.</p>
${banner(ICONS.ClickIcon, 'Enter a name and click "Save Agent"', "action")}
<p class="text-xs font-normal leading-[1.125rem] text-zinc-500 m-0" style="margin-top: 0.5rem;">Example: "My Calculator Agent"</p>
</div>
`,
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: [],
},
];

View File

@@ -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 = () => `
<div id="second-calc-requirements-box" class="mt-3 p-3 bg-amber-50 ring-1 ring-amber-200 rounded-2xl">
<p id="second-calc-requirements-title" class="text-sm font-medium text-amber-600 m-0 mb-2">⚠️ Required to continue:</p>
<ul id="second-calc-requirements-list" class="text-[0.8125rem] text-amber-600 m-0 pl-4 space-y-1">
<li id="req2-b" class="flex items-center gap-2">
<span class="req-icon">○</span> Enter a number in field <strong>B</strong> (e.g., 2)
</li>
<li id="req2-op" class="flex items-center gap-2">
<span class="req-icon">○</span> Select an <strong>Operation</strong> (e.g., Multiply)
</li>
</ul>
<p class="text-[0.75rem] text-amber-500 m-0 mt-2 italic">Note: Field A will be connected from the first Calculator's output</p>
</div>
`;
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Great job configuring the first Calculator!</p>
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">Now let's add a <strong>second Calculator block</strong> and connect them together.</p>
<div class="mt-3 p-3 bg-blue-50 ring-1 ring-blue-200 rounded-2xl">
<p class="text-sm font-medium text-blue-600 m-0 mb-1">We'll create a chain:</p>
<p class="text-[0.8125rem] text-blue-600 m-0">Calculator 1 → Calculator 2</p>
<p class="text-[0.75rem] text-blue-500 m-0 mt-1 italic">The output of the first will feed into the second!</p>
</div>
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">I've added a <strong>second Calculator block</strong> to your canvas.</p>
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.5rem;">Now let's configure it and connect them together.</p>
<div class="mt-3 p-3 bg-green-50 ring-1 ring-green-200 rounded-2xl">
<p class="text-sm font-medium text-green-600 m-0">You now have 2 Calculator blocks!</p>
</div>
</div>
`,
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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">Now configure the <strong>second Calculator block</strong>.</p>
${getSecondCalcRequirementsHtml()}
${banner(ICONS.ClickIcon, "Fill in field B and select an Operation", "action")}
</div>
`,
beforeShowPromise: async () => {
fitViewToScreen();
await new Promise<void>((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",
},
],
},
];

View File

@@ -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: `
<div class="text-sm leading-[1.375rem] text-zinc-800">
<p class="text-sm font-normal leading-[1.375rem] text-zinc-800 m-0">This interactive tutorial will teach you how to build your first AI agent.</p>
<p class="text-sm font-medium leading-[1.375rem] text-zinc-800 m-0" style="margin-top: 0.75rem;">You'll learn how to:</p>
<ul class="pl-2 text-sm pt-2">
<li>- Add blocks to your workflow</li>
<li>- Understand block inputs and outputs</li>
<li>- Save and run your agent</li>
<li>- and much more...</li>
</ul>
<p class="text-xs font-normal leading-[1.125rem] text-zinc-500 m-0" style="margin-top: 0.75rem;">Estimated time: 3-4 minutes</p>
</div>
`,
buttons: [
{
text: "Skip Tutorial",
action: () => handleTutorialSkip(tour),
classes: "shepherd-button-secondary",
},
{
text: "Let's Begin",
action: () => tour.next(),
},
],
},
];

View File

@@ -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 `
<div class="${styles.bg} ring-1 ${styles.ring} rounded-2xl p-2 px-4 mt-2 flex items-start gap-2 text-sm font-medium ${styles.text} ${className || ""}">
<span class="flex-shrink-0">${icon}</span>
<span>${content}</span>
</div>
`;
};
// Requirement box components
export const requirementBox = (
title: string,
items: string,
variant: "warning" | "success" = "warning",
) => {
const isSuccess = variant === "success";
return `
<div id="requirements-box" class="mt-3 p-3 ${isSuccess ? "bg-green-50 ring-1 ring-green-200" : "bg-amber-50 ring-1 ring-amber-200"} rounded-2xl">
<p class="text-sm font-medium ${isSuccess ? "text-green-600" : "text-amber-600"} m-0 mb-2">${title}</p>
${items}
</div>
`;
};
export const requirementItem = (id: string, content: string) => `
<li id="${id}" class="flex items-center gap-2 text-amber-600">
<span class="req-icon">○</span> ${content}
</li>
`;
// Connection status box
export const connectionStatusBox = (
id: string,
variant: "waiting" | "connected" = "waiting",
) => {
const isConnected = variant === "connected";
return `
<div id="${id}" class="mt-3 p-2 ${isConnected ? "bg-green-50 ring-1 ring-green-200" : "bg-amber-50 ring-1 ring-amber-200"} rounded-2xl text-center text-sm ${isConnected ? "text-green-600" : "text-amber-600"}">
${isConnected ? "✅ Connection already exists!" : "Waiting for connection..."}
</div>
`;
};

View File

@@ -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;
}

View File

@@ -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 (
<Button
draggable={true}
data-id={blockDataId}
className={cn(
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",

View File

@@ -14,10 +14,17 @@ import { ControlPanelButton } from "../../ControlPanelButton";
import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
export const BlockMenu = () => {
const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
const { blockMenuOpen, setBlockMenuOpen, forceOpenBlockMenu } =
useControlPanelStore();
return (
// pinBlocksPopover ? true : open
<Popover onOpenChange={setBlockMenuOpen} open={blockMenuOpen}>
<Popover
onOpenChange={(open) => {
if (!forceOpenBlockMenu || open) {
setBlockMenuOpen(open);
}
}}
open={forceOpenBlockMenu ? true : blockMenuOpen}
>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<PopoverTrigger className="hover:cursor-pointer">

View File

@@ -5,7 +5,10 @@ import { BlockMenuSearchContent } from "../BlockMenuSearchContent/BlockMenuSearc
export const BlockMenuSearch = () => {
return (
<div className={blockMenuContainerStyle}>
<div
className={blockMenuContainerStyle}
data-id="blocks-control-search-results"
>
<BlockMenuFilters />
<Text variant="body-medium">Search results</Text>
<BlockMenuSearchContent />

View File

@@ -22,6 +22,7 @@ export const BlockMenuSearchBar: React.FC<BlockMenuSearchBarProps> = ({
return (
<div
data-id="blocks-control-search-bar"
className={cn(
"flex min-h-[3.5625rem] items-center gap-2.5 px-4",
className,

View File

@@ -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 ||

View File

@@ -8,6 +8,7 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
selected?: boolean;
number?: number;
name?: string;
menuItemType?: string;
}
export const MenuItem: React.FC<Props> = ({
@@ -15,10 +16,12 @@ export const MenuItem: React.FC<Props> = ({
number,
name,
className,
menuItemType,
...rest
}) => {
return (
<Button
data-id={menuItemType ? `menu-item-${menuItemType}` : undefined}
className={cn(
"flex h-[2.375rem] w-[12.875rem] justify-between whitespace-normal rounded-[0.5rem] bg-transparent p-2 pl-3 shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0",

View File

@@ -19,10 +19,18 @@ import { useNewSaveControl } from "./useNewSaveControl";
export const NewSaveControl = () => {
const { form, isSaving, graphVersion, handleSave } = useNewSaveControl();
const { saveControlOpen, setSaveControlOpen } = useControlPanelStore();
const { saveControlOpen, setSaveControlOpen, forceOpenSave } =
useControlPanelStore();
return (
<Popover onOpenChange={setSaveControlOpen}>
<Popover
onOpenChange={(open) => {
if (!forceOpenSave || open) {
setSaveControlOpen(open);
}
}}
open={forceOpenSave ? true : saveControlOpen}
>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
@@ -94,6 +102,7 @@ export const NewSaveControl = () => {
value={graphVersion || "-"}
disabled
data-testid="save-control-version-output"
data-tutorial-id="save-control-version-output"
label="Version"
wrapperClassName="!mb-0"
/>

View File

@@ -42,7 +42,12 @@ export const UndoRedoButtons = () => {
<>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<ControlPanelButton as="button" disabled={!canUndo()} onClick={undo}>
<ControlPanelButton
as="button"
data-id="undo-button"
disabled={!canUndo()}
onClick={undo}
>
<ArrowUUpLeftIcon className="size-5" />
</ControlPanelButton>
</TooltipTrigger>
@@ -51,7 +56,12 @@ export const UndoRedoButtons = () => {
<Separator className="text-[#E1E1E1]" />
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<ControlPanelButton as="button" disabled={!canRedo()} onClick={redo}>
<ControlPanelButton
as="button"
data-id="redo-button"
disabled={!canRedo()}
onClick={redo}
>
<ArrowUUpRightIcon className="size-5" />
</ControlPanelButton>
</TooltipTrigger>

View File

@@ -83,6 +83,7 @@ export const BuildActionBar: React.FC<Props> = ({
title="Run the agent"
aria-label="Run the agent"
data-testid="primary-action-run-agent"
data-tutorial-id="primary-action-run-agent"
>
<IconPlay /> Run
</Button>

View File

@@ -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(() => {

View File

@@ -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<ControlPanelStore>((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,
}),
}));

View File

@@ -47,6 +47,7 @@ const dragStartPositions: Record<string, XYPosition> = {};
type NodeStore = {
nodes: CustomNode[];
nodeCounter: number;
setNodeCounter: (nodeCounter: number) => void;
nodeAdvancedStates: Record<string, boolean>;
setNodes: (nodes: CustomNode[]) => void;
onNodesChange: (changes: NodeChange<CustomNode>[]) => void;
@@ -116,6 +117,7 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
nodes: [],
setNodes: (nodes) => set({ nodes }),
nodeCounter: 0,
setNodeCounter: (nodeCounter) => set({ nodeCounter }),
nodeAdvancedStates: {},
incrementNodeCounter: () =>
set((state) => ({

View File

@@ -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<string, any>;
setTutorialInputValues: (values: Record<string, any>) => void;
};
export const useTutorialStore = create<TutorialStore>((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 }),
}));

View File

@@ -3,9 +3,14 @@
import React from "react";
import { useTallyPopup } from "./useTallyPopup";
import { Button } from "@/components/atoms/Button/Button";
import { usePathname, useSearchParams } from "next/navigation";
export function TallyPopupSimple() {
const { state, handlers } = useTallyPopup();
const searchParams = useSearchParams();
const pathname = usePathname();
const isNewBuilder =
pathname.includes("build") && searchParams.get("view") === "new";
if (state.isFormVisible) {
return null;
@@ -13,7 +18,7 @@ export function TallyPopupSimple() {
return (
<div className="fixed bottom-1 right-0 z-20 hidden select-none items-center gap-4 p-3 transition-all duration-300 ease-in-out md:flex">
{state.showTutorial && (
{state.showTutorial && !isNewBuilder && (
<Button
variant="primary"
onClick={handlers.handleResetTutorial}

View File

@@ -31,7 +31,7 @@ export const FormRenderer = ({
}, [preprocessedSchema, uiSchema]);
return (
<div className={"mb-6 mt-4"}>
<div className={"mb-6 mt-4"} data-tutorial-id="input-handles">
<Form
formContext={formContext}
idPrefix="agpt"

View File

@@ -28,12 +28,14 @@ export default function TitleField(props: TitleFieldProps) {
const showHandle = uiOptions.showHandles ?? showHandles;
const fieldPath = id.replace(/^agpt_%_/, "");
const tutorialId = nodeId ? `label-${nodeId}-${fieldPath}` : undefined;
const isInputBroken = useNodeStore((state) =>
state.isInputBroken(nodeId, cleanUpHandleId(uiOptions.handleId)),
);
return (
<div className="flex items-center">
<div className="flex items-center" data-tutorial-id={tutorialId}>
{showHandle !== false && (
<InputNodeHandle handleId={uiOptions.handleId} nodeId={nodeId} />
)}