Jon/workspace (#3175)

This commit is contained in:
Jonathan Dobson
2025-08-13 14:13:00 -04:00
committed by GitHub
parent 75b7a8ac4c
commit 97993cbede
10 changed files with 754 additions and 563 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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