Jon/workspace (#3175)
This commit is contained in:
@@ -158,6 +158,8 @@ function FloatingWindow({
|
||||
zIndex,
|
||||
// --
|
||||
onCycle,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onInteract,
|
||||
}: {
|
||||
bounded?: boolean;
|
||||
@@ -172,9 +174,11 @@ function FloatingWindow({
|
||||
showPowerButton?: boolean;
|
||||
showReloadButton?: boolean;
|
||||
title: string;
|
||||
zIndex?: string;
|
||||
zIndex?: number;
|
||||
// --
|
||||
onCycle?: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onInteract?: () => void;
|
||||
}) {
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
@@ -217,6 +221,7 @@ function FloatingWindow({
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
const os = getOs();
|
||||
|
||||
@@ -284,9 +289,10 @@ function FloatingWindow({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialWidth || !initialHeight) {
|
||||
if (hasInitialized.current || !initialWidth || !initialHeight) {
|
||||
return;
|
||||
}
|
||||
hasInitialized.current = true;
|
||||
setSize({
|
||||
left: initialPosition?.x ?? 0,
|
||||
top: initialPosition?.y ?? 0,
|
||||
@@ -533,6 +539,16 @@ function FloatingWindow({
|
||||
className={cn("border-2 border-gray-700", {
|
||||
"hover:border-slate-500": !isMaximized,
|
||||
})}
|
||||
handleStyles={{
|
||||
bottomLeft: {
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
},
|
||||
bottomRight: {
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
},
|
||||
}}
|
||||
minHeight={Constants.MinHeight}
|
||||
minWidth={Constants.MinWidth}
|
||||
// TODO: turn back on; turning off clears a resize bug atm
|
||||
@@ -556,6 +572,7 @@ function FloatingWindow({
|
||||
return;
|
||||
}
|
||||
|
||||
onFocus?.();
|
||||
setIsMinimized(false);
|
||||
setIsResizing(true);
|
||||
setDragStartSize({ ...size, left: position.x, top: position.y });
|
||||
@@ -565,6 +582,7 @@ function FloatingWindow({
|
||||
return;
|
||||
}
|
||||
|
||||
onFocus?.();
|
||||
onResize({ delta, direction, size });
|
||||
}}
|
||||
onResizeStop={() => {
|
||||
@@ -581,7 +599,8 @@ function FloatingWindow({
|
||||
<div
|
||||
ref={resizableRef}
|
||||
key={reloadKey}
|
||||
className="my-window"
|
||||
className="my-window focus:outline-none"
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
padding: "0px",
|
||||
@@ -590,7 +609,12 @@ function FloatingWindow({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
onMouseDownCapture={() => onInteract?.()}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onMouseDownCapture={(e) => {
|
||||
onInteract?.();
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
toggleMaximized();
|
||||
}}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useOnChange } from "@/hooks/useOnChange";
|
||||
import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab";
|
||||
import { BlockActionContext } from "@/store/BlockActionContext";
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
useWorkflowSave,
|
||||
type WorkflowSaveData,
|
||||
} from "@/store/WorkflowHasChangesStore";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
@@ -25,17 +23,15 @@ import {
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Edge,
|
||||
Panel,
|
||||
PanOnScrollMode,
|
||||
ReactFlow,
|
||||
Viewport,
|
||||
useEdgesState,
|
||||
useNodesInitialized,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
NodeChange,
|
||||
EdgeChange,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { nanoid } from "nanoid";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useBlocker } from "react-router-dom";
|
||||
import {
|
||||
@@ -56,22 +52,13 @@ import {
|
||||
ParameterYAML,
|
||||
WorkflowParameterYAML,
|
||||
} from "../types/workflowYamlTypes";
|
||||
import { WorkflowHeader } from "./WorkflowHeader";
|
||||
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
|
||||
import {
|
||||
BITWARDEN_CLIENT_ID_AWS_SECRET_KEY,
|
||||
BITWARDEN_CLIENT_SECRET_AWS_SECRET_KEY,
|
||||
BITWARDEN_MASTER_PASSWORD_AWS_SECRET_KEY,
|
||||
} from "./constants";
|
||||
import { edgeTypes } from "./edges";
|
||||
import {
|
||||
AppNode,
|
||||
isWorkflowBlockNode,
|
||||
nodeTypes,
|
||||
WorkflowBlockNode,
|
||||
} from "./nodes";
|
||||
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
|
||||
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
||||
import { AppNode, isWorkflowBlockNode, nodeTypes } from "./nodes";
|
||||
import {
|
||||
ParametersState,
|
||||
parameterIsSkyvernCredential,
|
||||
@@ -81,22 +68,14 @@ import {
|
||||
import "./reactFlowOverrideStyles.css";
|
||||
import {
|
||||
convertEchoParameters,
|
||||
createNode,
|
||||
defaultEdge,
|
||||
descendants,
|
||||
generateNodeLabel,
|
||||
getAdditionalParametersForEmailBlock,
|
||||
getOrderedChildrenBlocks,
|
||||
getOutputParameterKey,
|
||||
getWorkflowBlocks,
|
||||
getWorkflowErrors,
|
||||
getWorkflowSettings,
|
||||
layout,
|
||||
nodeAdderNode,
|
||||
startNode,
|
||||
} from "./workflowEditorUtils";
|
||||
import { cn } from "@/util/utils";
|
||||
import { WorkflowDebuggerRun } from "@/routes/workflows/editor/WorkflowDebuggerRun";
|
||||
import { useAutoPan } from "./useAutoPan";
|
||||
|
||||
function convertToParametersYAML(
|
||||
@@ -237,38 +216,38 @@ function convertToParametersYAML(
|
||||
}
|
||||
|
||||
type Props = {
|
||||
nodes: Array<AppNode>;
|
||||
edges: Array<Edge>;
|
||||
setNodes: (nodes: Array<AppNode>) => void;
|
||||
setEdges: (edges: Array<Edge>) => void;
|
||||
onNodesChange: (changes: Array<NodeChange<AppNode>>) => void;
|
||||
onEdgesChange: (changes: Array<EdgeChange>) => void;
|
||||
initialTitle: string;
|
||||
initialNodes: Array<AppNode>;
|
||||
initialEdges: Array<Edge>;
|
||||
initialParameters: ParametersState;
|
||||
workflow: WorkflowApiResponse;
|
||||
};
|
||||
|
||||
export type AddNodeProps = {
|
||||
nodeType: NonNullable<WorkflowBlockNode["type"]>;
|
||||
previous: string | null;
|
||||
next: string | null;
|
||||
parent?: string;
|
||||
connectingEdgeType: string;
|
||||
onDebuggableBlockCountChange: (count: number) => void;
|
||||
onMouseDownCapture?: () => void;
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
function FlowRenderer({
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
initialTitle,
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
initialParameters,
|
||||
workflow,
|
||||
onDebuggableBlockCountChange,
|
||||
onMouseDownCapture,
|
||||
zIndex,
|
||||
}: Props) {
|
||||
const reactFlowInstance = useReactFlow();
|
||||
const debugStore = useDebugStore();
|
||||
const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } =
|
||||
useWorkflowPanelStore();
|
||||
const { title, initializeTitle } = useWorkflowTitleStore();
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const [parameters, setParameters] =
|
||||
useState<ParametersState>(initialParameters);
|
||||
const [debuggableBlockCount, setDebuggableBlockCount] = useState(0);
|
||||
const [parameters] = useState<ParametersState>(initialParameters);
|
||||
const nodesInitialized = useNodesInitialized();
|
||||
const [shouldConstrainPan, setShouldConstrainPan] = useState(false);
|
||||
const onNodesChangeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -324,8 +303,8 @@ function FlowRenderer({
|
||||
}
|
||||
}
|
||||
|
||||
setDebuggableBlockCount(debuggable.length);
|
||||
}, [nodes, edges]);
|
||||
onDebuggableBlockCountChange(debuggable.length);
|
||||
}, [nodes, edges, onDebuggableBlockCountChange]);
|
||||
|
||||
const constructSaveData = useCallback((): WorkflowSaveData => {
|
||||
const blocks = getWorkflowBlocks(nodes, edges);
|
||||
@@ -371,88 +350,6 @@ function FlowRenderer({
|
||||
return await saveWorkflow.mutateAsync();
|
||||
}
|
||||
|
||||
function addNode({
|
||||
nodeType,
|
||||
previous,
|
||||
next,
|
||||
parent,
|
||||
connectingEdgeType,
|
||||
}: AddNodeProps) {
|
||||
const newNodes: Array<AppNode> = [];
|
||||
const newEdges: Array<Edge> = [];
|
||||
const id = nanoid();
|
||||
const existingLabels = nodes
|
||||
.filter(isWorkflowBlockNode)
|
||||
.map((node) => node.data.label);
|
||||
const node = createNode(
|
||||
{ id, parentId: parent },
|
||||
nodeType,
|
||||
generateNodeLabel(existingLabels),
|
||||
);
|
||||
newNodes.push(node);
|
||||
if (previous) {
|
||||
const newEdge = {
|
||||
id: nanoid(),
|
||||
type: "edgeWithAddButton",
|
||||
source: previous,
|
||||
target: id,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
};
|
||||
newEdges.push(newEdge);
|
||||
}
|
||||
if (next) {
|
||||
const newEdge = {
|
||||
id: nanoid(),
|
||||
type: connectingEdgeType,
|
||||
source: id,
|
||||
target: next,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
};
|
||||
newEdges.push(newEdge);
|
||||
}
|
||||
|
||||
if (nodeType === "loop") {
|
||||
// when loop node is first created it needs an adder node so nodes can be added inside the loop
|
||||
const startNodeId = nanoid();
|
||||
const adderNodeId = nanoid();
|
||||
newNodes.push(
|
||||
startNode(
|
||||
startNodeId,
|
||||
{
|
||||
withWorkflowSettings: false,
|
||||
editable: true,
|
||||
},
|
||||
id,
|
||||
),
|
||||
);
|
||||
newNodes.push(nodeAdderNode(adderNodeId, id));
|
||||
newEdges.push(defaultEdge(startNodeId, adderNodeId));
|
||||
}
|
||||
|
||||
const editedEdges = previous
|
||||
? edges.filter((edge) => edge.source !== previous)
|
||||
: edges;
|
||||
|
||||
const previousNode = nodes.find((node) => node.id === previous);
|
||||
const previousNodeIndex = previousNode
|
||||
? nodes.indexOf(previousNode)
|
||||
: nodes.length - 1;
|
||||
|
||||
// creating some memory for no reason, maybe check it out later
|
||||
const newNodesAfter = [
|
||||
...nodes.slice(0, previousNodeIndex + 1),
|
||||
...newNodes,
|
||||
...nodes.slice(previousNodeIndex + 1),
|
||||
];
|
||||
|
||||
workflowChangesStore.setHasChanges(true);
|
||||
doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
|
||||
}
|
||||
|
||||
function deleteNode(id: string) {
|
||||
const node = nodes.find((node) => node.id === id);
|
||||
if (!node || !isWorkflowBlockNode(node)) {
|
||||
@@ -629,7 +526,11 @@ function FlowRenderer({
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{ zIndex }}
|
||||
onMouseDownCapture={() => onMouseDownCapture?.()}
|
||||
>
|
||||
<Dialog
|
||||
open={blocker.state === "blocked"}
|
||||
onOpenChange={(open) => {
|
||||
@@ -671,155 +572,83 @@ function FlowRenderer({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<WorkflowParametersStateContext.Provider
|
||||
value={[parameters, setParameters]}
|
||||
<BlockActionContext.Provider
|
||||
value={{
|
||||
deleteNodeCallback: deleteNode,
|
||||
toggleScriptForNodeCallback: toggleScript,
|
||||
}}
|
||||
>
|
||||
<BlockActionContext.Provider
|
||||
value={{
|
||||
deleteNodeCallback: deleteNode,
|
||||
toggleScriptForNodeCallback: toggleScript,
|
||||
<ReactFlow
|
||||
ref={editorElementRef}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={(changes) => {
|
||||
const dimensionChanges = changes.filter(
|
||||
(change) => change.type === "dimensions",
|
||||
);
|
||||
const tempNodes = [...nodes];
|
||||
dimensionChanges.forEach((change) => {
|
||||
const node = tempNodes.find((node) => node.id === change.id);
|
||||
if (node) {
|
||||
if (node.measured?.width) {
|
||||
node.measured.width = change.dimensions?.width;
|
||||
}
|
||||
if (node.measured?.height) {
|
||||
node.measured.height = change.dimensions?.height;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (dimensionChanges.length > 0) {
|
||||
doLayout(tempNodes, edges);
|
||||
}
|
||||
if (
|
||||
changes.some((change) => {
|
||||
return (
|
||||
change.type === "add" ||
|
||||
change.type === "remove" ||
|
||||
change.type === "replace"
|
||||
);
|
||||
})
|
||||
) {
|
||||
workflowChangesStore.setHasChanges(true);
|
||||
}
|
||||
// throttle onNodesChange to prevent cascading React updates
|
||||
if (onNodesChangeTimeoutRef.current === null) {
|
||||
onNodesChange(changes);
|
||||
onNodesChangeTimeoutRef.current = setTimeout(() => {
|
||||
onNodesChangeTimeoutRef.current = null;
|
||||
}, 33); // ~30fps throttle
|
||||
}
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
colorMode="dark"
|
||||
fitView={true}
|
||||
fitViewOptions={{
|
||||
maxZoom: 1,
|
||||
}}
|
||||
deleteKeyCode={null}
|
||||
onMove={(_, viewport) => {
|
||||
if (debugStore.isDebugMode && shouldConstrainPan) {
|
||||
constrainPan(viewport);
|
||||
}
|
||||
}}
|
||||
maxZoom={debugStore.isDebugMode ? 1 : 2}
|
||||
minZoom={debugStore.isDebugMode ? 1 : 0.5}
|
||||
panOnDrag={true}
|
||||
panOnScroll={true}
|
||||
panOnScrollMode={PanOnScrollMode.Vertical}
|
||||
zoomOnDoubleClick={!debugStore.isDebugMode}
|
||||
zoomOnPinch={!debugStore.isDebugMode}
|
||||
zoomOnScroll={!debugStore.isDebugMode}
|
||||
>
|
||||
<ReactFlow
|
||||
ref={editorElementRef}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={(changes) => {
|
||||
const dimensionChanges = changes.filter(
|
||||
(change) => change.type === "dimensions",
|
||||
);
|
||||
const tempNodes = [...nodes];
|
||||
dimensionChanges.forEach((change) => {
|
||||
const node = tempNodes.find((node) => node.id === change.id);
|
||||
if (node) {
|
||||
if (node.measured?.width) {
|
||||
node.measured.width = change.dimensions?.width;
|
||||
}
|
||||
if (node.measured?.height) {
|
||||
node.measured.height = change.dimensions?.height;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (dimensionChanges.length > 0) {
|
||||
doLayout(tempNodes, edges);
|
||||
}
|
||||
if (
|
||||
changes.some((change) => {
|
||||
return (
|
||||
change.type === "add" ||
|
||||
change.type === "remove" ||
|
||||
change.type === "replace"
|
||||
);
|
||||
})
|
||||
) {
|
||||
workflowChangesStore.setHasChanges(true);
|
||||
}
|
||||
|
||||
// throttle onNodesChange to prevent cascading React updates
|
||||
if (onNodesChangeTimeoutRef.current === null) {
|
||||
onNodesChange(changes);
|
||||
onNodesChangeTimeoutRef.current = setTimeout(() => {
|
||||
onNodesChangeTimeoutRef.current = null;
|
||||
}, 33); // ~30fps throttle
|
||||
}
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
colorMode="dark"
|
||||
fitView={true}
|
||||
fitViewOptions={{
|
||||
maxZoom: 1,
|
||||
}}
|
||||
deleteKeyCode={null}
|
||||
onMove={(_, viewport) => {
|
||||
if (debugStore.isDebugMode && shouldConstrainPan) {
|
||||
constrainPan(viewport);
|
||||
}
|
||||
}}
|
||||
maxZoom={debugStore.isDebugMode ? 1 : 2}
|
||||
minZoom={debugStore.isDebugMode ? 1 : 0.5}
|
||||
panOnDrag={true}
|
||||
panOnScroll={true}
|
||||
panOnScrollMode={PanOnScrollMode.Vertical}
|
||||
zoomOnDoubleClick={!debugStore.isDebugMode}
|
||||
zoomOnPinch={!debugStore.isDebugMode}
|
||||
zoomOnScroll={!debugStore.isDebugMode}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
|
||||
<Controls position="bottom-left" />
|
||||
{debugStore.isDebugMode && (
|
||||
<Panel
|
||||
position="top-right"
|
||||
className="!bottom-[1rem] !right-[1.5rem] !top-0"
|
||||
>
|
||||
<div className="pointer-events-none absolute right-0 top-0 flex h-full w-[400px] flex-col items-end justify-end">
|
||||
<div className="pointer-events-auto relative mt-[8.5rem] h-full w-full overflow-hidden rounded-xl border-2 border-slate-500">
|
||||
<WorkflowDebuggerRun />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
<Panel position="top-center" className={cn("h-20")}>
|
||||
<WorkflowHeader
|
||||
debuggableBlockCount={debuggableBlockCount}
|
||||
saving={workflowChangesStore.saveIsPending}
|
||||
parametersPanelOpen={
|
||||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "parameters"
|
||||
}
|
||||
onParametersClick={() => {
|
||||
if (
|
||||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "parameters"
|
||||
) {
|
||||
closeWorkflowPanel();
|
||||
} else {
|
||||
setWorkflowPanelState({
|
||||
active: true,
|
||||
content: "parameters",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSave={async () => {
|
||||
const errors = getWorkflowErrors(nodes);
|
||||
if (errors.length > 0) {
|
||||
toast({
|
||||
title: "Can not save workflow because of errors:",
|
||||
description: (
|
||||
<div className="space-y-2">
|
||||
{errors.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await handleSave();
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
{workflowPanelState.active && (
|
||||
<Panel position="top-right">
|
||||
{workflowPanelState.content === "parameters" && (
|
||||
<WorkflowParametersPanel />
|
||||
)}
|
||||
{workflowPanelState.content === "nodeLibrary" && (
|
||||
<WorkflowNodeLibraryPanel
|
||||
onNodeClick={(props) => {
|
||||
addNode(props);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</BlockActionContext.Provider>
|
||||
</WorkflowParametersStateContext.Provider>
|
||||
</>
|
||||
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
|
||||
<Controls position="bottom-left" />
|
||||
</ReactFlow>
|
||||
</BlockActionContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { FlowRenderer };
|
||||
export { FlowRenderer, type Props as FlowRendererProps };
|
||||
|
||||
@@ -1,175 +1,18 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { DebugSessionApiResponse } from "@/api/types";
|
||||
import { AnimatedWave } from "@/components/AnimatedWave";
|
||||
import { BrowserStream } from "@/components/BrowserStream";
|
||||
import { FloatingWindow } from "@/components/FloatingWindow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useMountEffect } from "@/hooks/useMountEffect";
|
||||
import { statusIsFinalized } from "@/routes/tasks/types.ts";
|
||||
import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery";
|
||||
import { useBlockScriptStore } from "@/store/BlockScriptStore";
|
||||
import { useSidebarStore } from "@/store/SidebarStore";
|
||||
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
|
||||
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
|
||||
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
|
||||
import { useDebugSessionQuery } from "../hooks/useDebugSessionQuery";
|
||||
import { WorkflowSettings } from "../types/workflowTypes";
|
||||
import { FlowRenderer } from "./FlowRenderer";
|
||||
import { getElements } from "./workflowEditorUtils";
|
||||
import { getInitialParameters } from "./utils";
|
||||
|
||||
const Constants = {
|
||||
NewBrowserCooldown: 30000,
|
||||
} as const;
|
||||
import { Workspace } from "./Workspace";
|
||||
|
||||
function WorkflowDebugger() {
|
||||
const { blockLabel, workflowPermanentId } = useParams();
|
||||
const [openDialogue, setOpenDialogue] = useState(false);
|
||||
const [activeDebugSession, setActiveDebugSession] =
|
||||
useState<DebugSessionApiResponse | null>(null);
|
||||
const [showPowerButton, setShowPowerButton] = useState(true);
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
const [shouldFetchDebugSession, setShouldFetchDebugSession] = useState(false);
|
||||
const blockScriptStore = useBlockScriptStore();
|
||||
|
||||
const { data: workflowRun } = useWorkflowRunQuery();
|
||||
|
||||
const { workflowPermanentId } = useParams();
|
||||
const { data: workflow } = useWorkflowQuery({
|
||||
workflowPermanentId,
|
||||
});
|
||||
|
||||
const { data: blockScripts } = useBlockScriptsQuery({
|
||||
workflowPermanentId,
|
||||
});
|
||||
|
||||
const { data: debugSession } = useDebugSessionQuery({
|
||||
workflowPermanentId,
|
||||
enabled: shouldFetchDebugSession && !!workflowPermanentId,
|
||||
});
|
||||
|
||||
const setCollapsed = useSidebarStore((state) => {
|
||||
return state.setCollapsed;
|
||||
});
|
||||
|
||||
const workflowChangesStore = useWorkflowHasChangesStore();
|
||||
|
||||
const handleOnCycle = () => {
|
||||
setOpenDialogue(true);
|
||||
};
|
||||
|
||||
useMountEffect(() => {
|
||||
setCollapsed(true);
|
||||
workflowChangesStore.setHasChanges(false);
|
||||
|
||||
if (workflowPermanentId) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["debugSession", workflowPermanentId],
|
||||
});
|
||||
setShouldFetchDebugSession(true);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
blockScriptStore.setScripts(blockScripts ?? {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blockScripts]);
|
||||
|
||||
const afterCycleBrowser = () => {
|
||||
setOpenDialogue(false);
|
||||
setShowPowerButton(false);
|
||||
|
||||
if (powerButtonTimeoutRef.current) {
|
||||
clearTimeout(powerButtonTimeoutRef.current);
|
||||
}
|
||||
|
||||
powerButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShowPowerButton(true);
|
||||
}, Constants.NewBrowserCooldown);
|
||||
};
|
||||
|
||||
const cycleBrowser = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
return client.post<DebugSessionApiResponse>(`/debug-session/${id}/new`);
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
const newDebugSession = response.data;
|
||||
setActiveDebugSession(newDebugSession);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["debugSession", workflowPermanentId],
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Browser cycled",
|
||||
variant: "success",
|
||||
description: "Your browser has been cycled.",
|
||||
});
|
||||
|
||||
afterCycleBrowser();
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to cycle browser",
|
||||
description: error.message,
|
||||
});
|
||||
|
||||
afterCycleBrowser();
|
||||
},
|
||||
});
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const powerButtonTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(!debugSession || !debugSession.browser_session_id) &&
|
||||
shouldFetchDebugSession &&
|
||||
workflowPermanentId
|
||||
) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["debugSession", workflowPermanentId],
|
||||
});
|
||||
}, 2000);
|
||||
} else {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (debugSession) {
|
||||
setActiveDebugSession(debugSession);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [debugSession, shouldFetchDebugSession, workflowPermanentId, queryClient]);
|
||||
|
||||
if (!workflow) {
|
||||
return null;
|
||||
}
|
||||
@@ -193,110 +36,18 @@ function WorkflowDebugger() {
|
||||
true,
|
||||
);
|
||||
|
||||
const isFinalized = workflowRun ? statusIsFinalized(workflowRun) : null;
|
||||
const interactor = workflowRun && isFinalized === false ? "agent" : "human";
|
||||
const browserTitle = interactor === "agent" ? `Browser [🤖]` : `Browser [👤]`;
|
||||
|
||||
// ---start fya: https://github.com/frontyardart
|
||||
const initialBrowserPosition = {
|
||||
x: 600,
|
||||
y: 132,
|
||||
};
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
const rightPadding = 567;
|
||||
const initialWidth = Math.max(
|
||||
512,
|
||||
windowWidth - initialBrowserPosition.x - rightPadding,
|
||||
);
|
||||
const initialHeight = (initialWidth / 16) * 9;
|
||||
// ---end fya
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen w-full">
|
||||
<Dialog
|
||||
open={openDialogue}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && cycleBrowser.isPending) {
|
||||
return;
|
||||
}
|
||||
setOpenDialogue(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cycle (Get a new browser)</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="pb-2 pt-4 text-sm text-slate-400">
|
||||
{cycleBrowser.isPending ? (
|
||||
<>
|
||||
Cooking you up a fresh browser...
|
||||
<AnimatedWave text=".‧₊˚ ⋅ ✨★ ‧₊˚ ⋅" />
|
||||
</>
|
||||
) : (
|
||||
"Abandon this browser for a new one. Are you sure?"
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
{!cycleBrowser.isPending && (
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
cycleBrowser.mutate(workflowPermanentId!);
|
||||
}}
|
||||
disabled={cycleBrowser.isPending}
|
||||
>
|
||||
Yes, Continue{" "}
|
||||
{cycleBrowser.isPending && (
|
||||
<ReloadIcon className="ml-2 size-4 animate-spin" />
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ReactFlowProvider>
|
||||
<FlowRenderer
|
||||
<Workspace
|
||||
initialEdges={elements.edges}
|
||||
initialNodes={elements.nodes}
|
||||
initialParameters={getInitialParameters(workflow)}
|
||||
initialTitle={workflow.title}
|
||||
showBrowser={true}
|
||||
workflow={workflow}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
|
||||
<FloatingWindow
|
||||
title={browserTitle}
|
||||
bounded={false}
|
||||
initialPosition={initialBrowserPosition}
|
||||
initialWidth={initialWidth}
|
||||
initialHeight={initialHeight}
|
||||
showMaximizeButton={true}
|
||||
showMinimizeButton={true}
|
||||
showPowerButton={blockLabel === undefined && showPowerButton}
|
||||
showReloadButton={true}
|
||||
// --
|
||||
onCycle={handleOnCycle}
|
||||
>
|
||||
{activeDebugSession &&
|
||||
activeDebugSession.browser_session_id &&
|
||||
!cycleBrowser.isPending ? (
|
||||
<BrowserStream
|
||||
interactive={interactor === "human"}
|
||||
browserSessionId={activeDebugSession.browser_session_id}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 pb-2 pt-4 text-sm text-slate-400">
|
||||
Connecting to your browser...
|
||||
<AnimatedWave text=".‧₊˚ ⋅ ✨★ ‧₊˚ ⋅" />
|
||||
</div>
|
||||
)}
|
||||
</FloatingWindow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import { useEffect } from "react";
|
||||
import { useMountEffect } from "@/hooks/useMountEffect";
|
||||
import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery";
|
||||
import { useBlockScriptStore } from "@/store/BlockScriptStore";
|
||||
import { useSidebarStore } from "@/store/SidebarStore";
|
||||
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
|
||||
import { FlowRenderer } from "./FlowRenderer";
|
||||
import { getElements } from "./workflowEditorUtils";
|
||||
import { LogoMinimized } from "@/components/LogoMinimized";
|
||||
import { WorkflowSettings } from "../types/workflowTypes";
|
||||
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
|
||||
import { getInitialParameters } from "./utils";
|
||||
import { Workspace } from "./Workspace";
|
||||
|
||||
function WorkflowEditor() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const setCollapsed = useSidebarStore((state) => {
|
||||
return state.setCollapsed;
|
||||
});
|
||||
const workflowChangesStore = useWorkflowHasChangesStore();
|
||||
const blockScriptStore = useBlockScriptStore();
|
||||
|
||||
const { data: workflow, isLoading } = useWorkflowQuery({
|
||||
workflowPermanentId,
|
||||
@@ -29,20 +18,6 @@ function WorkflowEditor() {
|
||||
const { data: globalWorkflows, isLoading: isGlobalWorkflowsLoading } =
|
||||
useGlobalWorkflowsQuery();
|
||||
|
||||
const { data: blockScripts } = useBlockScriptsQuery({
|
||||
workflowPermanentId,
|
||||
});
|
||||
|
||||
useMountEffect(() => {
|
||||
setCollapsed(true);
|
||||
workflowChangesStore.setHasChanges(false);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
blockScriptStore.setScripts(blockScripts ?? {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blockScripts]);
|
||||
|
||||
if (isLoading || isGlobalWorkflowsLoading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
@@ -82,11 +57,12 @@ function WorkflowEditor() {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowRenderer
|
||||
<Workspace
|
||||
initialEdges={elements.edges}
|
||||
initialNodes={elements.nodes}
|
||||
initialParameters={getInitialParameters(workflow)}
|
||||
initialTitle={workflow.title}
|
||||
showBrowser={false}
|
||||
workflow={workflow}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
|
||||
@@ -29,6 +29,7 @@ type Props = {
|
||||
parametersPanelOpen: boolean;
|
||||
onParametersClick: () => void;
|
||||
onSave: () => void;
|
||||
onRun?: () => void;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
@@ -37,6 +38,7 @@ function WorkflowHeader({
|
||||
parametersPanelOpen,
|
||||
onParametersClick,
|
||||
onSave,
|
||||
onRun,
|
||||
saving,
|
||||
}: Props) {
|
||||
const { title, setTitle } = useWorkflowTitleStore();
|
||||
@@ -148,6 +150,7 @@ function WorkflowHeader({
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() => {
|
||||
onRun?.();
|
||||
navigate(`/workflows/${workflowPermanentId}/run`);
|
||||
}}
|
||||
>
|
||||
|
||||
543
skyvern-frontend/src/routes/workflows/editor/Workspace.tsx
Normal file
543
skyvern-frontend/src/routes/workflows/editor/Workspace.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useEdgesState, useNodesState, Edge } from "@xyflow/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { DebugSessionApiResponse } from "@/api/types";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useMountEffect } from "@/hooks/useMountEffect";
|
||||
import { useRanker } from "../hooks/useRanker";
|
||||
import { useDebugSessionQuery } from "../hooks/useDebugSessionQuery";
|
||||
import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery";
|
||||
import { useBlockScriptStore } from "@/store/BlockScriptStore";
|
||||
import { useSidebarStore } from "@/store/SidebarStore";
|
||||
|
||||
import { AnimatedWave } from "@/components/AnimatedWave";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { BrowserStream } from "@/components/BrowserStream";
|
||||
import { FloatingWindow } from "@/components/FloatingWindow";
|
||||
import { statusIsFinalized } from "@/routes/tasks/types.ts";
|
||||
import { WorkflowDebuggerRun } from "@/routes/workflows/editor/WorkflowDebuggerRun";
|
||||
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
|
||||
import { useDebugStore } from "@/store/useDebugStore";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import {
|
||||
useWorkflowHasChangesStore,
|
||||
useWorkflowSave,
|
||||
} from "@/store/WorkflowHasChangesStore";
|
||||
|
||||
import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer";
|
||||
import { AppNode, isWorkflowBlockNode, WorkflowBlockNode } from "./nodes";
|
||||
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
|
||||
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
||||
import { ParametersState } from "./types";
|
||||
import { getWorkflowErrors } from "./workflowEditorUtils";
|
||||
import { WorkflowHeader } from "./WorkflowHeader";
|
||||
import {
|
||||
nodeAdderNode,
|
||||
createNode,
|
||||
defaultEdge,
|
||||
generateNodeLabel,
|
||||
layout,
|
||||
startNode,
|
||||
} from "./workflowEditorUtils";
|
||||
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
|
||||
|
||||
const Constants = {
|
||||
NewBrowserCooldown: 30000,
|
||||
} as const;
|
||||
|
||||
type Props = Pick<
|
||||
FlowRendererProps,
|
||||
"initialTitle" | "initialParameters" | "workflow"
|
||||
> & {
|
||||
initialNodes: Array<AppNode>;
|
||||
initialEdges: Array<Edge>;
|
||||
showBrowser?: boolean;
|
||||
};
|
||||
|
||||
export type AddNodeProps = {
|
||||
nodeType: NonNullable<WorkflowBlockNode["type"]>;
|
||||
previous: string | null;
|
||||
next: string | null;
|
||||
parent?: string;
|
||||
connectingEdgeType: string;
|
||||
};
|
||||
|
||||
function Workspace({
|
||||
initialNodes,
|
||||
initialEdges,
|
||||
initialTitle,
|
||||
initialParameters,
|
||||
showBrowser = false,
|
||||
workflow,
|
||||
}: Props) {
|
||||
const { blockLabel, workflowPermanentId } = useParams();
|
||||
const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } =
|
||||
useWorkflowPanelStore();
|
||||
const [parameters, setParameters] =
|
||||
useState<ParametersState>(initialParameters);
|
||||
const debugStore = useDebugStore();
|
||||
const [debuggableBlockCount, setDebuggableBlockCount] = useState(0);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const saveWorkflow = useWorkflowSave();
|
||||
|
||||
const { data: workflowRun } = useWorkflowRunQuery();
|
||||
const isFinalized = workflowRun ? statusIsFinalized(workflowRun) : null;
|
||||
const interactor = workflowRun && isFinalized === false ? "agent" : "human";
|
||||
const browserTitle = interactor === "agent" ? `Browser [🤖]` : `Browser [👤]`;
|
||||
|
||||
const [openDialogue, setOpenDialogue] = useState(false);
|
||||
const [activeDebugSession, setActiveDebugSession] =
|
||||
useState<DebugSessionApiResponse | null>(null);
|
||||
const [showPowerButton, setShowPowerButton] = useState(true);
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
const [shouldFetchDebugSession, setShouldFetchDebugSession] = useState(false);
|
||||
const blockScriptStore = useBlockScriptStore();
|
||||
const { rankedItems, promote } = useRanker([
|
||||
"browserWindow",
|
||||
"header",
|
||||
"dropdown",
|
||||
"history",
|
||||
"infiniteCanvas",
|
||||
]);
|
||||
|
||||
// ---start fya: https://github.com/frontyardart
|
||||
const initialBrowserPosition = {
|
||||
x: 600,
|
||||
y: 132,
|
||||
};
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
const rightPadding = 567;
|
||||
const initialWidth = Math.max(
|
||||
512,
|
||||
windowWidth - initialBrowserPosition.x - rightPadding,
|
||||
);
|
||||
const initialHeight = (initialWidth / 16) * 9;
|
||||
// ---end fya
|
||||
|
||||
const { data: blockScripts } = useBlockScriptsQuery({
|
||||
workflowPermanentId,
|
||||
});
|
||||
|
||||
const { data: debugSession } = useDebugSessionQuery({
|
||||
workflowPermanentId,
|
||||
enabled: shouldFetchDebugSession && !!workflowPermanentId,
|
||||
});
|
||||
|
||||
const setCollapsed = useSidebarStore((state) => {
|
||||
return state.setCollapsed;
|
||||
});
|
||||
|
||||
const workflowChangesStore = useWorkflowHasChangesStore();
|
||||
|
||||
const handleOnCycle = () => {
|
||||
setOpenDialogue(true);
|
||||
};
|
||||
|
||||
useMountEffect(() => {
|
||||
setCollapsed(true);
|
||||
workflowChangesStore.setHasChanges(false);
|
||||
|
||||
if (workflowPermanentId) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["debugSession", workflowPermanentId],
|
||||
});
|
||||
setShouldFetchDebugSession(true);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
blockScriptStore.setScripts(blockScripts ?? {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [blockScripts]);
|
||||
|
||||
const afterCycleBrowser = () => {
|
||||
setOpenDialogue(false);
|
||||
setShowPowerButton(false);
|
||||
|
||||
if (powerButtonTimeoutRef.current) {
|
||||
clearTimeout(powerButtonTimeoutRef.current);
|
||||
}
|
||||
|
||||
powerButtonTimeoutRef.current = setTimeout(() => {
|
||||
setShowPowerButton(true);
|
||||
}, Constants.NewBrowserCooldown);
|
||||
};
|
||||
|
||||
const cycleBrowser = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
return client.post<DebugSessionApiResponse>(`/debug-session/${id}/new`);
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
const newDebugSession = response.data;
|
||||
setActiveDebugSession(newDebugSession);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["debugSession", workflowPermanentId],
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "Browser cycled",
|
||||
variant: "success",
|
||||
description: "Your browser has been cycled.",
|
||||
});
|
||||
|
||||
afterCycleBrowser();
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to cycle browser",
|
||||
description: error.message,
|
||||
});
|
||||
|
||||
afterCycleBrowser();
|
||||
},
|
||||
});
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const powerButtonTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(!debugSession || !debugSession.browser_session_id) &&
|
||||
shouldFetchDebugSession &&
|
||||
workflowPermanentId
|
||||
) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["debugSession", workflowPermanentId],
|
||||
});
|
||||
}, 2000);
|
||||
} else {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
if (debugSession) {
|
||||
setActiveDebugSession(debugSession);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [debugSession, shouldFetchDebugSession, workflowPermanentId, queryClient]);
|
||||
|
||||
function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) {
|
||||
const layoutedElements = layout(nodes, edges);
|
||||
setNodes(layoutedElements.nodes);
|
||||
setEdges(layoutedElements.edges);
|
||||
}
|
||||
|
||||
function addNode({
|
||||
nodeType,
|
||||
previous,
|
||||
next,
|
||||
parent,
|
||||
connectingEdgeType,
|
||||
}: AddNodeProps) {
|
||||
const newNodes: Array<AppNode> = [];
|
||||
const newEdges: Array<Edge> = [];
|
||||
const id = nanoid();
|
||||
const existingLabels = nodes
|
||||
.filter(isWorkflowBlockNode)
|
||||
.map((node) => node.data.label);
|
||||
const node = createNode(
|
||||
{ id, parentId: parent },
|
||||
nodeType,
|
||||
generateNodeLabel(existingLabels),
|
||||
);
|
||||
newNodes.push(node);
|
||||
if (previous) {
|
||||
const newEdge = {
|
||||
id: nanoid(),
|
||||
type: "edgeWithAddButton",
|
||||
source: previous,
|
||||
target: id,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
};
|
||||
newEdges.push(newEdge);
|
||||
}
|
||||
if (next) {
|
||||
const newEdge = {
|
||||
id: nanoid(),
|
||||
type: connectingEdgeType,
|
||||
source: id,
|
||||
target: next,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
};
|
||||
newEdges.push(newEdge);
|
||||
}
|
||||
|
||||
if (nodeType === "loop") {
|
||||
// when loop node is first created it needs an adder node so nodes can be added inside the loop
|
||||
const startNodeId = nanoid();
|
||||
const adderNodeId = nanoid();
|
||||
newNodes.push(
|
||||
startNode(
|
||||
startNodeId,
|
||||
{
|
||||
withWorkflowSettings: false,
|
||||
editable: true,
|
||||
},
|
||||
id,
|
||||
),
|
||||
);
|
||||
newNodes.push(nodeAdderNode(adderNodeId, id));
|
||||
newEdges.push(defaultEdge(startNodeId, adderNodeId));
|
||||
}
|
||||
|
||||
const editedEdges = previous
|
||||
? edges.filter((edge) => edge.source !== previous)
|
||||
: edges;
|
||||
|
||||
const previousNode = nodes.find((node) => node.id === previous);
|
||||
const previousNodeIndex = previousNode
|
||||
? nodes.indexOf(previousNode)
|
||||
: nodes.length - 1;
|
||||
|
||||
// creating some memory for no reason, maybe check it out later
|
||||
const newNodesAfter = [
|
||||
...nodes.slice(0, previousNodeIndex + 1),
|
||||
...newNodes,
|
||||
...nodes.slice(previousNodeIndex + 1),
|
||||
];
|
||||
|
||||
workflowChangesStore.setHasChanges(true);
|
||||
doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkflowParametersStateContext.Provider
|
||||
value={[parameters, setParameters]}
|
||||
>
|
||||
<div className="relative h-full w-full">
|
||||
<Dialog
|
||||
open={openDialogue}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && cycleBrowser.isPending) {
|
||||
return;
|
||||
}
|
||||
setOpenDialogue(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cycle (Get a new browser)</DialogTitle>
|
||||
<DialogDescription>
|
||||
<div className="pb-2 pt-4 text-sm text-slate-400">
|
||||
{cycleBrowser.isPending ? (
|
||||
<>
|
||||
Cooking you up a fresh browser...
|
||||
<AnimatedWave text=".‧₊˚ ⋅ ✨★ ‧₊˚ ⋅" />
|
||||
</>
|
||||
) : (
|
||||
"Abandon this browser for a new one. Are you sure?"
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
{!cycleBrowser.isPending && (
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
cycleBrowser.mutate(workflowPermanentId!);
|
||||
}}
|
||||
disabled={cycleBrowser.isPending}
|
||||
>
|
||||
Yes, Continue{" "}
|
||||
{cycleBrowser.isPending && (
|
||||
<ReloadIcon className="ml-2 size-4 animate-spin" />
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* header panel */}
|
||||
<div
|
||||
className="absolute left-6 right-6 top-8 h-20"
|
||||
style={{ zIndex: rankedItems.header ?? 3 }}
|
||||
onMouseDownCapture={() => {
|
||||
promote("header");
|
||||
}}
|
||||
>
|
||||
<WorkflowHeader
|
||||
debuggableBlockCount={debuggableBlockCount}
|
||||
saving={workflowChangesStore.saveIsPending}
|
||||
parametersPanelOpen={
|
||||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "parameters"
|
||||
}
|
||||
onParametersClick={() => {
|
||||
if (
|
||||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "parameters"
|
||||
) {
|
||||
closeWorkflowPanel();
|
||||
promote("header");
|
||||
} else {
|
||||
setWorkflowPanelState({
|
||||
active: true,
|
||||
content: "parameters",
|
||||
});
|
||||
promote("dropdown");
|
||||
}
|
||||
}}
|
||||
onSave={async () => {
|
||||
const errors = getWorkflowErrors(nodes);
|
||||
if (errors.length > 0) {
|
||||
toast({
|
||||
title: "Can not save workflow because of errors:",
|
||||
description: (
|
||||
<div className="space-y-2">
|
||||
{errors.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await saveWorkflow.mutateAsync();
|
||||
}}
|
||||
onRun={() => {
|
||||
closeWorkflowPanel();
|
||||
promote("header");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* sub panels */}
|
||||
{workflowPanelState.active && (
|
||||
<div
|
||||
className="absolute right-6 top-[7.75rem]"
|
||||
style={{ zIndex: rankedItems.dropdown ?? 2 }}
|
||||
onMouseDownCapture={() => {
|
||||
promote("dropdown");
|
||||
}}
|
||||
>
|
||||
{workflowPanelState.content === "parameters" && (
|
||||
<WorkflowParametersPanel
|
||||
onMouseDownCapture={() => {
|
||||
promote("dropdown");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{workflowPanelState.content === "nodeLibrary" && (
|
||||
<WorkflowNodeLibraryPanel
|
||||
onMouseDownCapture={() => {
|
||||
promote("dropdown");
|
||||
}}
|
||||
onNodeClick={(props) => {
|
||||
addNode(props);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{debugStore.isDebugMode && (
|
||||
<div
|
||||
className="absolute right-6 top-[8.5rem] h-[calc(100vh-9.5rem)]"
|
||||
style={{ zIndex: rankedItems.history ?? 1 }}
|
||||
onMouseDownCapture={() => {
|
||||
closeWorkflowPanel();
|
||||
promote("history");
|
||||
}}
|
||||
>
|
||||
<div className="pointer-events-none absolute right-0 top-0 flex h-full w-[400px] flex-col items-end justify-end">
|
||||
<div className="pointer-events-auto relative h-full w-full overflow-hidden rounded-xl border-2 border-slate-500">
|
||||
<WorkflowDebuggerRun />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* infinite canvas */}
|
||||
<FlowRenderer
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
setNodes={setNodes}
|
||||
setEdges={setEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
initialTitle={initialTitle}
|
||||
initialParameters={initialParameters}
|
||||
workflow={workflow}
|
||||
onDebuggableBlockCountChange={(c) => setDebuggableBlockCount(c)}
|
||||
onMouseDownCapture={() => promote("infiniteCanvas")}
|
||||
zIndex={rankedItems.infiniteCanvas}
|
||||
/>
|
||||
|
||||
{/* browser */}
|
||||
{showBrowser && (
|
||||
<FloatingWindow
|
||||
title={browserTitle}
|
||||
bounded={false}
|
||||
initialPosition={initialBrowserPosition}
|
||||
initialWidth={initialWidth}
|
||||
initialHeight={initialHeight}
|
||||
showMaximizeButton={true}
|
||||
showMinimizeButton={true}
|
||||
showPowerButton={blockLabel === undefined && showPowerButton}
|
||||
showReloadButton={true}
|
||||
zIndex={rankedItems.browserWindow ?? 4}
|
||||
// --
|
||||
onCycle={handleOnCycle}
|
||||
onFocus={() => promote("browserWindow")}
|
||||
>
|
||||
{activeDebugSession &&
|
||||
activeDebugSession.browser_session_id &&
|
||||
!cycleBrowser.isPending ? (
|
||||
<BrowserStream
|
||||
interactive={interactor === "human"}
|
||||
browserSessionId={activeDebugSession.browser_session_id}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 pb-2 pt-4 text-sm text-slate-400">
|
||||
Connecting to your browser...
|
||||
<AnimatedWave text=".‧₊˚ ⋅ ✨★ ‧₊˚ ⋅" />
|
||||
</div>
|
||||
)}
|
||||
</FloatingWindow>
|
||||
)}
|
||||
</div>
|
||||
</WorkflowParametersStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { Workspace };
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/routes/workflows/types/workflowTypes";
|
||||
import { getInitialValues } from "@/routes/workflows/utils";
|
||||
import { useDebugStore } from "@/store/useDebugStore";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import { useWorkflowSave } from "@/store/WorkflowHasChangesStore";
|
||||
import {
|
||||
useWorkflowSettingsStore,
|
||||
@@ -135,6 +136,7 @@ function NodeHeader({
|
||||
workflowRunId,
|
||||
} = useParams();
|
||||
const debugStore = useDebugStore();
|
||||
const { closeWorkflowPanel } = useWorkflowPanelStore();
|
||||
const thisBlockIsPlaying =
|
||||
urlBlockLabel !== undefined && urlBlockLabel === blockLabel;
|
||||
const anyBlockIsPlaying =
|
||||
@@ -194,6 +196,8 @@ function NodeHeader({
|
||||
|
||||
const runBlock = useMutation({
|
||||
mutationFn: async () => {
|
||||
closeWorkflowPanel();
|
||||
|
||||
await saveWorkflow.mutateAsync();
|
||||
|
||||
if (!workflowPermanentId) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
MagnifyingGlassIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { WorkflowBlockTypes } from "../../types/workflowTypes";
|
||||
import { AddNodeProps } from "../FlowRenderer";
|
||||
import { WorkflowBlockNode } from "../nodes";
|
||||
import { WorkflowBlockIcon } from "../nodes/WorkflowBlockIcon";
|
||||
import { AddNodeProps } from "../Workspace";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const enableCodeBlock =
|
||||
@@ -243,11 +243,16 @@ const nodeLibraryItems: Array<{
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onMouseDownCapture?: () => void;
|
||||
onNodeClick: (props: AddNodeProps) => void;
|
||||
first?: boolean;
|
||||
};
|
||||
|
||||
function WorkflowNodeLibraryPanel({ onNodeClick, first }: Props) {
|
||||
function WorkflowNodeLibraryPanel({
|
||||
onMouseDownCapture,
|
||||
onNodeClick,
|
||||
first,
|
||||
}: Props) {
|
||||
const workflowPanelData = useWorkflowPanelStore(
|
||||
(state) => state.workflowPanelState.data,
|
||||
);
|
||||
@@ -311,7 +316,10 @@ function WorkflowNodeLibraryPanel({ onNodeClick, first }: Props) {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
|
||||
<div
|
||||
className="w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl"
|
||||
onMouseDownCapture={() => onMouseDownCapture?.()}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -36,7 +36,11 @@ import { getLabelForWorkflowParameterType } from "../workflowEditorUtils";
|
||||
const WORKFLOW_EDIT_PANEL_WIDTH = 20 * 16;
|
||||
const WORKFLOW_EDIT_PANEL_GAP = 1 * 16;
|
||||
|
||||
function WorkflowParametersPanel() {
|
||||
interface Props {
|
||||
onMouseDownCapture?: () => void;
|
||||
}
|
||||
|
||||
function WorkflowParametersPanel({ onMouseDownCapture }: Props) {
|
||||
const setHasChanges = useWorkflowHasChangesStore(
|
||||
(state) => state.setHasChanges,
|
||||
);
|
||||
@@ -56,7 +60,10 @@ function WorkflowParametersPanel() {
|
||||
const { setNodes } = useReactFlow();
|
||||
|
||||
return (
|
||||
<div className="relative w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
|
||||
<div
|
||||
className="relative z-10 w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl"
|
||||
onMouseDownCapture={() => onMouseDownCapture?.()}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<header>
|
||||
<h1 className="text-lg">Parameters</h1>
|
||||
|
||||
46
skyvern-frontend/src/routes/workflows/hooks/useRanker.ts
Normal file
46
skyvern-frontend/src/routes/workflows/hooks/useRanker.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
|
||||
interface RankedItems {
|
||||
[key: string]: number;
|
||||
}
|
||||
|
||||
interface UseRankerReturn {
|
||||
rankedItems: RankedItems;
|
||||
promote: (name: string) => void;
|
||||
orderedNames: string[];
|
||||
}
|
||||
|
||||
function useRanker(initialNames: string[]): UseRankerReturn {
|
||||
const [orderedNames, setOrderedNames] = useState<string[]>(initialNames);
|
||||
|
||||
const rankedItems = useMemo<RankedItems>(() => {
|
||||
const items: RankedItems = {};
|
||||
const maxRank = orderedNames.length;
|
||||
|
||||
orderedNames.forEach((name, index) => {
|
||||
items[name] = maxRank - index;
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [orderedNames]);
|
||||
|
||||
const promote = useCallback((name: string) => {
|
||||
setOrderedNames((prevNames) => {
|
||||
if (!prevNames.includes(name)) {
|
||||
console.warn(`Name "${name}" not found in ranked list`);
|
||||
return prevNames;
|
||||
}
|
||||
|
||||
const filteredNames = prevNames.filter((n) => n !== name);
|
||||
return [name, ...filteredNames];
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
rankedItems,
|
||||
promote,
|
||||
orderedNames,
|
||||
};
|
||||
}
|
||||
|
||||
export { useRanker, type RankedItems };
|
||||
Reference in New Issue
Block a user