/** * Per-kind config forms for the node-config drawer (issue B5b / Phase 3b). * Each form renders a small set of typed fields for a high-traffic `NodeKind`, * reading from the controlled `config` object and merging edits back via * `onChange` (a shallow-merge patch). Kinds without a dedicated form fall back * to the drawer's raw-JSON editor. * * Field keys mirror what `vendor/tinyflows` actually reads at runtime: * - `http_request` → `method` / `url` / `connection_ref` / `headers` / `body` * - `agent` → `prompt` / `model` / `connection_ref` * - `tool_call` → `slug` / `args` / `connection_ref` * - `code` → `language` (`javascript`|`python`) / `source` * - `condition` → `field` (truthiness of a key on the input item) * - `switch` → `expression` (=-expr, precedence) / `field` (fallback) * - `transform` → `set` (key → =-expression map) * - `trigger` → `trigger_kind` + kind-specific (`schedule`, `toolkit`/`trigger_slug`) * - `memory` → `operation` + operation-specific (`scope`/`query`/`flavour`/`key`/`value`/ * `limit`/`min_score`), see `memoryFields.tsx` * - `dedup` → `key` (an `=`-bindable per-item id expression), see `dedupFields.tsx` */ import createDebug from 'debug'; import { useEffect, useState } from 'react'; import { describeSchedule, scheduleCronExpr } from '../../../../lib/flows/cron'; import type { NodeKind } from '../../../../lib/flows/types'; import { useT } from '../../../../lib/i18n/I18nContext'; import type { FlowConnection } from '../../../../services/api/flowsApi'; import AgentNodeInspector from '../AgentNodeInspector'; import { ComposioActionField, type ComposioActionSchema, ComposioToolkitField, ComposioTriggerField, fetchActionSchema, } from './composioFields'; import { DedupForm } from './dedupFields'; import { MemoryForm } from './memoryFields'; import { NATIVE_TOOL_PREFIX, NativeToolField } from './nativeToolFields'; import { configString, configStringMap, CredentialPickerField, ExpressionField, JsonField, KeyMapField, SelectField, TextAreaField, TextField, UpstreamInsertSelect, } from './nodeConfigFields'; import { ScheduleField } from './ScheduleField'; import type { UpstreamExpressionOption } from './upstreamOptions'; const log = createDebug('app:flows:nodeConfig:forms'); /** Derive the toolkit slug from a `composio::` connection ref. */ function toolkitFromConnectionRef(ref: string): string { const parts = ref.split(':'); return parts[0] === 'composio' && parts.length >= 2 ? parts[1] : ''; } export interface NodeConfigFormProps { config: Record; /** Shallow-merge patch into the node's config (undefined values are still set). */ onChange: (patch: Record) => void; connections: FlowConnection[]; /** * `=nodes.…` expressions referencing this node's upstream ancestors, for the * insert pickers on expression-bearing fields. Optional — absent (or empty) * simply hides the pickers. */ upstreamOptions?: UpstreamExpressionOption[]; } type NodeConfigForm = (props: NodeConfigFormProps) => React.ReactElement; // ── trigger ──────────────────────────────────────────────────────────────── const TRIGGER_KINDS = ['manual', 'schedule', 'webhook', 'app_event'] as const; function TriggerForm({ config, onChange, connections }: NodeConfigFormProps) { const { t } = useT(); const kind = configString(config, 'trigger_kind') || 'manual'; const toolkit = configString(config, 'toolkit'); return (
onChange({ trigger_kind: v })} testId="node-config-trigger-kind" options={TRIGGER_KINDS.map(k => ({ value: k, label: t(`flows.nodeConfig.trigger.kind_${k}`), }))} /> {kind === 'schedule' && (() => { const rawSchedule = config.schedule; const cronExpr = scheduleCronExpr(rawSchedule); // A cron-shaped schedule (bare string or `{kind:"cron", expr}`) — or // nothing set yet — is what the visual/advanced builder understands; // let it render (and start empty, as before, when unset). if (cronExpr !== null || rawSchedule == null) { return ( onChange({ schedule: v })} testId="node-config-trigger-schedule" /> ); } // `{kind:"every", every_ms}` / `{kind:"at", at}` — the cron builder // can't model these. Show a read-only summary instead of handing it // to `ScheduleField`, whose mount effect would otherwise seed (and // silently overwrite) a default cron string over the real schedule. return (
{describeSchedule(rawSchedule)}
); })()} {kind === 'app_event' && ( <> onChange({ toolkit: v, trigger_slug: '' })} connections={connections} testId="node-config-trigger-toolkit" /> onChange({ trigger_slug: v })} toolkit={toolkit} testId="node-config-trigger-slug" /> )} {kind === 'webhook' && (

{t('flows.nodeConfig.trigger.webhookHint')}

)}
); } // ── http_request ───────────────────────────────────────────────────────────── const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; function HttpRequestForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) { const { t } = useT(); return (
onChange({ method: v })} testId="node-config-http-method" options={HTTP_METHODS.map(m => ({ value: m, label: m }))} /> onChange({ url: v })} placeholder="https://api.example.com/v1/resource" upstreamOptions={upstreamOptions} testId="node-config-http-url" /> onChange({ connection_ref: v })} connections={connections} kinds={['http']} testId="node-config-http-credential" /> onChange({ headers: v })} monoValues testId="node-config-http-headers" /> onChange({ body: v })} testId="node-config-http-body" />
); } // ── agent ──────────────────────────────────────────────────────────────────── function AgentForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) { const { t } = useT(); const prompt = configString(config, 'prompt'); return (
onChange({ prompt: v })} placeholder={t('flows.nodeConfig.agent.promptPlaceholder')} rows={5} testId="node-config-agent-prompt" /> {upstreamOptions && upstreamOptions.length > 0 && ( // Appends the picked `=nodes.…` expression to the prompt (a prompt is // prose, so inserting must not clobber what's already written).
onChange({ prompt: prompt ? `${prompt} ${expr}` : expr })} testId="node-config-agent-prompt-upstream" className="cursor-pointer rounded-md border border-line-strong bg-surface-muted px-1.5 py-1 text-[11px] text-content-muted focus:outline-none" />
)} {/* Harness knobs: which registered agent runs this node (Phase A routes an `agent_ref` node through the full tool loop) + its managed model tier. Both write onto the node config via the same shallow-merge patch. */} onChange({ connection_ref: v })} connections={connections} testId="node-config-agent-credential" />
); } // ── tool_call ───────────────────────────────────────────────────────────────── /** Read `config.args` as a plain object (Composio args are a JSON object). */ function configArgs(config: Record): Record { const args = config.args; return args && typeof args === 'object' && !Array.isArray(args) ? (args as Record) : {}; } function ToolCallForm({ config, onChange, connections, upstreamOptions }: NodeConfigFormProps) { const { t } = useT(); const slug = configString(config, 'slug'); // Two flavours of tool_call: a native OpenHuman "Tool" (provider=openhuman / // slug `oh:...`) vs a Composio "App action". The palette seeds `provider`. const isNative = configString(config, 'provider') === 'openhuman' || slug.startsWith(NATIVE_TOOL_PREFIX); // The connected account is chosen first; its toolkit scopes the action list. const connectionRef = configString(config, 'connection_ref'); const toolkit = toolkitFromConnectionRef(connectionRef); // Required-arg preflight rows (Composio only): once an action is selected, // fetch its JSON-schema `parameters` so each required arg gets its own // labeled ExpressionField row. `null` (fetch failed / unknown action / // native tool) degrades gracefully to just the raw JsonField below. The // fetched schema is tagged with its toolkit/slug key so switching action // discards a stale schema without a synchronous setState in the effect. const schemaKey = `${toolkit}${slug}`; const [fetchedSchema, setFetchedSchema] = useState<{ key: string; schema: ComposioActionSchema | null; } | null>(null); const actionSchema = fetchedSchema?.key === schemaKey ? fetchedSchema.schema : null; // Remount key for the raw args JsonField: it holds a local text buffer that // is seeded once, so row edits bump this to re-seed it with the merged args. const [argsSeed, setArgsSeed] = useState(0); useEffect(() => { if (isNative || !toolkit || !slug) return; const key = `${toolkit}${slug}`; let cancelled = false; void (async () => { try { const schema = await fetchActionSchema(toolkit, slug); if (!cancelled) { log( 'ToolCallForm: schema %s/%s → required=%d optional=%d', toolkit, slug, schema?.required.length ?? -1, schema?.optional.length ?? -1 ); setFetchedSchema({ key, schema }); } } catch { // Catalog fetch failed — keep `null` and fall back to the raw editor. log('ToolCallForm: schema fetch failed for %s/%s — raw args only', toolkit, slug); } })(); return () => { cancelled = true; }; }, [isNative, toolkit, slug]); if (isNative) { return (
onChange({ slug: v })} testId="node-config-tool-slug" /> onChange({ args: v })} testId="node-config-tool-args" />
); } const args = configArgs(config); const requiredArgs = actionSchema?.required ?? []; const setArg = (name: string, value: string) => { const next = { ...args }; if (value === '') { delete next[name]; } else { next[name] = value; } log('ToolCallForm: setArg %s (empty=%s)', name, value === ''); onChange({ args: next }); // Re-seed the raw editor so it reflects the row edit. setArgsSeed(s => s + 1); }; return (
onChange({ connection_ref: v, slug: '' })} connections={connections} kinds={['composio']} testId="node-config-tool-credential" /> onChange({ slug: v })} toolkit={toolkit} testId="node-config-tool-slug" /> {requiredArgs.map(name => { const raw = args[name]; const value = typeof raw === 'string' ? raw : raw == null ? '' : JSON.stringify(raw); const missing = value.trim() === ''; return ( setArg(name, v)} upstreamOptions={upstreamOptions} warning={ missing ? t('flows.nodeConfig.tool.requiredMissing', 'Required — not wired') : undefined } testId={`node-config-tool-arg-${name}`} /> ); })} 0 ? t('flows.nodeConfig.tool.argsAdvancedLabel', 'All args (advanced)') : t('flows.nodeConfig.tool.argsLabel') } value={config.args ?? null} onChange={v => onChange({ args: v })} testId="node-config-tool-args" />
); } // ── condition ───────────────────────────────────────────────────────────────── function ConditionForm({ config, onChange }: NodeConfigFormProps) { const { t } = useT(); return (
onChange({ field: v })} placeholder="status" testId="node-config-condition-field" />
); } // ── switch ──────────────────────────────────────────────────────────────────── function SwitchForm({ config, onChange, upstreamOptions }: NodeConfigFormProps) { const { t } = useT(); return (
onChange({ expression: v })} placeholder="item.type" upstreamOptions={upstreamOptions} testId="node-config-switch-expression" /> onChange({ field: v })} placeholder="type" testId="node-config-switch-field" />
); } // ── transform ───────────────────────────────────────────────────────────────── function TransformForm({ config, onChange, upstreamOptions }: NodeConfigFormProps) { const { t } = useT(); return (
onChange({ set: v })} monoValues upstreamOptions={upstreamOptions} testId="node-config-transform-set" />
); } // ── code ────────────────────────────────────────────────────────────────────── function CodeForm({ config, onChange }: NodeConfigFormProps) { const { t } = useT(); const language = configString(config, 'language') || 'javascript'; return (
onChange({ language: v })} testId="node-config-code-language" options={[ { value: 'javascript', label: t('flows.nodeConfig.code.language_javascript') }, { value: 'python', label: t('flows.nodeConfig.code.language_python') }, ]} /> onChange({ source: v })} placeholder="return items;" rows={8} mono testId="node-config-code-source" />
); } /** * Registry of the kinds that get a dedicated form. Any `NodeKind` absent here * (merge, split_out, output_parser, sub_workflow) falls through to the drawer's * raw-JSON escape hatch. */ export const NODE_CONFIG_FORMS: Partial> = { trigger: TriggerForm, http_request: HttpRequestForm, agent: AgentForm, tool_call: ToolCallForm, condition: ConditionForm, switch: SwitchForm, transform: TransformForm, code: CodeForm, memory: MemoryForm, dedup: DedupForm, };