feat(canvas): prompt for extractor output on connect

This commit is contained in:
2026-01-29 02:45:15 +00:00
parent 00dd88599e
commit e2cfa0a3d9

View File

@@ -273,6 +273,15 @@ function SpecRendererInner({
const [showResults, setShowResults] = useState(false);
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'unchecked'>('unchecked');
// When connecting Extractor → Objective/Constraint and the extractor has multiple outputs,
// we prompt the user to choose which output_name to use.
const [pendingOutputSelect, setPendingOutputSelect] = useState<null | {
sourceId: string;
targetId: string;
outputNames: string[];
selected: string;
}>(null);
// Build trial history for sparklines (extract objective values from recent trials)
const trialHistory = useMemo(() => {
const history: Record<string, number[]> = {};
@@ -589,39 +598,43 @@ function SpecRendererInner({
const targetType = classify(targetId);
try {
// Always persist the visual edge (for now)
await addEdge(sourceId, targetId);
// Option A truth model: objective/constraint source is the real linkage.
// When user connects Extractor -> Objective/Constraint, update *.source accordingly.
// When user connects Extractor -> Objective/Constraint, we must choose an output_name.
if (spec && sourceType === 'extractor' && (targetType === 'objective' || targetType === 'constraint')) {
const ext = spec.extractors.find((e) => e.id === sourceId);
const outputNames = (ext?.outputs || []).map((o) => o.name).filter(Boolean);
// Choose a sensible default output:
// - prefer 'value' if present
// - else if only one output, use it
// - else use first output
const outputs = ext?.outputs || [];
const preferred = outputs.find((o) => o.name === 'value')?.name;
const outputName =
preferred || (outputs.length === 1 ? outputs[0].name : outputs.length > 0 ? outputs[0].name : 'value');
if (targetType === 'objective') {
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: outputName },
});
} else {
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: outputName },
// If extractor has multiple outputs, prompt the user.
if (outputNames.length > 1) {
const preferred = outputNames.includes('value') ? 'value' : outputNames[0];
setPendingOutputSelect({
sourceId,
targetId,
outputNames,
selected: preferred,
});
return;
}
// Single (or zero) output: choose deterministically.
const outputName = outputNames[0] || 'value';
// Persist edge + runnable source.
await addEdge(sourceId, targetId);
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: outputName },
});
return;
}
// Default: just persist the visual edge.
await addEdge(sourceId, targetId);
} catch (err) {
console.error('Failed to add connection:', err);
setError(err instanceof Error ? err.message : 'Failed to add connection');
}
},
[editable, addEdge, setError, spec, updateNode]
[editable, addEdge, setError, spec, updateNode, setPendingOutputSelect]
);
// Handle node clicks for selection
@@ -760,6 +773,34 @@ function SpecRendererInner({
[editable, addNode, selectNode, setError, localNodes]
);
// -------------------------------------------------------------------------
// Output selection modal handlers (Extractor → Objective/Constraint)
// -------------------------------------------------------------------------
const confirmOutputSelection = useCallback(async () => {
if (!pendingOutputSelect) return;
const { sourceId, targetId, selected } = pendingOutputSelect;
try {
// Persist edge + runnable source wiring
await addEdge(sourceId, targetId);
await updateNode(targetId, {
source: { extractor_id: sourceId, output_name: selected },
});
} catch (err) {
console.error('Failed to apply output selection:', err);
setError(err instanceof Error ? err.message : 'Failed to apply output selection');
} finally {
setPendingOutputSelect(null);
}
}, [pendingOutputSelect, addEdge, updateNode, setError]);
const cancelOutputSelection = useCallback(() => {
// User canceled: do not create the edge, do not update source
setPendingOutputSelect(null);
}, []);
// Loading state
if (showLoadingOverlay && isLoading && !spec) {
return (
@@ -842,6 +883,55 @@ function SpecRendererInner({
</div>
)}
{/* Output selection modal (Extractor → Objective/Constraint) */}
{pendingOutputSelect && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-[520px] max-w-[90vw] bg-dark-850 border border-dark-600 rounded-xl shadow-2xl p-5">
<h3 className="text-white font-semibold text-lg">Select extractor output</h3>
<p className="text-sm text-dark-300 mt-1">
This extractor provides multiple outputs. Choose which output the target should use.
</p>
<div className="mt-4">
<label className="block text-sm font-medium text-dark-300 mb-1">Output</label>
<select
value={pendingOutputSelect.selected}
onChange={(e) =>
setPendingOutputSelect((prev) =>
prev ? { ...prev, selected: e.target.value } : prev
)
}
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white rounded-lg focus:border-primary-500 focus:outline-none transition-colors"
>
{pendingOutputSelect.outputNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<p className="text-xs text-dark-500 mt-2">
Tip: we default to <span className="text-dark-300 font-medium">value</span> when available.
</p>
</div>
<div className="mt-5 flex justify-end gap-2">
<button
onClick={cancelOutputSelection}
className="px-4 py-2 bg-dark-700 text-dark-200 hover:bg-dark-600 rounded-lg border border-dark-600 transition-colors"
>
Cancel
</button>
<button
onClick={confirmOutputSelection}
className="px-4 py-2 bg-primary-600 text-white hover:bg-primary-500 rounded-lg border border-primary-500 transition-colors"
>
Connect
</button>
</div>
</div>
</div>
)}
<ReactFlow
nodes={localNodes}
edges={edges}