fix: disable duplicate trigger nodes in workflow builder (#402)

* fix: disable duplicate trigger nodes in workflow builder

AddNodePanel: disable trigger buttons and show tooltip when a trigger
already exists on the canvas, using bySpecName to identify trigger-
category specs from the live node list.
useWorkflowState: preflight in saveWorkflow rejects saves with multiple
trigger nodes via a sonner toast before the network request is made.
text_chat_session_service: include the original exception message in
TextChatSessionExecutionError so the HTTP 500 detail surfaces the root
cause without DB inspection.

Closes #378

* style: format test_text_chat_session_service.py with ruff

* chore: retrigger CI checks

* fix(workflow): enforce node instance constraints

---------

Co-authored-by: Abhishek Kumar <abhishek@a6k.me>
This commit is contained in:
nuthalapativarun 2026-06-19 03:29:30 -07:00 committed by GitHub
parent 7c31dd3eec
commit 7d053320df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 591 additions and 91 deletions

View file

@ -6,12 +6,13 @@ import type { NodeSpec } from '@/client/types.gen';
import { useNodeSpecs } from '@/components/flow/renderer';
import { Button } from '@/components/ui/button';
import { NodeType } from './types';
import { FlowNode, NodeType } from './types';
type AddNodePanelProps = {
isOpen: boolean;
onClose: () => void;
onNodeSelect: (nodeType: NodeType) => void;
nodes: FlowNode[];
};
// Section ordering and labels. Drives both the category → section title
@ -32,10 +33,12 @@ function NodeSection({
title,
specs,
onNodeSelect,
nodeTypeCounts,
}: {
title: string;
specs: NodeSpec[];
onNodeSelect: (nodeType: NodeType) => void;
nodeTypeCounts: Map<string, number>;
}) {
if (specs.length === 0) return null;
return (
@ -46,12 +49,23 @@ function NodeSection({
<div className="space-y-2">
{specs.map((spec) => {
const Icon = resolveIcon(spec.icon);
const maxInstances = spec.graph_constraints?.max_instances;
const disabled =
maxInstances !== undefined &&
maxInstances !== null &&
(nodeTypeCounts.get(spec.name) ?? 0) >= maxInstances;
return (
<Button
key={spec.name}
variant="outline"
className="w-full justify-start p-4 h-auto hover:bg-accent/50 transition-colors"
onClick={() => onNodeSelect(spec.name as NodeType)}
disabled={disabled}
title={
disabled
? `${spec.display_name} limit reached for this workflow`
: undefined
}
>
<div className="flex items-center">
<div className="bg-muted p-2 rounded-lg mr-3 border border-border">
@ -74,7 +88,7 @@ function NodeSection({
);
}
export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodePanelProps) {
export default function AddNodePanel({ isOpen, onNodeSelect, onClose, nodes }: AddNodePanelProps) {
const { specs } = useNodeSpecs();
// Group registered specs by category, preserving the SECTION_ORDER.
@ -86,6 +100,14 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
}));
}, [specs]);
const nodeTypeCounts = useMemo(() => {
const counts = new Map<string, number>();
nodes.forEach((node) => {
counts.set(node.type, (counts.get(node.type) ?? 0) + 1);
});
return counts;
}, [nodes]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && isOpen) {
@ -128,6 +150,7 @@ export default function AddNodePanel({ isOpen, onNodeSelect, onClose }: AddNodeP
title={title}
specs={specs}
onNodeSelect={onNodeSelect}
nodeTypeCounts={nodeTypeCounts}
/>
))}
</div>