Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 163 additions & 44 deletions components/workflow/node-config-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
Check,
Copy,
Eraser,
Eye,
Expand Down Expand Up @@ -49,7 +50,7 @@ import {
showDeleteDialogAtom,
updateNodeDataAtom,
} from "@/lib/workflow-store";
import { findActionById } from "@/plugins";
import { findActionById, getIntegration } from "@/plugins";
import { Panel } from "../ai-elements/panel";
import { IntegrationsDialog } from "../settings/integrations-dialog";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
Expand All @@ -71,6 +72,46 @@ const SYSTEM_ACTION_INTEGRATIONS: Record<string, IntegrationType> = {
"Database Query": "database",
};

// Helper to get a display label for a node
const getNodeDisplayLabel = (
node:
| {
data: {
label?: string;
type: string;
config?: Record<string, unknown>;
};
id: string;
}
| undefined,
fallbackId: string
): string => {
if (!node) {
return fallbackId;
}
if (node.data.label) {
return node.data.label;
}

if (node.data.type === "action" && node.data.config?.actionType) {
const actionType = node.data.config.actionType as string;
const action = findActionById(actionType);
if (action) {
const plugin = getIntegration(action.integration);
if (plugin) {
return `${plugin.label}: ${action.label}`;
}
}
return `System: ${actionType}`;
}

if (node.data.type === "trigger" && node.data.config?.triggerType) {
return `Trigger: ${node.data.config.triggerType as string}`;
}

return node.id;
};

// Multi-selection panel component
const MultiSelectionPanel = ({
selectedNodes,
Expand Down Expand Up @@ -173,6 +214,8 @@ export const PanelInner = () => {
const [showDeleteRunsAlert, setShowDeleteRunsAlert] = useState(false);
const [showIntegrationsDialog, setShowIntegrationsDialog] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [copiedNode, setCopiedNode] = useState(false);
const [copiedWorkflow, setCopiedWorkflow] = useState(false);
const [activeTab, setActiveTab] = useAtom(propertiesPanelActiveTabAtom);
const refreshRunsRef = useRef<(() => Promise<void>) | null>(null);
const autoSelectAbortControllersRef = useRef<Record<string, AbortController>>(
Expand Down Expand Up @@ -284,15 +327,28 @@ export const PanelInner = () => {
return code;
}, [nodes, edges, currentWorkflowName]);

const handleCopyCode = () => {
const handleCopyCode = async () => {
if (selectedNode) {
navigator.clipboard.writeText(generateNodeCode(selectedNode));
try {
await navigator.clipboard.writeText(generateNodeCode(selectedNode));
setCopiedNode(true);
setTimeout(() => setCopiedNode(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
toast.error("Failed to copy code to clipboard");
}
}
};

const handleCopyWorkflowCode = () => {
navigator.clipboard.writeText(workflowCode);
toast.success("Code copied to clipboard");
const handleCopyWorkflowCode = async () => {
try {
await navigator.clipboard.writeText(workflowCode);
setCopiedWorkflow(true);
setTimeout(() => setCopiedWorkflow(false), 2000);
} catch (error) {
console.error("Failed to copy:", error);
toast.error("Failed to copy code to clipboard");
}
};

const handleDelete = () => {
Expand Down Expand Up @@ -487,51 +543,101 @@ export const PanelInner = () => {
}

// If an edge is selected, show edge properties

if (selectedEdge) {
const sourceNode = nodes.find((node) => node.id === selectedEdge.source);
const targetNode = nodes.find((node) => node.id === selectedEdge.target);
const sourceLabel = getNodeDisplayLabel(sourceNode, selectedEdge.source);
const targetLabel = getNodeDisplayLabel(targetNode, selectedEdge.target);

return (
<>
<div className="flex size-full flex-col">
<div className="flex h-14 w-full shrink-0 items-center border-b bg-transparent px-4">
<h2 className="font-semibold text-foreground">Properties</h2>
</div>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
<div className="space-y-2">
<Label className="ml-1" htmlFor="edge-id">
Edge ID
</Label>
<Input disabled id="edge-id" value={selectedEdge.id} />
<Tabs
className="size-full"
defaultValue="properties"
onValueChange={setActiveTab}
value={activeTab}
>
<TabsList className="h-14 w-full shrink-0 rounded-none border-b bg-transparent px-4 py-2.5">
<TabsTrigger
className="bg-transparent text-muted-foreground data-[state=active]:text-foreground data-[state=active]:shadow-none"
value="properties"
>
Properties
</TabsTrigger>
<TabsTrigger
className="bg-transparent text-muted-foreground data-[state=active]:text-foreground data-[state=active]:shadow-none"
value="runs"
>
Runs
</TabsTrigger>
</TabsList>
<TabsContent
className="flex flex-col overflow-hidden"
value="properties"
>
<div className="flex-1 space-y-4 overflow-y-auto p-4">
<div className="space-y-2">
<Label className="ml-1" htmlFor="edge-source">
From
</Label>
<Input disabled id="edge-source" value={sourceLabel} />
</div>
<div className="space-y-2">
<Label className="ml-1" htmlFor="edge-target">
To
</Label>
<Input disabled id="edge-target" value={targetLabel} />
</div>
<div className="space-y-2">
<Label className="ml-1" htmlFor="edge-id">
Edge ID
</Label>
<Input disabled id="edge-id" value={selectedEdge.id} />
</div>
</div>
<div className="space-y-2">
<Label className="ml-1" htmlFor="edge-source">
Source
</Label>
<Input disabled id="edge-source" value={selectedEdge.source} />
<div className="shrink-0 border-t p-4">
<Button
onClick={() => setShowDeleteEdgeAlert(true)}
size="icon"
title="Delete connection"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="space-y-2">
<Label className="ml-1" htmlFor="edge-target">
Target
</Label>
<Input disabled id="edge-target" value={selectedEdge.target} />
</TabsContent>
<TabsContent className="flex flex-col overflow-hidden" value="runs">
<div className="flex-1 space-y-4 overflow-y-auto p-4">
<WorkflowRuns
connectionNodeIds={[selectedEdge.source, selectedEdge.target]}
isActive={activeTab === "runs"}
onRefreshRef={refreshRunsRef}
/>
</div>
</div>
<div className="shrink-0 border-t p-4">
<Button
onClick={() => setShowDeleteEdgeAlert(true)}
size="icon"
variant="ghost"
>
<Trash2 className="size-4" />
</Button>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 border-t p-4">
<Button
disabled={isRefreshing}
onClick={handleRefreshRuns}
size="icon"
title="Refresh runs"
variant="ghost"
>
<RefreshCw
className={`size-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
</Button>
</div>
</TabsContent>
</Tabs>

<AlertDialog
onOpenChange={setShowDeleteEdgeAlert}
open={showDeleteEdgeAlert}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Edge</AlertDialogTitle>
<AlertDialogTitle>Delete Connection</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this connection? This action
cannot be undone.
Expand Down Expand Up @@ -699,9 +805,14 @@ export const PanelInner = () => {
<Button
onClick={handleCopyWorkflowCode}
size="icon"
title="Copy code"
variant="ghost"
>
<Copy className="size-4" />
{copiedWorkflow ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</TabsContent>
Expand Down Expand Up @@ -830,10 +941,10 @@ export const PanelInner = () => {
disabled={isGenerating || !isOwner}
id="label"
onChange={(e) => handleUpdateLabel(e.target.value)}
placeholder="e.g. Send welcome email"
value={selectedNode.data.label}
/>
</div>

<div className="space-y-2">
<Label className="ml-1" htmlFor="description">
Description
Expand All @@ -842,7 +953,7 @@ export const PanelInner = () => {
disabled={isGenerating || !isOwner}
id="description"
onChange={(e) => handleUpdateDescription(e.target.value)}
placeholder="Optional description"
placeholder="e.g. Sends a welcome email to new users"
value={selectedNode.data.description || ""}
/>
</div>
Expand Down Expand Up @@ -989,9 +1100,17 @@ export const PanelInner = () => {
/>
</div>
<div className="shrink-0 border-t p-4">
<Button onClick={handleCopyCode} size="sm" variant="ghost">
<Copy className="mr-2 size-4" />
Copy Code
<Button
onClick={handleCopyCode}
size="icon"
title="Copy code"
variant="ghost"
>
{copiedNode ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</>
Expand Down
41 changes: 41 additions & 0 deletions components/workflow/workflow-runs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ type WorkflowRunsProps = {
isActive?: boolean;
onRefreshRef?: React.MutableRefObject<(() => Promise<void>) | null>;
onStartRun?: (executionId: string) => void;
// When provided, shows a badge on runs that executed all these nodes (for connection view)
connectionNodeIds?: string[];
};

// Helper to get the output display config for a node type
Expand Down Expand Up @@ -488,6 +490,7 @@ export function WorkflowRuns({
isActive = false,
onRefreshRef,
onStartRun,
connectionNodeIds,
}: WorkflowRunsProps) {
const [currentWorkflowId] = useAtom(currentWorkflowIdAtom);
const [selectedExecutionId, setSelectedExecutionId] = useAtom(
Expand Down Expand Up @@ -598,6 +601,30 @@ export function WorkflowRuns({
[mapNodeLabels, selectedExecutionId, setExecutionLogs]
);

// Load logs for recent executions when viewing a connection (to show "Used" badge)
useEffect(() => {
if (!connectionNodeIds || connectionNodeIds.length === 0) {
return;
}

// Limit to recent executions to avoid performance issues
const recentExecutions = executions.slice(0, 10);

const loadLogsForConnection = async () => {
await Promise.all(
recentExecutions.map(async (execution) => {
// Skip if we already have logs for this execution
if (logs[execution.id]) {
return;
}
await loadExecutionLogs(execution.id);
})
);
};

loadLogsForConnection();
}, [connectionNodeIds, executions, logs, loadExecutionLogs]);

// Notify parent when a new execution starts and auto-expand it
useEffect(() => {
if (executions.length === 0) {
Expand Down Expand Up @@ -781,6 +808,15 @@ export function WorkflowRuns({
);
});

// Check if this run used the connection (both nodes were executed)
const usedConnection =
connectionNodeIds &&
connectionNodeIds.length > 0 &&
executionLogs.length > 0 &&
connectionNodeIds.every((nodeId) =>
executionLogs.some((log) => log.nodeId === nodeId)
);

return (
<div
className={cn(
Expand Down Expand Up @@ -815,6 +851,11 @@ export function WorkflowRuns({
<span className="font-semibold text-sm">
Run #{executions.length - index}
</span>
{usedConnection && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary text-xs">
Edge Active
</span>
)}
</div>
<div className="flex items-center gap-2 font-mono text-muted-foreground text-xs">
<span>{getRelativeTime(execution.startedAt)}</span>
Expand Down
Loading
Loading