Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started.
- **AI Gateway**: Generate Text, Generate Image
- **Firecrawl**: Scrape URL, Search Web
- **Linear**: Create Ticket, Find Issues
- **Native**: HTTP Request
- **Resend**: Send Email
- **Slack**: Send Slack Message
- **Superagent**: Guard, Redact
Expand Down
6 changes: 6 additions & 0 deletions app/workflows/[workflowId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { NodeConfigPanel } from "@/components/workflow/node-config-panel";
import { useIsMobile } from "@/hooks/use-mobile";
import { api } from "@/lib/api-client";
import { fetchIntegrationsAtom } from "@/lib/integrations-store";
import {
currentWorkflowIdAtom,
currentWorkflowNameAtom,
Expand Down Expand Up @@ -55,6 +56,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => {
const setTriggerExecute = useSetAtom(triggerExecuteAtom);
const setRightPanelWidth = useSetAtom(rightPanelWidthAtom);
const setIsPanelAnimating = useSetAtom(isPanelAnimatingAtom);
const fetchIntegrations = useSetAtom(fetchIntegrationsAtom);
const [hasSidebarBeenShown, setHasSidebarBeenShown] = useAtom(
hasSidebarBeenShownAtom
);
Expand Down Expand Up @@ -315,6 +317,9 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => {
const storedPrompt = sessionStorage.getItem("ai-prompt");
const storedWorkflowId = sessionStorage.getItem("generating-workflow-id");

// Prefetch integrations in parallel with workflow loading
fetchIntegrations();

// Check if state is already loaded for this workflow
if (currentWorkflowId === workflowId && nodes.length > 0) {
return;
Expand Down Expand Up @@ -342,6 +347,7 @@ const WorkflowEditor = ({ params }: WorkflowPageProps) => {
nodes.length,
generateWorkflowFromAI,
loadExistingWorkflow,
fetchIntegrations,
]);

// Keyboard shortcuts
Expand Down
8 changes: 6 additions & 2 deletions components/settings/integration-form-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@ const SYSTEM_INTEGRATION_LABELS: Record<string, string> = {
database: "Database",
};

// Get all integration types (plugins + system)
// Get all integration types (plugins that require integration + system)
// Excludes plugins with requiresIntegration: false (like Native)
const getIntegrationTypes = (): IntegrationType[] => [
...getSortedIntegrationTypes(),
...getSortedIntegrationTypes().filter((type) => {
const plugin = getIntegration(type);
return plugin?.requiresIntegration !== false;
}),
...SYSTEM_INTEGRATION_TYPES,
];

Expand Down
55 changes: 30 additions & 25 deletions components/ui/integration-selector.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Select,
SelectContent,
Expand All @@ -10,7 +11,12 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { api, type Integration } from "@/lib/api-client";
import {
fetchIntegrationsAtom,
integrationsAtom,
integrationsFetchedAtom,
integrationsLoadingAtom,
} from "@/lib/integrations-store";
import type { IntegrationType } from "@/lib/types/integration";
import { IntegrationFormDialog } from "@/components/settings/integration-form-dialog";

Expand All @@ -31,32 +37,31 @@ export function IntegrationSelector({
label,
disabled,
}: IntegrationSelectorProps) {
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [loading, setLoading] = useState(true);
const allIntegrations = useAtomValue(integrationsAtom);
const loading = useAtomValue(integrationsLoadingAtom);
const fetched = useAtomValue(integrationsFetchedAtom);
const fetchIntegrations = useSetAtom(fetchIntegrationsAtom);
const [showNewDialog, setShowNewDialog] = useState(false);

const loadIntegrations = async () => {
try {
setLoading(true);
const all = await api.integration.getAll();
const filtered = all.filter((i) => i.type === integrationType);
setIntegrations(filtered);

// Auto-select if only one option and nothing selected yet
if (filtered.length === 1 && !value) {
onChange(filtered[0].id);
}
} catch (error) {
console.error("Failed to load integrations:", error);
} finally {
setLoading(false);
// Filter integrations by type
const integrations = useMemo(
() => allIntegrations.filter((i) => i.type === integrationType),
[allIntegrations, integrationType]
);

// Fetch integrations on mount if not already fetched
useEffect(() => {
if (!fetched && !loading) {
fetchIntegrations();
}
};
}, [fetched, loading, fetchIntegrations]);

// Auto-select if only one option and nothing selected yet
useEffect(() => {
loadIntegrations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [integrationType]);
if (integrations.length === 1 && !value && fetched) {
onChange(integrations[0].id);
}
}, [integrations, value, fetched, onChange]);

const handleValueChange = (newValue: string) => {
if (newValue === "__new__") {
Expand All @@ -69,12 +74,12 @@ export function IntegrationSelector({
};

const handleNewIntegrationCreated = async (integrationId: string) => {
await loadIntegrations();
await fetchIntegrations();
onChange(integrationId);
setShowNewDialog(false);
};

if (loading) {
if (loading || !fetched) {
return (
<Select disabled value="">
<SelectTrigger className="flex-1">
Expand Down
96 changes: 23 additions & 73 deletions components/ui/template-autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { edgesAtom, nodesAtom, type WorkflowNode } from "@/lib/workflow-store";
import { findActionById } from "@/plugins";

type TemplateAutocompleteProps = {
isOpen: boolean;
Expand All @@ -32,6 +33,13 @@ const getNodeDisplayName = (node: WorkflowNode): string => {

if (node.data.type === "action") {
const actionType = node.data.config?.actionType as string | undefined;
if (actionType) {
// Look up human-readable label from plugin registry
const action = findActionById(actionType);
if (action?.label) {
return action.label;
}
}
return actionType || "HTTP Request";
}

Expand Down Expand Up @@ -103,35 +111,16 @@ const isActionType = (
const getCommonFields = (node: WorkflowNode) => {
const actionType = node.data.config?.actionType as string | undefined;

if (isActionType(actionType, "Find Issues", "linear/find-issues")) {
return [
{ field: "issues", description: "Array of issues found" },
{ field: "count", description: "Number of issues" },
];
}
if (isActionType(actionType, "Send Email", "resend/send-email")) {
return [
{ field: "id", description: "Email ID" },
{ field: "status", description: "Send status" },
];
}
if (isActionType(actionType, "Create Ticket", "linear/create-ticket")) {
return [
{ field: "id", description: "Ticket ID" },
{ field: "url", description: "Ticket URL" },
{ field: "number", description: "Ticket number" },
];
}
// Special handling for dynamic outputs (system actions and schema-based)
if (actionType === "HTTP Request") {
return [
{ field: "data", description: "Response data" },
{ field: "status", description: "HTTP status code" },
];
}

if (actionType === "Database Query") {
const dbSchema = node.data.config?.dbSchema as string | undefined;

// If schema is defined, show schema fields
if (dbSchema) {
try {
const schema = JSON.parse(dbSchema) as SchemaField[];
Expand All @@ -142,81 +131,43 @@ const getCommonFields = (node: WorkflowNode) => {
// If schema parsing fails, fall through to default fields
}
}

// Default fields when no schema
return [
{ field: "rows", description: "Query result rows" },
{ field: "count", description: "Number of rows" },
];
}

// AI Gateway generate-text has dynamic output based on format/schema
if (isActionType(actionType, "Generate Text", "ai-gateway/generate-text")) {
const aiFormat = node.data.config?.aiFormat as string | undefined;
const aiSchema = node.data.config?.aiSchema as string | undefined;

// If format is object and schema is defined, show schema fields
if (aiFormat === "object" && aiSchema) {
try {
const schema = JSON.parse(aiSchema) as SchemaField[];
if (schema.length > 0) {
return schemaToFields(schema);
return schemaToFields(schema, "object");
}
} catch {
// If schema parsing fails, fall through to default fields
}
}

// Default fields for text format or when no schema
return [
{ field: "text", description: "Generated text" },
{ field: "model", description: "Model used" },
];
return [{ field: "text", description: "Generated text" }];
}
if (isActionType(actionType, "Generate Image", "ai-gateway/generate-image")) {
return [
{ field: "base64", description: "Base64 image data" },
{ field: "model", description: "Model used" },
];
}
if (
isActionType(actionType, "Scrape", "Scrape URL", "firecrawl/scrape")
) {
return [
{ field: "markdown", description: "Scraped content as markdown" },
{ field: "metadata.url", description: "Page URL" },
{ field: "metadata.title", description: "Page title" },
{ field: "metadata.description", description: "Page description" },
{ field: "metadata.language", description: "Page language" },
{ field: "metadata.favicon", description: "Favicon URL" },
];
}
if (isActionType(actionType, "Search", "Search Web", "firecrawl/search")) {
return [{ field: "web", description: "Array of search results" }];
}
if (isActionType(actionType, "Create Chat", "v0/create-chat")) {
return [
{ field: "chatId", description: "v0 chat ID" },
{ field: "url", description: "v0 chat URL" },
{ field: "demoUrl", description: "Demo preview URL" },
];
}
if (isActionType(actionType, "Send Message", "v0/send-message")) {
return [
{ field: "chatId", description: "v0 chat ID" },
{ field: "demoUrl", description: "Demo preview URL" },
];
}
if (isActionType(actionType, "Send Slack Message", "slack/send-message")) {
return [
{ field: "ok", description: "Success status" },
{ field: "ts", description: "Message timestamp" },
{ field: "channel", description: "Channel ID" },
];

// Check if the plugin defines output fields
if (actionType) {
const action = findActionById(actionType);
if (action?.outputFields && action.outputFields.length > 0) {
return action.outputFields;
}
}

// Trigger fields
if (node.data.type === "trigger") {
const triggerType = node.data.config?.triggerType as string | undefined;
const webhookSchema = node.data.config?.webhookSchema as string | undefined;

// If it's a webhook trigger with a schema, show schema fields
if (triggerType === "Webhook" && webhookSchema) {
try {
const schema = JSON.parse(webhookSchema) as SchemaField[];
Expand All @@ -228,7 +179,6 @@ const getCommonFields = (node: WorkflowNode) => {
}
}

// Default trigger fields
return [
{ field: "triggered", description: "Trigger status" },
{ field: "timestamp", description: "Trigger timestamp" },
Expand Down
19 changes: 16 additions & 3 deletions components/ui/template-badge-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface TemplateBadgeInputProps {
disabled?: boolean;
className?: string;
id?: string;
/** Optional non-editable prefix to display before the input */
prefix?: string;
}

// Helper to check if a template references an existing node
Expand Down Expand Up @@ -71,6 +73,7 @@ export function TemplateBadgeInput({
disabled,
className,
id,
prefix,
}: TemplateBadgeInputProps) {
const [isFocused, setIsFocused] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -487,12 +490,17 @@ export function TemplateBadgeInput({
document.execCommand("insertText", false, text);
};

// Trigger display update when placeholder changes (e.g., when integration prefix changes)
useEffect(() => {
shouldUpdateDisplay.current = true;
}, [placeholder]);

// Update display only when needed (not while typing)
useEffect(() => {
if (shouldUpdateDisplay.current) {
updateDisplay();
}
}, [internalValue, isFocused]);
}, [internalValue, isFocused, placeholder]);

return (
<>
Expand All @@ -503,8 +511,13 @@ export function TemplateBadgeInput({
className
)}
>
{prefix && (
<span className="text-muted-foreground flex-shrink-0 select-none pr-1 font-mono text-xs leading-[1.35rem]">
{prefix}
</span>
)}
<div
className="w-full outline-none"
className="w-full overflow-hidden whitespace-nowrap outline-none"
contentEditable={!disabled}
id={id}
onBlur={handleBlur}
Expand All @@ -516,7 +529,7 @@ export function TemplateBadgeInput({
suppressContentEditableWarning
/>
</div>

<TemplateAutocomplete
currentNodeId={selectedNodeId || undefined}
filter={autocompleteFilter}
Expand Down
Loading