Share one file-input control between code and agent workflow nodes

The code node's Input files and the agent node's Documents now render the same
NodeDocumentsControl: an All/None/Choose toggle, a multiselect of upstream
document/artifact refs, and a free-text add-ref escape (a typed A1/id appears as
a removable chip). The control owns its toggle state so Choose is reachable from
an empty selection — retiring the per-node parent flag. File passing stays
agent-only; the code node keeps its inputs field name and stages the selected
refs as files in the sandbox.
This commit is contained in:
Alex committed 2026-06-25 13:45:51 +01:00
1 parent 37d93cbd86
commit 60a06081ad
4 files changed
+233 -167

No files matched your search

+71 -167
View File
@@ -68,24 +68,20 @@ import { ConditionCase, WorkflowNode } from '../types/workflow';
import {
createDefaultCodeConfig,
normalizeCodeConfig,
parseCodeInputs,
parseCodeJsonSchemaDraft,
serializeCodeConfig,
stringifyCodeInputs,
validateCodeJsonSchema,
} from './codeNodeConfig';
import MobileBlocker from './components/MobileBlocker';
import NodeDocumentsControl from './components/NodeDocumentsControl';
import PromptTextArea, {
extractUpstreamVariables,
} from './components/PromptTextArea';
import {
documentsModeToInputDocuments,
FILE_PASSING_OPTIONS,
FilePassing,
getDocumentsMode,
normalizeFilePassing,
toDocumentVariableOptions,
withChosenDocumentOptions,
} from './documentConfig';
import {
AgentNode,
@@ -359,9 +355,6 @@ function WorkflowBuilderInner() {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
// Node id for which the author explicitly entered "Choose…" documents mode,
// so the picker shows even before any document is selected (empty reads as None).
const [docChooseNodeId, setDocChooseNodeId] = useState<string | null>(null);
const [workflowName, setWorkflowName] = useState('New Workflow');
const [workflowDescription, setWorkflowDescription] = useState('');
const [showWorkflowSettings, setShowWorkflowSettings] = useState(false);
@@ -1478,6 +1471,13 @@ function WorkflowBuilderInner() {
);
}, [selectedNode, nodes, edges]);
const selectedCodeDocumentOptions = useMemo(() => {
if (!selectedNode || selectedNode.type !== 'code') return [];
return toDocumentVariableOptions(
extractUpstreamVariables(nodes, edges, selectedNode.id),
);
}, [selectedNode, nodes, edges]);
const selectedCodeJsonSchemaText = useMemo(() => {
if (!selectedNode || selectedNode.type !== 'code') return '';
@@ -2214,144 +2214,60 @@ function WorkflowBuilderInner() {
emptyText="No sources available"
/>
</div>
{(() => {
const inputDocuments =
selectedNode.data.config?.input_documents;
const documentsMode =
getDocumentsMode(inputDocuments);
const showChoose =
documentsMode === 'choose' ||
docChooseNodeId === selectedNode.id;
const chosenDocuments = showChoose
? documentsModeToInputDocuments(
'choose',
inputDocuments ?? [],
)
: [];
const filePassing = normalizeFilePassing(
selectedNode.data.config?.file_passing,
);
const setInputDocuments = (
nextInputDocuments: string[],
) =>
<NodeDocumentsControl
key={selectedNode.id}
value={
selectedNode.data.config?.input_documents ??
[]
}
onChange={(nextInputDocuments) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
input_documents: nextInputDocuments,
},
});
return (
<>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Documents
</label>
<div className="border-border bg-card flex gap-1 rounded-xl border p-1">
{(
[
['all', 'All input docs'],
['none', 'None'],
['choose', 'Choose…'],
] as const
).map(([mode, modeLabel]) => (
<Button
key={mode}
type="button"
variant="ghost"
size="sm"
onClick={() => {
setDocChooseNodeId(
mode === 'choose'
? selectedNode.id
: null,
);
setInputDocuments(
documentsModeToInputDocuments(
mode,
chosenDocuments,
),
);
}}
className={`h-auto flex-1 rounded-lg px-2 py-1.5 text-xs font-medium ${
(showChoose
? 'choose'
: documentsMode) === mode
? 'bg-primary text-white'
: 'text-gray-600 dark:text-gray-300'
}`}
>
{modeLabel}
</Button>
))}
</div>
{showChoose && (
<div className="mt-2">
<MultiSelect
options={withChosenDocumentOptions(
selectedAgentDocumentOptions,
chosenDocuments,
)}
selected={chosenDocuments}
onChange={(nextDocuments) =>
setInputDocuments(
documentsModeToInputDocuments(
'choose',
nextDocuments,
),
)
}
placeholder="Select documents..."
searchPlaceholder="Search variables..."
emptyText="No upstream documents"
/>
</div>
)}
<p className="text-muted-foreground mt-1 text-xs">
Documents passed to this agent from
uploads or upstream nodes.
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
File passing
</label>
<Select
value={filePassing}
onValueChange={(value) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config ||
{}),
file_passing:
value as FilePassing,
},
})
}
})
}
options={selectedAgentDocumentOptions}
label="Documents"
helpText="Documents passed to this agent from uploads or upstream nodes."
/>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
File passing
</label>
<Select
value={normalizeFilePassing(
selectedNode.data.config?.file_passing,
)}
onValueChange={(value) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
file_passing: value as FilePassing,
},
})
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILE_PASSING_OPTIONS.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILE_PASSING_OPTIONS.map(
(option) => (
<SelectItem
key={option.value}
value={option.value}
>
{option.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
Auto: send native when the model
supports it, otherwise extract text.
</p>
</div>
</>
);
})()}
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-xs">
Auto: send native when the model supports
it, otherwise extract text.
</p>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Structured Output (JSON Schema)
@@ -2911,33 +2827,21 @@ function WorkflowBuilderInner() {
placeholder={'print("hello world")'}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Inputs
</label>
<textarea
value={stringifyCodeInputs(
selectedNode.data.config?.inputs,
)}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
inputs: parseCodeInputs(
e.target.value,
),
},
})
}
className="border-border focus-visible:ring-ring/50 focus-visible:border-ring bg-card w-full rounded-xl border px-3 py-2 text-sm transition-all outline-none focus-visible:ring-2 dark:text-white"
rows={2}
placeholder="One per line: state variable or artifact ref"
/>
<p className="text-muted-foreground mt-1 text-xs">
Artifact refs or state-variable names, one
per line.
</p>
</div>
<NodeDocumentsControl
key={selectedNode.id}
value={selectedNode.data.config?.inputs ?? []}
onChange={(nextInputs) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
inputs: nextInputs,
},
})
}
options={selectedCodeDocumentOptions}
label="Input files"
helpText="Artifacts/upstream refs staged as files in the sandbox (one becomes inputs/<name>)."
/>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Output Variable
@@ -0,0 +1,128 @@
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { MultiSelect } from '@/components/ui/multi-select';
import {
appendDocumentRef,
documentsModeToInputDocuments,
DocumentsMode,
getDocumentsMode,
withChosenDocumentOptions,
} from '../documentConfig';
interface NodeDocumentsControlProps {
value: string[];
onChange: (next: string[]) => void;
options: { value: string; label: string }[];
label: string;
helpText?: string;
}
const MODE_OPTIONS: { mode: DocumentsMode; label: string }[] = [
{ mode: 'all', label: 'All input docs' },
{ mode: 'none', label: 'None' },
{ mode: 'choose', label: 'Choose…' },
];
/** Shared All/None/Choose documents picker for agent and code workflow nodes. */
export default function NodeDocumentsControl({
value,
onChange,
options,
label,
helpText,
}: NodeDocumentsControlProps) {
// Track mode in component state so "Choose" stays reachable even when the
// chosen list is empty (an empty list otherwise reads back as "None").
const [mode, setMode] = useState<DocumentsMode>(() =>
getDocumentsMode(value),
);
const [refDraft, setRefDraft] = useState('');
const chosen = documentsModeToInputDocuments('choose', value);
const showChoose = mode === 'choose';
const selectMode = (next: DocumentsMode) => {
setMode(next);
onChange(documentsModeToInputDocuments(next, chosen));
};
const addRef = () => {
const next = appendDocumentRef(chosen, refDraft);
setRefDraft('');
if (next === chosen) return;
setMode('choose');
onChange(documentsModeToInputDocuments('choose', next));
};
return (
<div>
<label className="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</label>
<div className="border-border bg-card flex gap-1 rounded-xl border p-1">
{MODE_OPTIONS.map(({ mode: optionMode, label: modeLabel }) => (
<Button
key={optionMode}
type="button"
variant="ghost"
size="sm"
onClick={() => selectMode(optionMode)}
className={`h-auto flex-1 rounded-lg px-2 py-1.5 text-xs font-medium ${
mode === optionMode
? 'bg-primary text-white'
: 'text-gray-600 dark:text-gray-300'
}`}
>
{modeLabel}
</Button>
))}
</div>
{showChoose && (
<div className="mt-2 flex flex-col gap-2">
<MultiSelect
options={withChosenDocumentOptions(options, chosen)}
selected={chosen}
onChange={(next) =>
onChange(documentsModeToInputDocuments('choose', next))
}
placeholder="Select documents..."
searchPlaceholder="Search variables..."
emptyText="No upstream documents"
/>
<div className="flex gap-2">
<Input
type="text"
value={refDraft}
onChange={(e) => setRefDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addRef();
}
}}
className="bg-card h-auto rounded-xl px-3 py-2 text-sm shadow-none"
placeholder="Add ref (e.g. A1)"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={addRef}
className="h-auto shrink-0 gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300"
>
<Plus size={14} />
Add
</Button>
</div>
</div>
)}
{helpText && (
<p className="text-muted-foreground mt-1 text-xs">{helpText}</p>
)}
</div>
);
}
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import type { WorkflowVariable } from './components/PromptTextArea';
import {
ALL_INPUT_DOCUMENTS_TOKEN,
appendDocumentRef,
DEFAULT_FILE_PASSING,
documentsModeToInputDocuments,
getDocumentsMode,
@@ -126,6 +127,26 @@ describe('toDocumentVariableOptions', () => {
});
});
describe('appendDocumentRef', () => {
it('appends a trimmed literal ref', () => {
expect(appendDocumentRef(['wire_doc'], ' A1 ')).toEqual([
'wire_doc',
'A1',
]);
});
it('ignores blanks and the wildcard token', () => {
expect(appendDocumentRef(['wire_doc'], ' ')).toEqual(['wire_doc']);
expect(appendDocumentRef(['wire_doc'], ALL_INPUT_DOCUMENTS_TOKEN)).toEqual([
'wire_doc',
]);
});
it('does not append an already-present ref', () => {
expect(appendDocumentRef(['A1'], 'A1')).toEqual(['A1']);
});
});
describe('withChosenDocumentOptions', () => {
const base = [{ value: 'wire_doc', label: 'wire_doc' }];
@@ -83,6 +83,19 @@ export function toDocumentVariableOptions(
return options;
}
/** Append a typed literal ref to a chosen list, ignoring blanks, the wildcard and duplicates. */
export function appendDocumentRef(chosen: string[], ref: string): string[] {
const value = ref.trim();
if (
value === '' ||
value === ALL_INPUT_DOCUMENTS_TOKEN ||
chosen.includes(value)
) {
return chosen;
}
return [...chosen, value];
}
/** Append stored chosen names lacking an upstream option so they stay visible and removable. */
export function withChosenDocumentOptions(
options: { value: string; label: string }[],