chore: snapshot workflow migration progress

This commit is contained in:
2026-04-12 11:49:04 +02:00
parent 0cd02513d5
commit 3e810c74a3
163 changed files with 31774 additions and 2753 deletions
File diff suppressed because it is too large Load Diff
@@ -34,6 +34,32 @@ const EMPTY_FORM = {
lighting_only: false,
shadow_catcher_enabled: false,
camera_orbit: true,
workflow_input_schema_text: '[]',
}
function stringifyWorkflowInputSchema(value: unknown): string {
try {
return JSON.stringify(Array.isArray(value) ? value : [], null, 2)
} catch {
return '[]'
}
}
function parseWorkflowInputSchemaText(rawValue: unknown): unknown[] {
const text = typeof rawValue === 'string' ? rawValue.trim() : ''
if (!text) return []
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
throw new Error('Workflow input schema must be valid JSON')
}
if (!Array.isArray(parsed)) {
throw new Error('Workflow input schema must be a JSON array')
}
return parsed
}
export default function RenderTemplateTable() {
@@ -43,7 +69,7 @@ export default function RenderTemplateTable() {
const [addFile, setAddFile] = useState<File | null>(null)
const [cloneBlendFrom, setCloneBlendFrom] = useState<string>('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editDraft, setEditDraft] = useState<Partial<RenderTemplate>>({})
const [editDraft, setEditDraft] = useState<(Partial<RenderTemplate> & { workflow_input_schema_text?: string })>({})
const fileInputRef = useRef<HTMLInputElement>(null)
const reuploadRef = useRef<HTMLInputElement>(null)
const [reuploadId, setReuploadId] = useState<string | null>(null)
@@ -75,6 +101,7 @@ export default function RenderTemplateTable() {
fd.append('lighting_only', String(form.lighting_only))
fd.append('shadow_catcher_enabled', String(form.shadow_catcher_enabled))
fd.append('camera_orbit', String(form.camera_orbit))
fd.append('workflow_input_schema', JSON.stringify(parseWorkflowInputSchemaText(form.workflow_input_schema_text)))
return createRenderTemplate(fd)
},
onSuccess: () => {
@@ -85,7 +112,7 @@ export default function RenderTemplateTable() {
setCloneBlendFrom('')
setShowAdd(false)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to create template'),
onError: (e: any) => toast.error(e.response?.data?.detail || e.message || 'Failed to create template'),
})
const updateMut = useMutation({
@@ -96,7 +123,7 @@ export default function RenderTemplateTable() {
qc.invalidateQueries({ queryKey: ['render-templates'] })
setEditingId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'),
onError: (e: any) => toast.error(e.response?.data?.detail || e.message || 'Failed to update'),
})
const deleteMut = useMutation({
@@ -128,6 +155,7 @@ export default function RenderTemplateTable() {
shadow_catcher_enabled: t.shadow_catcher_enabled,
camera_orbit: t.camera_orbit,
output_type_ids: t.output_type_ids ?? [],
workflow_input_schema: t.workflow_input_schema ?? [],
}),
onSuccess: () => {
toast.success('Template duplicated')
@@ -147,13 +175,19 @@ export default function RenderTemplateTable() {
lighting_only: t.lighting_only,
shadow_catcher_enabled: t.shadow_catcher_enabled,
camera_orbit: t.camera_orbit,
workflow_input_schema_text: stringifyWorkflowInputSchema(t.workflow_input_schema),
is_active: t.is_active,
})
}
function saveEdit() {
if (!editingId) return
updateMut.mutate({ id: editingId, data: editDraft as Record<string, unknown> })
const data: Record<string, unknown> = { ...editDraft }
if (Object.prototype.hasOwnProperty.call(editDraft, 'workflow_input_schema_text')) {
data.workflow_input_schema = parseWorkflowInputSchemaText(editDraft.workflow_input_schema_text)
delete data.workflow_input_schema_text
}
updateMut.mutate({ id: editingId, data })
}
// Render the edit form grid (shared between edit-row and add-row)
@@ -174,6 +208,9 @@ export default function RenderTemplateTable() {
if (field === 'lighting_only') return editDraft.lighting_only ?? t!.lighting_only
if (field === 'shadow_catcher_enabled') return editDraft.shadow_catcher_enabled ?? t!.shadow_catcher_enabled
if (field === 'camera_orbit') return editDraft.camera_orbit ?? t!.camera_orbit
if (field === 'workflow_input_schema_text') {
return editDraft.workflow_input_schema_text ?? stringifyWorkflowInputSchema(t!.workflow_input_schema)
}
if (field === 'is_active') return editDraft.is_active ?? t!.is_active
return (editDraft as any)[field] ?? (t as any)[field]
}
@@ -381,7 +418,27 @@ export default function RenderTemplateTable() {
)}
</div>
{/* Row 4: Active + Save/Cancel */}
<div className="mt-4">
<label className="block text-xs font-medium text-content-muted mb-1">
Workflow Input Schema (JSON)
</label>
<textarea
className="input-sm w-full min-h-36 font-mono text-xs"
value={String(val('workflow_input_schema_text') ?? '[]')}
onChange={(e) => set('workflow_input_schema_text', e.target.value)}
placeholder='[{"key":"studio_variant","label":"Studio Variant","type":"select","options":[{"value":"default","label":"Default"}]}]'
/>
<p className="mt-1 text-xs text-content-muted">
Defines additional `resolve_template` node inputs for this .blend template.
</p>
<p className="mt-1 text-xs text-content-muted">
Matching variants can be bound inside the template via markers like
`template-input:studio_variant=warm` or a `template_input=studio_variant=warm`
custom property on collections, objects, or worlds.
</p>
</div>
{/* Row 5: Active + Save/Cancel */}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border-light">
{isEdit ? (
<label className="flex items-center gap-2">
@@ -0,0 +1,80 @@
import type { OutputTypeWorkflowRolloutMode } from '../../api/outputTypes'
export interface OutputTypeRolloutPresentation {
badgeLabel: string
badgeClassName: string
statusLabel: string
statusClassName: string
operatorHint: string
rowSummary: string
}
interface OutputTypeRolloutPresentationOptions {
hasWorkflowLink: boolean
workflowRolloutMode: OutputTypeWorkflowRolloutMode
hasBlockingIssues?: boolean
}
export function getOutputTypeRolloutPresentation({
hasWorkflowLink,
workflowRolloutMode,
hasBlockingIssues = false,
}: OutputTypeRolloutPresentationOptions): OutputTypeRolloutPresentation {
if (!hasWorkflowLink) {
return {
badgeLabel: 'Legacy Only',
badgeClassName: 'bg-surface-muted text-content-muted',
statusLabel: 'Production: Legacy',
statusClassName: 'bg-slate-100 text-slate-700',
operatorHint:
'No workflow is linked. Production stays entirely on the legacy dispatcher until a compatible graph workflow is attached.',
rowSummary: 'No linked graph workflow.',
}
}
if (hasBlockingIssues) {
return {
badgeLabel: 'Contract Blocked',
badgeClassName: 'bg-red-100 text-red-700',
statusLabel: 'Do Not Promote',
statusClassName: 'bg-red-100 text-red-700',
operatorHint:
'The current workflow binding is contract-invalid. Keep legacy authoritative until family, artifact, and rollout settings are fixed.',
rowSummary: 'Linked workflow needs contract fixes before rollout.',
}
}
switch (workflowRolloutMode) {
case 'graph':
return {
badgeLabel: 'Graph Authoritative',
badgeClassName: 'bg-status-success-bg text-status-success-text',
statusLabel: 'Production: Graph',
statusClassName: 'bg-emerald-100 text-emerald-700',
operatorHint:
'Graph dispatch is authoritative for production. Legacy remains the operational fallback if graph dispatch fails.',
rowSummary: 'Graph drives production with legacy fallback armed.',
}
case 'shadow':
return {
badgeLabel: 'Shadow',
badgeClassName: 'bg-status-info-bg text-status-info-text',
statusLabel: 'Production: Legacy',
statusClassName: 'bg-sky-100 text-sky-700',
operatorHint:
'Legacy stays authoritative while the graph runs as an observer for parity and rollout-gate checks.',
rowSummary: 'Graph observes only; legacy remains authoritative.',
}
case 'legacy_only':
default:
return {
badgeLabel: 'Legacy Only',
badgeClassName: 'bg-surface-muted text-content-muted',
statusLabel: 'Production: Legacy',
statusClassName: 'bg-slate-100 text-slate-700',
operatorHint:
'A workflow is linked for authoring and future rollout, but production dispatch remains on the legacy path.',
rowSummary: 'Linked graph is not active in production.',
}
}
}
+91 -72
View File
@@ -2,15 +2,25 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'rea
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Canvas, useThree } from '@react-three/fiber'
import { OrbitControls, useGLTF, Environment } from '@react-three/drei'
import * as THREE from 'three'
import {
Box3,
Group,
Mesh,
MeshStandardMaterial,
Object3D,
PerspectiveCamera,
Vector3,
type Material,
} from 'three'
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { Loader2, Box, RefreshCw, Grid3X3, Layers, Sun, AlertCircle, EyeOff, Zap } from 'lucide-react'
import { toast } from 'sonner'
import { listMediaAssets as getMediaAssets } from '../../api/media'
import { generateGltfGeometry, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { generateGltfGeometry, getManualOverrides, getPartMaterials, type PartMaterialMap } from '../../api/cad'
import { fetchSceneManifest } from '../../api/sceneManifest'
import { useAuthStore } from '../../store/auth'
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, convertSceneManifestMaterials, alignSceneManifestToLogicalPartKeys, buildScenePartRegistry, buildEffectiveViewerMaterials, resolveObjectPartKey, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
import WebGLErrorBoundary from './WebGLErrorBoundary'
@@ -35,7 +45,7 @@ function CameraAutoFit({
controlsRef,
fitTrigger,
}: {
sceneRef: React.MutableRefObject<THREE.Object3D | null>
sceneRef: React.MutableRefObject<Object3D | null>
controlsRef: React.RefObject<any>
fitTrigger: number
}) {
@@ -43,17 +53,17 @@ function CameraAutoFit({
useEffect(() => {
if (fitTrigger === 0 || !sceneRef.current) return
const box = new THREE.Box3()
const box = new Box3()
sceneRef.current.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj)
if ((obj as Mesh).isMesh) box.expandByObject(obj)
})
if (box.isEmpty()) return
const center = box.getCenter(new THREE.Vector3())
const sizeVec = box.getSize(new THREE.Vector3())
const center = box.getCenter(new Vector3())
const sizeVec = box.getSize(new Vector3())
const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z)
const pc = camera as THREE.PerspectiveCamera
const pc = camera as PerspectiveCamera
const fovRad = (pc.fov * Math.PI) / 180
const aspect = size.width / size.height
const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect)
@@ -91,19 +101,19 @@ function GlbModelWithFit({
}: {
url: string
wireframe: boolean
sceneRef: React.MutableRefObject<THREE.Object3D | null>
sceneRef: React.MutableRefObject<Object3D | null>
onReady: () => void
onPointerOver?: (e: any) => void
onPointerOut?: () => void
onClick?: (e: any) => void
}) {
const { scene } = useGLTF(url)
const cloned = useRef<THREE.Group | null>(null)
const cloned = useRef<Group | null>(null)
if (!cloned.current) {
cloned.current = scene.clone(true)
cloned.current.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
if (obj instanceof Mesh) {
if (obj.geometry) {
let geo = obj.geometry.clone()
if (!geo.index) geo = mergeVertices(geo)
@@ -116,7 +126,7 @@ function GlbModelWithFit({
// Clone materials so emissive / color changes don't affect the shared GLTF cache
if (obj.material) {
obj.material = Array.isArray(obj.material)
? obj.material.map((m: THREE.Material) => m.clone())
? obj.material.map((m: Material) => m.clone())
: obj.material.clone()
}
}
@@ -125,10 +135,10 @@ function GlbModelWithFit({
useEffect(() => {
cloned.current?.traverse((obj) => {
if (obj instanceof THREE.Mesh && obj.material) {
if (obj instanceof Mesh && obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material]
mats.forEach((m) => {
;(m as THREE.MeshStandardMaterial).wireframe = wireframe
;(m as MeshStandardMaterial).wireframe = wireframe
m.needsUpdate = true
})
}
@@ -195,13 +205,13 @@ export default function InlineCadViewer({
const [hideAssigned, setHideAssigned] = useState(false)
const [isolateMode, setIsolateMode] = useState<IsolateMode>('none')
const [perfMode, setPerfMode] = useState(false)
const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const [logicalPartKeys, setLogicalPartKeys] = useState<Set<string>>(new Set())
const [unresolvedMeshNames, setUnresolvedMeshNames] = useState<Set<string>>(new Set())
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
const sceneRef = useRef<THREE.Object3D | null>(null)
const sceneRef = useRef<Object3D | null>(null)
const controlsRef = useRef<any>(null)
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
const hoveredMeshRef = useRef<Mesh | null>(null)
const meshRegistryRef = useRef<MeshRegistryEntry[]>([])
// Media asset queries
@@ -220,6 +230,20 @@ export default function InlineCadViewer({
retry: false,
})
const { data: sceneManifest } = useQuery({
queryKey: ['scene-manifest', cadFileId],
queryFn: () => fetchSceneManifest(cadFileId),
staleTime: Infinity,
retry: false,
})
const { data: manualOverrides = {} } = useQuery({
queryKey: ['manual-overrides', cadFileId],
queryFn: () => getManualOverrides(cadFileId),
staleTime: 30_000,
retry: false,
})
// PBR material properties from Blender asset library
const { data: pbrMap = {} as MaterialPBRMap } = useQuery({
queryKey: ['material-pbr'],
@@ -227,23 +251,33 @@ export default function InlineCadViewer({
staleTime: 300_000,
})
// Merge: initialPartMaterials (from Product Excel data) as base; savedPartMaterials overrides
// Remap keys through partKeyMap so Excel-imported names match partKey slugs
const partMaterials = useMemo(
const manifestMaterials = useMemo(
() => alignSceneManifestToLogicalPartKeys(
convertSceneManifestMaterials(sceneManifest?.parts ?? []),
logicalPartKeys,
),
[sceneManifest, logicalPartKeys],
)
const fallbackMaterials = useMemo(
() => remapToPartKeys({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap, partKeyMap),
[initialPartMaterials, savedPartMaterials, partKeyMap],
)
// Resolve partKey from normalized mesh name (identity fallback when no map loaded)
const resolvePartKey = useCallback(
(normalizedName: string): string => partKeyMap[normalizedName] ?? normalizedName,
[partKeyMap],
// Merge authoritative manifest assignments with legacy/viewer fallback so the
// inline viewer consumes the same effective source contract as the main viewer.
const partMaterials = useMemo(
() => buildEffectiveViewerMaterials(manifestMaterials, fallbackMaterials, manualOverrides),
[manifestMaterials, fallbackMaterials, manualOverrides],
)
const resolvedMeshCount = logicalPartKeys.size
const unresolvedMeshCount = unresolvedMeshNames.size
// Count how many unique GLB mesh types have a resolved material assignment
const assignedCount = useMemo(
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
[glbMeshNames, partMaterials],
() => [...logicalPartKeys].filter(n => !!resolvePartMaterial(n, partMaterials)).length,
[logicalPartKeys, partMaterials],
)
useEffect(() => {
@@ -282,7 +316,7 @@ export default function InlineCadViewer({
// Clone materials on first PBR application (GLB loader shares instances)
if (!mesh.userData._pbrApplied) {
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((m: THREE.Material) => m.clone())
? mesh.material.map((m: Material) => m.clone())
: mesh.material.clone()
mesh.userData._pbrApplied = true
}
@@ -294,7 +328,7 @@ export default function InlineCadViewer({
}
})
}
}, [modelReady, partMaterials, resolvePartKey, pbrMap])
}, [modelReady, partMaterials, pbrMap])
// Unassigned glow — uses MeshRegistry instead of traverse
useEffect(() => {
@@ -313,7 +347,7 @@ export default function InlineCadViewer({
}
})
}
}, [modelReady, showUnassigned, partMaterials, resolvePartKey])
}, [modelReady, showUnassigned, partMaterials])
// Reset isolateMode when no part is pinned
useEffect(() => {
@@ -334,7 +368,7 @@ export default function InlineCadViewer({
// Default: fully visible + raycasting enabled
mesh.visible = true
mesh.raycast = THREE.Mesh.prototype.raycast
mesh.raycast = Mesh.prototype.raycast
forEachMeshMaterial(mesh, (mat) => {
if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
})
@@ -358,7 +392,7 @@ export default function InlineCadViewer({
}
}
}
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials, resolvePartKey])
}, [modelReady, pinnedPart, isolateMode, hideAssigned, partMaterials])
// Dev-only: log normalized GLB mesh names vs stored keys to diagnose mismatches
useEffect(() => {
@@ -399,16 +433,16 @@ export default function InlineCadViewer({
// Hover highlight
const handlePointerOver = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
const mesh = e.object as Mesh
// Restore previous hovered mesh (correctly preserve unassigned glow)
if (hoveredMeshRef.current && hoveredMeshRef.current !== mesh) {
const prev = hoveredMeshRef.current
const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material]
const hasAny = Object.keys(partMaterials).length > 0
prevMats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
const prevPk = (prev.userData?.partKey as string) || resolvePartKey(normalizeMeshName((prev.userData?.name as string) || prev.name))
const prevPk = resolveObjectPartKey(prev, partKeyMap)
if (showUnassigned && hasAny && !resolvePartMaterial(prevPk, partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
@@ -419,10 +453,10 @@ export default function InlineCadViewer({
hoveredMeshRef.current = mesh
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
})
}, [showUnassigned, partMaterials, resolvePartKey])
}, [showUnassigned, partMaterials, partKeyMap])
const handlePointerOut = useCallback(() => {
if (hoveredMeshRef.current) {
@@ -430,9 +464,9 @@ export default function InlineCadViewer({
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const hasAnyAssignment = Object.keys(partMaterials).length > 0
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
const pk = (mesh.userData?.partKey as string) || resolvePartKey(normalizeMeshName((mesh.userData?.name as string) || mesh.name))
const pk = resolveObjectPartKey(mesh, partKeyMap)
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(pk, partMaterials as PartMaterialMap)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
@@ -441,14 +475,14 @@ export default function InlineCadViewer({
})
hoveredMeshRef.current = null
}
}, [showUnassigned, partMaterials, resolvePartKey])
}, [showUnassigned, partMaterials, partKeyMap])
const handleClick = useCallback((e: any) => {
e.stopPropagation()
const meshObj = e.object as THREE.Mesh
const pk = (meshObj?.userData?.partKey as string) || resolvePartKey(normalizeMeshName((meshObj?.userData?.name as string) || meshObj?.name || ''))
const meshObj = e.object as Mesh
const pk = resolveObjectPartKey(meshObj, partKeyMap)
if (pk) setPinnedPart(pk)
}, [resolvePartKey])
}, [partKeyMap])
// ── Render: model loaded ──────────────────────────────────────────────────
@@ -498,10 +532,15 @@ export default function InlineCadViewer({
<ToolbarBtn
active={showUnassigned}
onClick={() => setShowUnassigned(v => !v)}
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
title={`Highlight unassigned parts (${assignedCount}/${resolvedMeshCount} resolved${unresolvedMeshCount > 0 ? `, ${unresolvedMeshCount} unresolved` : ''})`}
>
<AlertCircle size={11} />
<span className="tabular-nums text-[10px]">{assignedCount}/{totalMeshCount}</span>
<span className="tabular-nums text-[10px]">{assignedCount}/{resolvedMeshCount}</span>
{unresolvedMeshCount > 0 && (
<span className="text-[10px] text-amber-300 tabular-nums" title={`${unresolvedMeshCount} unresolved meshes without authoritative part mapping`}>
?{unresolvedMeshCount}
</span>
)}
</ToolbarBtn>
{assignedCount > 0 && (
<ToolbarBtn
@@ -536,32 +575,12 @@ export default function InlineCadViewer({
// Extract partKeyMap from GLB extras
const glbExtras = (sceneRef.current as any)?.userData ?? {}
const map = glbExtras.partKeyMap as Record<string, string> | undefined
if (map && Object.keys(map).length > 0) {
setPartKeyMap(map)
}
// Single traverse: stamp partKey, build registry, count unique parts
const registry: MeshRegistryEntry[] = []
const names = new Set<string>()
sceneRef.current?.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return
// Stamp partKey from parent Group or partKeyMap
if (!obj.userData.partKey) {
const parentPk = obj.parent?.userData?.partKey as string | undefined
if (parentPk) {
obj.userData.partKey = parentPk
} else if (map) {
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
obj.userData.partKey = map[normalized] ?? normalized
}
}
const pk = (obj.userData?.partKey as string) ||
normalizeMeshName((obj.userData?.name as string) || obj.name)
registry.push({ mesh: obj, partKey: pk })
if (pk) names.add(pk)
})
meshRegistryRef.current = registry
setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names))
const resolvedMap = map ?? {}
if (Object.keys(resolvedMap).length > 0) setPartKeyMap(resolvedMap)
const { meshRegistry, logicalPartKeys, unresolvedMeshNames } = buildScenePartRegistry(sceneRef.current, resolvedMap)
meshRegistryRef.current = meshRegistry
setLogicalPartKeys(new Set(logicalPartKeys))
setUnresolvedMeshNames(new Set(unresolvedMeshNames))
setModelReady(true)
setFitTrigger(t => t + 1)
}}
+103 -103
View File
@@ -21,7 +21,17 @@ import {
GizmoHelper,
GizmoViewport,
} from '@react-three/drei'
import * as THREE from 'three'
import {
Box3,
Color,
Mesh,
MeshStandardMaterial,
Object3D,
OrthographicCamera as ThreeOrthographicCamera,
PerspectiveCamera as ThreePerspectiveCamera,
Vector3,
type Material,
} from 'three'
import { toast } from 'sonner'
import {
X, Camera, Loader2, AlertTriangle, Box, Download, ChevronDown,
@@ -32,7 +42,7 @@ import { getParsedObjects, getPartMaterials, getManualOverrides, type PartMateri
import { fetchSceneManifest } from '../../api/sceneManifest'
import { useAuthStore } from '../../store/auth'
import MaterialPanel, { type IsolateMode } from './MaterialPanel'
import { normalizeMeshName, resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, type MeshRegistryEntry } from './cadUtils'
import { resolvePartMaterial, remapToPartKeys, applyPBRToMaterial, previewColorForEntry, forEachMeshMaterial, convertSceneManifestMaterials, alignSceneManifestToLogicalPartKeys, buildScenePartRegistry, resolveObjectPartKey, buildEffectiveViewerMaterials, type MeshRegistryEntry } from './cadUtils'
import { fetchMaterialPBR, type MaterialPBRMap } from '../../api/assetLibraries'
import { useGeometryMerge } from './useGeometryMerge'
import WebGLErrorBoundary from './WebGLErrorBoundary'
@@ -68,7 +78,7 @@ const BG_COLORS = [
]
interface SceneInfo {
center: THREE.Vector3
center: Vector3
maxDim: number
groundY: number
}
@@ -84,7 +94,7 @@ function CameraFit({
isOrtho,
onFitted,
}: {
sceneRef: React.MutableRefObject<THREE.Object3D | null>
sceneRef: React.MutableRefObject<Object3D | null>
controlsRef: React.RefObject<any>
fitTrigger: number
isOrtho: boolean
@@ -96,19 +106,19 @@ function CameraFit({
if (fitTrigger === 0 || !sceneRef.current) return
// Compute bbox from meshes only (ignore lights / empty groups)
const box = new THREE.Box3()
const box = new Box3()
sceneRef.current.traverse((obj) => {
if ((obj as THREE.Mesh).isMesh) box.expandByObject(obj)
if ((obj as Mesh).isMesh) box.expandByObject(obj)
})
if (box.isEmpty()) return
const center = box.getCenter(new THREE.Vector3())
const sizeVec = box.getSize(new THREE.Vector3())
const center = box.getCenter(new Vector3())
const sizeVec = box.getSize(new Vector3())
const maxDim = Math.max(sizeVec.x, sizeVec.y, sizeVec.z)
const groundY = box.min.y
if (isOrtho) {
const oc = camera as THREE.OrthographicCamera
const oc = camera as ThreeOrthographicCamera
const aspect = size.width / size.height
const halfH = maxDim * 0.8
oc.left = -halfH * aspect
@@ -121,7 +131,7 @@ function CameraFit({
oc.updateProjectionMatrix()
camera.position.set(center.x, center.y, center.z + maxDim * 5)
} else {
const pc = camera as THREE.PerspectiveCamera
const pc = camera as ThreePerspectiveCamera
const fovRad = (pc.fov * Math.PI) / 180
const aspect = size.width / size.height
const fovH = 2 * Math.atan(Math.tan(fovRad / 2) * aspect)
@@ -156,7 +166,7 @@ function CameraFit({
function SceneBackground({ color }: { color: string }) {
const { scene } = useThree()
useEffect(() => {
scene.background = new THREE.Color(color)
scene.background = new Color(color)
return () => { scene.background = null }
}, [color, scene])
return null
@@ -182,7 +192,7 @@ interface ModelWithReadyProps {
url: string
wireframe: boolean
onReady: () => void
sceneRef: React.MutableRefObject<THREE.Object3D | null>
sceneRef: React.MutableRefObject<Object3D | null>
onPointerOver?: (e: any) => void
onPointerOut?: () => void
onClick?: (e: any) => void
@@ -385,7 +395,7 @@ export default function ThreeDViewer({
const [hoverInfo, setHoverInfo] = useState<{ name: string; x: number; y: number } | null>(null)
// Task 5 — hovered mesh ref for emissive highlight
const hoveredMeshRef = useRef<THREE.Mesh | null>(null)
const hoveredMeshRef = useRef<Mesh | null>(null)
// partKey map from GLB extras: normalizedMeshName → partKey slug
const [partKeyMap, setPartKeyMap] = useState<Record<string, string>>({})
@@ -409,7 +419,7 @@ export default function ThreeDViewer({
const [perfMode, setPerfMode] = useState(false)
// Refs
const sceneRef = useRef<THREE.Object3D | null>(null)
const sceneRef = useRef<Object3D | null>(null)
const controlsRef = useRef<any>(null)
const camPosRef = useRef<[number, number, number]>([0, 0.1, 0.3])
@@ -443,8 +453,8 @@ export default function ThreeDViewer({
})
// Total unique normalized mesh count (set once when model is ready)
const [totalMeshCount, setTotalMeshCount] = useState(0)
const [glbMeshNames, setGlbMeshNames] = useState<Set<string>>(new Set())
const [logicalPartKeys, setLogicalPartKeys] = useState<Set<string>>(new Set())
const [unresolvedMeshNames, setUnresolvedMeshNames] = useState<Set<string>>(new Set())
// Task 6 — load saved part-material assignments (manual overrides from the viewer)
const { data: savedPartMaterials = {} } = useQuery({
@@ -461,37 +471,33 @@ export default function ThreeDViewer({
staleTime: 300_000,
})
// Merge: initialPartMaterials (Product Excel data) as base; savedPartMaterials overrides
const partMaterials = useMemo(
() => ({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap),
[initialPartMaterials, savedPartMaterials],
const manifestMaterials = useMemo(
() => alignSceneManifestToLogicalPartKeys(
convertSceneManifestMaterials(sceneManifest?.parts ?? []),
logicalPartKeys,
),
[sceneManifest, logicalPartKeys],
)
// Effective materials: remap Excel-imported keys to partKey slugs (when
// partKeyMap is available), then layer manual overrides on top.
const effectiveMaterials = useMemo(() => {
// Remap normalized OCC name keys → partKey slugs so they match mesh resolution
const remapped = remapToPartKeys(partMaterials, partKeyMap)
// Manual overrides are already keyed by partKey slug
const fromManual: PartMaterialMap = Object.fromEntries(
Object.entries(manualOverrides).map(([k, v]) => [
k,
{ type: (v.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value: v },
])
)
return { ...remapped, ...fromManual }
}, [partMaterials, manualOverrides, partKeyMap])
// Resolve partKey from normalized mesh name (identity fallback when no map loaded)
const resolvePartKey = useCallback(
(normalizedName: string): string => partKeyMap[normalizedName] ?? normalizedName,
[partKeyMap],
const fallbackMaterials = useMemo(
() => remapToPartKeys({ ...initialPartMaterials, ...savedPartMaterials } as PartMaterialMap, partKeyMap),
[initialPartMaterials, savedPartMaterials, partKeyMap],
)
// Merge scene-manifest and legacy/product sources so richer CAD mappings stay
// authoritative while graph/USD manifest keys still fill gaps.
const effectiveMaterials = useMemo(
() => buildEffectiveViewerMaterials(manifestMaterials, fallbackMaterials, manualOverrides),
[manifestMaterials, fallbackMaterials, manualOverrides],
)
const resolvedMeshCount = logicalPartKeys.size
const unresolvedMeshCount = unresolvedMeshNames.size
// Count how many unique GLB mesh types have a resolved material assignment
const assignedCount = useMemo(
() => [...glbMeshNames].filter(n => !!resolvePartMaterial(n, effectiveMaterials)).length,
[glbMeshNames, effectiveMaterials],
() => [...logicalPartKeys].filter(n => !!resolvePartMaterial(n, effectiveMaterials)).length,
[logicalPartKeys, effectiveMaterials],
)
// Raw URL (used as stable key before blob fetch)
@@ -547,33 +553,10 @@ export default function ThreeDViewer({
setPartKeyMap(map)
}
// Single traverse: stamp partKey, build registry, count unique parts
const registry: MeshRegistryEntry[] = []
const names = new Set<string>()
sceneRef.current.traverse((obj) => {
if (!(obj instanceof THREE.Mesh)) return
// Stamp userData.partKey (propagate from parent Group for multi-primitive GLB nodes)
if (!obj.userData.partKey) {
const parentPk = obj.parent?.userData?.partKey as string | undefined
if (parentPk) {
obj.userData.partKey = parentPk
} else if (map) {
const normalized = normalizeMeshName((obj.userData?.name as string) || obj.name)
obj.userData.partKey = map[normalized] ?? normalized
}
}
// Resolve partKey for this mesh
const pk = (obj.userData?.partKey as string) ||
normalizeMeshName((obj.userData?.name as string) || obj.name)
registry.push({ mesh: obj, partKey: pk })
if (pk) names.add(pk)
})
meshRegistryRef.current = registry
setTotalMeshCount(names.size)
setGlbMeshNames(new Set(names))
const { meshRegistry, logicalPartKeys, unresolvedMeshNames } = buildScenePartRegistry(sceneRef.current, map ?? {})
meshRegistryRef.current = meshRegistry
setLogicalPartKeys(new Set(logicalPartKeys))
setUnresolvedMeshNames(new Set(unresolvedMeshNames))
}, [modelReady])
// Re-fit when switching projection mode
@@ -592,7 +575,7 @@ export default function ThreeDViewer({
// Clone materials on first PBR application (GLB loader shares instances)
if (!mesh.userData._pbrApplied) {
mesh.material = Array.isArray(mesh.material)
? mesh.material.map((m: THREE.Material) => m.clone())
? mesh.material.map((m: Material) => m.clone())
: mesh.material.clone()
mesh.userData._pbrApplied = true
}
@@ -604,7 +587,7 @@ export default function ThreeDViewer({
}
})
}
}, [modelReady, effectiveMaterials, resolvePartKey, pbrMap])
}, [modelReady, effectiveMaterials, pbrMap])
// Apply/remove unassigned highlight — uses MeshRegistry instead of traverse
useEffect(() => {
@@ -623,7 +606,7 @@ export default function ThreeDViewer({
}
})
}
}, [modelReady, showUnassigned, effectiveMaterials, resolvePartKey])
}, [modelReady, showUnassigned, effectiveMaterials])
// Reset isolateMode when no part is pinned
useEffect(() => {
@@ -644,7 +627,7 @@ export default function ThreeDViewer({
// Default: fully visible + raycasting enabled
mesh.visible = true
mesh.raycast = THREE.Mesh.prototype.raycast
mesh.raycast = Mesh.prototype.raycast
forEachMeshMaterial(mesh, (mat) => {
if ('opacity' in mat) { mat.opacity = 1; mat.transparent = false; mat.depthWrite = true; mat.needsUpdate = true }
})
@@ -668,7 +651,7 @@ export default function ThreeDViewer({
}
}
}
}, [modelReady, pinnedPart, isolateMode, hideAssigned, effectiveMaterials, resolvePartKey])
}, [modelReady, pinnedPart, isolateMode, hideAssigned, effectiveMaterials])
// Keyboard shortcuts
useEffect(() => {
@@ -706,12 +689,8 @@ export default function ThreeDViewer({
// Task 5 — hover: highlight mesh with emissive, restore on out
const handlePointerOver = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
const raw = (mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || ''
const normalized = normalizeMeshName(raw) || 'Part'
// Task 3: prefer userData.partKey (set by GLB node extras or Task 2 stamp) over
// raw normalized name so tooltip shows canonical slug (e.g. "ring_outer") not OCC name
const displayName = (mesh?.userData?.partKey as string | undefined) ?? resolvePartKey(normalized)
const mesh = e.object as Mesh
const displayName = resolveObjectPartKey(mesh, partKeyMap) || 'Part'
setHoverInfo({ name: displayName, x: e.nativeEvent.clientX, y: e.nativeEvent.clientY })
// Restore previous hovered mesh (array-safe)
@@ -720,7 +699,7 @@ export default function ThreeDViewer({
if (!showUnassigned) {
const prevMats = Array.isArray(prev.material) ? prev.material : [prev.material]
prevMats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x000000); mat.emissiveIntensity = 0 }
})
}
@@ -732,10 +711,10 @@ export default function ThreeDViewer({
? (Array.isArray(mesh.material) ? mesh.material : [mesh.material])
: []
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (mat && 'emissive' in mat) { mat.emissive.set(0x333333); mat.emissiveIntensity = 0.5 }
})
}, [showUnassigned, resolvePartKey])
}, [showUnassigned, partKeyMap])
const handlePointerOut = useCallback(() => {
setHoverInfo(null)
@@ -744,10 +723,10 @@ export default function ThreeDViewer({
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const hasAnyAssignment = Object.keys(effectiveMaterials).length > 0
mats.forEach((m) => {
const mat = m as THREE.MeshStandardMaterial
const mat = m as MeshStandardMaterial
if (!mat || !('emissive' in mat)) return
const normalized = normalizeMeshName((mesh.userData?.name as string) || mesh.name)
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(resolvePartKey(normalized), effectiveMaterials)) {
const partKey = resolveObjectPartKey(mesh, partKeyMap)
if (showUnassigned && hasAnyAssignment && !resolvePartMaterial(partKey, effectiveMaterials)) {
mat.emissive.set(0xff4400); mat.emissiveIntensity = 0.8
} else {
mat.emissive.set(0x000000); mat.emissiveIntensity = 0
@@ -755,7 +734,7 @@ export default function ThreeDViewer({
})
hoveredMeshRef.current = null
}
}, [showUnassigned, effectiveMaterials, resolvePartKey])
}, [showUnassigned, effectiveMaterials, partKeyMap])
const handlePointerMove = useCallback((e: React.PointerEvent) => {
setHoverInfo(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)
@@ -764,10 +743,10 @@ export default function ThreeDViewer({
// Task 7 — click to pin material panel (resolves to partKey slug when available)
const handleClick = useCallback((e: any) => {
e.stopPropagation()
const mesh = e.object as THREE.Mesh
const normalized = normalizeMeshName((mesh?.userData?.name as string) || mesh?.name || (mesh?.parent?.userData?.name as string) || mesh?.parent?.name || '')
if (normalized) setPinnedPart(resolvePartKey(normalized))
}, [resolvePartKey])
const mesh = e.object as Mesh
const partKey = resolveObjectPartKey(mesh, partKeyMap)
if (partKey) setPinnedPart(partKey)
}, [partKeyMap])
return (
<div className="fixed inset-0 z-50 flex flex-col bg-gray-950" onClick={() => setPinnedPart(null)}>
@@ -831,10 +810,15 @@ export default function ThreeDViewer({
<TBtn
active={showUnassigned}
onClick={() => setShowUnassigned(v => !v)}
title={`Highlight unassigned parts (${assignedCount}/${totalMeshCount} assigned)`}
title={`Highlight unassigned parts (${assignedCount}/${resolvedMeshCount} resolved${unresolvedMeshCount > 0 ? `, ${unresolvedMeshCount} unresolved` : ''})`}
>
<AlertCircle size={11} />
<span className="text-[10px] tabular-nums">{assignedCount}/{totalMeshCount}</span>
<span className="text-[10px] tabular-nums">{assignedCount}/{resolvedMeshCount}</span>
{unresolvedMeshCount > 0 && (
<span className="text-[10px] text-amber-300 tabular-nums" title={`${unresolvedMeshCount} unresolved meshes without authoritative part mapping`}>
?{unresolvedMeshCount}
</span>
)}
</TBtn>
)}
@@ -851,18 +835,18 @@ export default function ThreeDViewer({
)}
{/* Reconciliation button — shown when manifest has unmatched/unassigned items */}
{sceneManifest && (sceneManifest.unmatched_source_rows.length > 0 || sceneManifest.unassigned_parts.length > 0) && (
{(sceneManifest && (sceneManifest.unmatched_source_rows.length > 0 || sceneManifest.unassigned_parts.length > 0)) || unresolvedMeshCount > 0 ? (
<TBtn
active={showReconcile}
onClick={() => setShowReconcile(v => !v)}
title={`${sceneManifest.unmatched_source_rows.length} unmatched source rows · ${sceneManifest.unassigned_parts.length} unassigned parts`}
title={`${sceneManifest?.unmatched_source_rows.length ?? 0} unmatched source rows · ${sceneManifest?.unassigned_parts.length ?? 0} unassigned parts · ${unresolvedMeshCount} unresolved meshes`}
>
<AlertTriangle size={11} />
<span className="text-[10px] tabular-nums">
{sceneManifest.unmatched_source_rows.length + sceneManifest.unassigned_parts.length}
{(sceneManifest?.unmatched_source_rows.length ?? 0) + (sceneManifest?.unassigned_parts.length ?? 0) + unresolvedMeshCount}
</span>
</TBtn>
)}
) : null}
{/* Environment */}
<EnvDropdown value={envPreset} onChange={setEnvPreset} />
@@ -978,7 +962,7 @@ export default function ThreeDViewer({
)}
{/* Reconciliation panel */}
{showReconcile && sceneManifest && (
{showReconcile && (sceneManifest || unresolvedMeshCount > 0) && (
<div
className="absolute top-2 right-2 z-30 w-64 bg-gray-900 border border-gray-700 rounded-lg shadow-2xl max-h-[70vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
@@ -992,7 +976,23 @@ export default function ThreeDViewer({
</button>
</div>
<div className="p-3 space-y-3">
{sceneManifest.unassigned_parts.length > 0 && (
{unresolvedMeshCount > 0 && (
<div>
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
Unresolved meshes ({unresolvedMeshCount})
</p>
{[...unresolvedMeshNames].sort().map((meshName) => (
<div
key={meshName}
className="px-2 py-1 text-xs text-amber-300 truncate"
title={meshName}
>
{meshName}
</div>
))}
</div>
)}
{sceneManifest?.unassigned_parts.length ? (
<div>
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
Unassigned parts ({sceneManifest.unassigned_parts.length})
@@ -1008,8 +1008,8 @@ export default function ThreeDViewer({
</button>
))}
</div>
)}
{sceneManifest.unmatched_source_rows.length > 0 && (
) : null}
{sceneManifest?.unmatched_source_rows.length ? (
<div>
<p className="text-gray-400 text-[10px] font-medium mb-1.5 uppercase tracking-wider">
Unmatched source rows ({sceneManifest.unmatched_source_rows.length})
@@ -1024,7 +1024,7 @@ export default function ThreeDViewer({
</div>
))}
</div>
)}
) : null}
</div>
</div>
)}
@@ -1082,8 +1082,8 @@ export default function ThreeDViewer({
args={[
sceneInfo.maxDim * 6,
Math.round(sceneInfo.maxDim * 6 / (sceneInfo.maxDim / 10)),
new THREE.Color('#3a3a3a'),
new THREE.Color('#222222'),
new Color('#3a3a3a'),
new Color('#222222'),
]}
position={[sceneInfo.center.x, sceneInfo.groundY - 0.0001, sceneInfo.center.z]}
/>
+391 -16
View File
@@ -27,6 +27,144 @@ export function normalizeMeshName(name: string): string {
return n
}
function slugifyMaterialKey(value: string): string {
return value.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
}
function buildLegacyMaterialLookup(
materialMap: PartMaterialMap,
): PartMaterialMap {
const lookup: PartMaterialMap = {}
for (const [rawKey, entry] of Object.entries(materialMap)) {
const normalized = rawKey.toLowerCase().trim()
if (!normalized) continue
lookup[normalized] = entry
const slugKey = slugifyMaterialKey(normalized)
if (slugKey && !lookup[slugKey]) lookup[slugKey] = entry
const stripped = normalized.replace(/(_af\d+(_\d+)?)+$/gi, '')
if (stripped !== normalized) {
if (!lookup[stripped]) lookup[stripped] = entry
const slugStripped = slugifyMaterialKey(stripped)
if (slugStripped && !lookup[slugStripped]) lookup[slugStripped] = entry
}
}
return lookup
}
function commonPrefixLength(left: string, right: string): number {
const limit = Math.min(left.length, right.length)
let idx = 0
while (idx < limit && left[idx] === right[idx]) idx += 1
return idx
}
function lookupByPrefix(
query: string,
materialLookup: PartMaterialMap,
): PartMaterialEntry | undefined {
if (!query) return undefined
const contenders: Array<{ keyLength: number; entry: PartMaterialEntry }> = []
for (const [key, entry] of Object.entries(materialLookup)) {
if (key.length >= 5 && query.length >= 5 && (query.startsWith(key) || key.startsWith(query))) {
contenders.push({ keyLength: key.length, entry })
}
}
if (contenders.length === 0) return undefined
contenders.sort((a, b) => b.keyLength - a.keyLength)
const topLength = contenders[0].keyLength
const closeValues = new Set(
contenders
.filter((item) => item.keyLength >= topLength - 2)
.map((item) => `${item.entry.type}:${item.entry.value}`),
)
return closeValues.size === 1 ? contenders[0].entry : undefined
}
function lookupByCommonPrefix(
query: string,
materialLookup: PartMaterialMap,
): PartMaterialEntry | undefined {
if (!query) return undefined
const scored: Array<{ ratio: number; prefixLength: number; keyLength: number; entry: PartMaterialEntry }> = []
for (const [key, entry] of Object.entries(materialLookup)) {
const prefixLength = commonPrefixLength(query, key)
if (prefixLength < 12) continue
const ratio = prefixLength / Math.max(query.length, key.length)
if (ratio < 0.68) continue
scored.push({ ratio, prefixLength, keyLength: key.length, entry })
}
if (scored.length === 0) return undefined
scored.sort((a, b) =>
b.ratio - a.ratio ||
b.prefixLength - a.prefixLength ||
b.keyLength - a.keyLength,
)
const top = scored[0]
const closeValues = new Set(
scored
.filter((item) => item.ratio >= top.ratio - 0.02 && item.prefixLength >= top.prefixLength - 2)
.map((item) => `${item.entry.type}:${item.entry.value}`),
)
return closeValues.size === 1 ? top.entry : undefined
}
function lookupLegacyPartMaterial(
rawName: string,
materialLookup: PartMaterialMap,
...fallbackNames: string[]
): PartMaterialEntry | undefined {
const candidates = [rawName, ...fallbackNames]
const seen = new Set<string>()
for (const candidate of candidates) {
if (!candidate) continue
const normalized = candidate.toLowerCase().trim()
const variants = [normalized]
const stripped = normalized.replace(/(_af\d+(_\d+)?)+$/gi, '')
if (stripped !== normalized) variants.push(stripped)
const noInstance = stripped.replace(/_\d+$/, '')
if (noInstance && !variants.includes(noInstance)) variants.push(noInstance)
for (const variant of [...variants]) {
const slug = slugifyMaterialKey(variant)
if (slug && !variants.includes(slug)) variants.push(slug)
}
const deduped = variants.filter((variant) => {
if (!variant || seen.has(variant)) return false
seen.add(variant)
return true
})
for (const variant of deduped) {
if (materialLookup[variant]) return materialLookup[variant]
}
for (const variant of deduped) {
const matched = lookupByPrefix(variant, materialLookup)
if (matched) return matched
}
for (const variant of deduped) {
const matched = lookupByCommonPrefix(variant, materialLookup)
if (matched) return matched
}
}
return undefined
}
// ---------------------------------------------------------------------------
// resolvePartMaterial
// ---------------------------------------------------------------------------
@@ -80,24 +218,13 @@ export function remapToPartKeys(
partKeyMap: Record<string, string>,
): PartMaterialMap {
if (!partKeyMap || Object.keys(partKeyMap).length === 0) return materials
const mapKeys = Object.keys(partKeyMap)
const lookup = buildLegacyMaterialLookup(materials)
const result: PartMaterialMap = {}
for (const [key, entry] of Object.entries(materials)) {
// 1. Exact match
if (partKeyMap[key]) { result[partKeyMap[key]] = entry; continue }
// 2. Prefix match: cad_part_materials may have extra _1 instance suffixes
// that partKeyMap doesn't (e.g. "PART_04_1" vs partKeyMap "PART_04")
let matched = false
for (const mk of mapKeys) {
if (key.startsWith(mk + '_') || key === mk) {
result[partKeyMap[mk]] = entry
matched = true
break
}
}
if (!matched) result[key] = entry // preserve unmapped
for (const [sourceName, partKey] of Object.entries(partKeyMap)) {
const entry = lookupLegacyPartMaterial(sourceName, lookup, partKey)
if (entry) result[partKey] = entry
}
return result
return Object.keys(result).length > 0 ? result : materials
}
// ---------------------------------------------------------------------------
@@ -125,6 +252,94 @@ export function convertCadPartMaterials(
return result
}
/**
* Convert scene manifest parts into the PartMaterialMap format used by the viewers.
*
* Scene manifest materials are already authoritative and keyed by part_key.
*/
export function convertSceneManifestMaterials(
parts: Array<{ part_key: string; effective_material: string | null }>,
): PartMaterialMap {
const result: PartMaterialMap = {}
for (const part of parts) {
const key = part.part_key?.trim()
const value = part.effective_material?.trim()
if (!key || !value) continue
result[key] = { type: value.startsWith('#') ? 'hex' : 'library', value }
}
return result
}
/**
* Add viewer-logical keys for scene-manifest entries when GLB semantics collapse
* repeated leaf part keys onto a shared instance key.
*/
export function alignSceneManifestToLogicalPartKeys(
partMaterials: PartMaterialMap,
logicalPartKeys: Iterable<string>,
): PartMaterialMap {
if (!partMaterials || Object.keys(partMaterials).length === 0) return partMaterials
const legacyLookup = buildLegacyMaterialLookup(partMaterials)
let changed = false
const result: PartMaterialMap = { ...partMaterials }
for (const rawKey of logicalPartKeys) {
const logicalKey = rawKey.trim()
if (!logicalKey || result[logicalKey]) continue
const entry =
resolvePartMaterial(logicalKey, partMaterials) ??
lookupLegacyPartMaterial(logicalKey, legacyLookup)
if (!entry) continue
result[logicalKey] = entry
changed = true
}
return changed ? result : partMaterials
}
/**
* Merge scene-manifest and legacy/fallback viewer material maps.
*
* Legacy CAD/product assignments stay authoritative for overlapping keys so the
* viewer keeps parity with the pre-USD material mapping behavior. The scene
* manifest still fills gaps for graph/USD-only logical keys that do not exist
* in the fallback map yet.
*/
export function mergeViewerMaterialSources(
manifestMaterials: PartMaterialMap,
fallbackMaterials: PartMaterialMap,
): PartMaterialMap {
if (!Object.keys(manifestMaterials).length) return fallbackMaterials
if (!Object.keys(fallbackMaterials).length) return manifestMaterials
return { ...manifestMaterials, ...fallbackMaterials }
}
/**
* Build the effective viewer material map shared by inline and fullscreen CAD viewers.
*
* - fallback materials stay authoritative for overlapping keys
* - manifest materials fill graph/USD-only gaps
* - manual overrides win last because they are explicit user actions
*/
export function buildEffectiveViewerMaterials(
manifestMaterials: PartMaterialMap,
fallbackMaterials: PartMaterialMap,
manualOverrides: Record<string, string>,
): PartMaterialMap {
const merged = mergeViewerMaterialSources(manifestMaterials, fallbackMaterials)
if (!manualOverrides || Object.keys(manualOverrides).length === 0) return merged
const overrideEntries: PartMaterialMap = Object.fromEntries(
Object.entries(manualOverrides).map(([key, value]) => [
key,
{ type: (value.startsWith('#') ? 'hex' : 'library') as 'hex' | 'library', value },
]),
)
return { ...merged, ...overrideEntries }
}
// ---------------------------------------------------------------------------
// PBR material helpers
// ---------------------------------------------------------------------------
@@ -184,6 +399,166 @@ export interface MeshRegistryEntry {
partKey: string
}
export interface ScenePartRegistry {
meshRegistry: MeshRegistryEntry[]
logicalPartKeys: Set<string>
unresolvedMeshNames: Set<string>
}
function readPartKey(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined
}
function readFiniteNumber(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function readObjectName(object: any): string {
const raw = object?.userData?.name || object?.name || ''
return typeof raw === 'string' ? raw.trim() : ''
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function findAncestorPartKey(object: any): string | undefined {
let current = object
while (current) {
const partKey = readPartKey(current?.userData?.partKey)
if (partKey) return partKey
current = current.parent
}
return undefined
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function resolveOwnPartKey(object: any, partKeyMap: Record<string, string>): string | undefined {
const explicitPartKey = readPartKey(object?.userData?.partKey)
if (explicitPartKey) return explicitPartKey
const normalized = normalizeMeshName(readObjectName(object))
return normalized ? partKeyMap[normalized] : undefined
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function haveMatchingLocalTransforms(left: any, right: any): boolean {
const epsilon = 1e-5
const samePosition =
Math.abs(readFiniteNumber(left?.position?.x) - readFiniteNumber(right?.position?.x)) <= epsilon &&
Math.abs(readFiniteNumber(left?.position?.y) - readFiniteNumber(right?.position?.y)) <= epsilon &&
Math.abs(readFiniteNumber(left?.position?.z) - readFiniteNumber(right?.position?.z)) <= epsilon
if (!samePosition) return false
const sameScale =
Math.abs(readFiniteNumber(left?.scale?.x, 1) - readFiniteNumber(right?.scale?.x, 1)) <= epsilon &&
Math.abs(readFiniteNumber(left?.scale?.y, 1) - readFiniteNumber(right?.scale?.y, 1)) <= epsilon &&
Math.abs(readFiniteNumber(left?.scale?.z, 1) - readFiniteNumber(right?.scale?.z, 1)) <= epsilon
if (!sameScale) return false
const dot =
readFiniteNumber(left?.quaternion?.x) * readFiniteNumber(right?.quaternion?.x) +
readFiniteNumber(left?.quaternion?.y) * readFiniteNumber(right?.quaternion?.y) +
readFiniteNumber(left?.quaternion?.z) * readFiniteNumber(right?.quaternion?.z) +
readFiniteNumber(left?.quaternion?.w, 1) * readFiniteNumber(right?.quaternion?.w, 1)
return Math.abs(1 - Math.abs(dot)) <= 1e-4
}
function isSemanticSiblingMatch(meshName: string, siblingName: string): boolean {
if (!meshName || !siblingName) return false
if (meshName === siblingName) return true
if (meshName.startsWith(`${siblingName}_`)) return true
return meshName.replace(/_\d+$/, '') === siblingName
}
function scoreSiblingCandidate(meshName: string, siblingName: string, siblingPartKey: string): number {
const canonicalBonus = /_af\d+$/i.test(siblingPartKey) ? 0 : 1000
const baseBonus = meshName.replace(/_\d+$/, '') === siblingName ? 100 : 0
return canonicalBonus + baseBonus + siblingName.length
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function findSiblingSemanticPartKey(object: any, partKeyMap: Record<string, string>): string | undefined {
if (!object?.isMesh || !object?.parent?.children) return undefined
const meshName = normalizeMeshName(readObjectName(object))
if (!meshName) return undefined
let bestMatch: { partKey: string; score: number } | undefined
for (const sibling of object.parent.children) {
if (!sibling || sibling === object || sibling.isMesh) continue
const siblingPartKey = resolveOwnPartKey(sibling, partKeyMap)
const siblingName = normalizeMeshName(readObjectName(sibling))
if (!siblingPartKey || !siblingName) continue
if (!isSemanticSiblingMatch(meshName, siblingName)) continue
// Real OCC exports can place the semantic helper node and the mesh instance
// under the same parent with different local transforms. Matching transforms
// is still a strong hint, but it must not be mandatory.
const score =
scoreSiblingCandidate(meshName, siblingName, siblingPartKey) +
(haveMatchingLocalTransforms(object, sibling) ? 10_000 : 0)
if (!bestMatch || score > bestMatch.score) {
bestMatch = { partKey: siblingPartKey, score }
}
}
return bestMatch?.partKey
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function resolveSceneNodePartKey(object: any, partKeyMap: Record<string, string>): string {
const ownPartKey = resolveOwnPartKey(object, partKeyMap)
if (ownPartKey) return ownPartKey
const siblingPartKey = findSiblingSemanticPartKey(object, partKeyMap)
if (siblingPartKey) return siblingPartKey
const inheritedPartKey = findAncestorPartKey(object?.parent)
if (inheritedPartKey) return inheritedPartKey
return ''
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function resolveObjectPartKey(object: any, partKeyMap: Record<string, string>): string {
return resolveSceneNodePartKey(object, partKeyMap)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function buildScenePartRegistry(object: any, partKeyMap: Record<string, string>): ScenePartRegistry {
const meshRegistry: MeshRegistryEntry[] = []
const logicalPartKeys = new Set<string>()
const unresolvedMeshNames = new Set<string>()
object?.traverse((node: any) => {
const resolvedPartKey = resolveSceneNodePartKey(node, partKeyMap)
if (resolvedPartKey && !readPartKey(node?.userData?.partKey)) {
node.userData = node.userData ?? {}
node.userData.partKey = resolvedPartKey
}
if (!node?.isMesh) return
const nodePartKey = readPartKey(node?.userData?.partKey)
const meshPartKey = resolvedPartKey || nodePartKey
if (!meshPartKey) {
const unresolvedName = normalizeMeshName(readObjectName(node)) || readObjectName(node)
if (unresolvedName) unresolvedMeshNames.add(unresolvedName)
return
}
node.userData = node.userData ?? {}
node.userData.partKey = meshPartKey
logicalPartKeys.add(meshPartKey)
meshRegistry.push({ mesh: node, partKey: meshPartKey })
})
return { meshRegistry, logicalPartKeys, unresolvedMeshNames }
}
/**
* Iterate all materials on a mesh, calling `fn` for each MeshStandardMaterial.
* Handles both single and array materials safely.
+19 -13
View File
@@ -1,5 +1,11 @@
import { useEffect, useRef } from 'react'
import * as THREE from 'three'
import {
BufferGeometry,
Mesh,
MeshStandardMaterial,
Object3D,
Scene,
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import type { MeshRegistryEntry } from './cadUtils'
import type { PartMaterialMap } from '../../api/cad'
@@ -17,8 +23,8 @@ interface UseGeometryMergeOpts {
}
interface MergedState {
mergedMeshes: THREE.Mesh[]
hiddenOriginals: THREE.Object3D[]
mergedMeshes: Mesh[]
hiddenOriginals: Object3D[]
}
/**
@@ -57,15 +63,15 @@ export function useGeometryMerge({
const groups = groupRegistryByMaterial(registry, partMaterials)
if (groups.size === 0) return
const mergedMeshes: THREE.Mesh[] = []
const hiddenOriginals: THREE.Object3D[] = []
const mergedMeshes: Mesh[] = []
const hiddenOriginals: Object3D[] = []
let meshesReplaced = 0
for (const [materialKey, entries] of groups) {
// Collect geometries with world transforms baked in
const geometries: THREE.BufferGeometry[] = []
const geometries: BufferGeometry[] = []
for (const entry of entries) {
const mesh = entry.mesh as THREE.Mesh
const mesh = entry.mesh as Mesh
if (!mesh.geometry) continue
// Ensure world matrix is up to date
mesh.updateWorldMatrix(true, false)
@@ -96,10 +102,10 @@ export function useGeometryMerge({
}
// Create merged mesh with material from the first entry
const sourceMesh = entries[0].mesh as THREE.Mesh
const sourceMesh = entries[0].mesh as Mesh
const mat = (Array.isArray(sourceMesh.material)
? sourceMesh.material[0]
: sourceMesh.material) as THREE.MeshStandardMaterial
: sourceMesh.material) as MeshStandardMaterial
const mergedMat = mat.clone()
// Apply PBR properties to the merged material
@@ -112,7 +118,7 @@ export function useGeometryMerge({
}
}
const mergedMesh = new THREE.Mesh(merged, mergedMat)
const mergedMesh = new Mesh(merged, mergedMat)
mergedMesh.name = `__merged_${materialKey}`
mergedMesh.userData._isMerged = true
scene.add(mergedMesh)
@@ -120,7 +126,7 @@ export function useGeometryMerge({
// Hide originals
for (const entry of entries) {
const mesh = entry.mesh as THREE.Object3D
const mesh = entry.mesh as Object3D
mesh.visible = false
mesh.raycast = () => {} // disable raycasting
hiddenOriginals.push(mesh)
@@ -150,7 +156,7 @@ export function useGeometryMerge({
return { drawCallReduction: reductionRef.current }
}
function _restore(state: MergedState, scene: THREE.Scene): void {
function _restore(state: MergedState, scene: Scene): void {
// Remove merged meshes
for (const mesh of state.mergedMeshes) {
scene.remove(mesh)
@@ -164,6 +170,6 @@ function _restore(state: MergedState, scene: THREE.Scene): void {
// Restore originals
for (const obj of state.hiddenOriginals) {
obj.visible = true
obj.raycast = THREE.Mesh.prototype.raycast
obj.raycast = Mesh.prototype.raycast
}
}
@@ -1,14 +1,23 @@
import { useEffect, type ReactNode } from 'react'
import { X } from 'lucide-react'
import { Boxes, Milestone, X } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser'
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
import {
type WorkflowAuthoringActions,
type WorkflowAuthoringPosition,
} from './workflowAuthoringActions'
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
export const NODE_COMMAND_MENU_WIDTH = 360
interface NodeCommandMenuProps {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
onSelectStep: (step: string) => void
activeSteps?: string[]
actions: WorkflowAuthoringActions
preferredPosition?: WorkflowAuthoringPosition
onClose: () => void
renderIcon: (iconName?: string, size?: number) => ReactNode
}
@@ -16,10 +25,22 @@ interface NodeCommandMenuProps {
export function NodeCommandMenu({
definitions,
graphFamily,
onSelectStep,
activeSteps = [],
actions,
preferredPosition,
onClose,
renderIcon,
}: NodeCommandMenuProps) {
const { activeSection, insertBindings, plan, sections, setActiveSection } =
useWorkflowAuthoringSurface({
definitions,
graphFamily,
activeSteps,
actions,
preferredPosition,
onAfterInsert: onClose,
})
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -32,14 +53,34 @@ export function NodeCommandMenu({
}, [onClose])
return (
<div className="flex max-h-[70vh] w-[380px] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface shadow-2xl">
<div
className="flex max-h-[calc(100vh-2rem)] flex-col overflow-hidden rounded-2xl border border-border-default bg-surface shadow-2xl"
style={{ width: NODE_COMMAND_MENU_WIDTH }}
>
<div className="border-b border-border-default px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-content">Add Workflow Node</p>
<p className="text-sm font-semibold text-content">Workflow Authoring</p>
<p className="text-xs text-content-muted">
Search by label, step, family, or execution mode.
Use complete reference paths, modules, starter steps, or the raw node library directly on the canvas.
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
{activeSteps.length} on canvas
</span>
{plan.referenceBundles.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
<Milestone size={11} />
{plan.referenceBundles.length} paths
</span>
)}
{plan.moduleBundles.length > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface-hover px-2 py-0.5">
<Boxes size={11} />
{plan.moduleBundles.length} modules
</span>
)}
</div>
</div>
<button
type="button"
@@ -53,15 +94,42 @@ export function NodeCommandMenu({
</div>
<div className="flex-1 overflow-y-auto px-3 py-3">
<WorkflowNodeCatalogBrowser
definitions={definitions}
graphFamily={graphFamily}
variant="menu"
onSelectStep={onSelectStep}
renderIcon={renderIcon}
searchPlaceholder="Search nodes"
autoFocusSearch
/>
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{sections.map(section => {
const Icon = section.icon
const isActive = activeSection === section.key
return (
<button
key={section.key}
type="button"
onClick={() => setActiveSection(section.key)}
className={`inline-flex items-center gap-1 rounded-full px-3 py-1.5 text-xs font-medium transition-colors ${
isActive
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={section.helper}
>
<Icon size={12} />
{section.label}
</button>
)
})}
</div>
<WorkflowAuthoringSectionContent
activeSection={activeSection}
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
actions={insertBindings}
renderIcon={renderIcon}
nodesVariant="menu"
searchPlaceholder="Search nodes"
autoFocusSearch
/>
</div>
</div>
</div>
)
@@ -1,39 +1,195 @@
import type { ReactNode } from 'react'
import { ArrowRight } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import { WorkflowAuthoringSectionContent } from './WorkflowAuthoringSectionContent'
import { type WorkflowAuthoringActions } from './workflowAuthoringActions'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser'
import { useWorkflowAuthoringSurface } from './workflowAuthoringSurface'
interface NodeDefinitionsPanelProps {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
onSelectStep?: (step: string) => void
activeSteps?: string[]
actions?: WorkflowAuthoringActions
renderIcon?: (iconName?: string, size?: number) => ReactNode
}
export function NodeDefinitionsPanel({ definitions, graphFamily, onSelectStep, renderIcon }: NodeDefinitionsPanelProps) {
type AuthoringFlowStep = {
index: number
title: string
description: string
}
export function NodeDefinitionsPanel({
definitions,
graphFamily,
activeSteps = [],
actions = {},
renderIcon,
}: NodeDefinitionsPanelProps) {
const {
activeSection,
activeSectionMeta,
defaultSection,
insertBindings,
plan: authoringPlan,
sections,
setActiveSection,
} = useWorkflowAuthoringSurface({
definitions,
graphFamily,
activeSteps,
actions,
})
const authoringFlow: AuthoringFlowStep[] = authoringPlan.authoringFlow
const presentStepCount = activeSteps.length
return (
<div className="space-y-3">
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Node Library
</p>
<span className="text-[11px] text-content-muted">
{onSelectStep ? 'Click insert to add to canvas' : `${definitions.length} definitions`}
<div className="space-y-3 rounded-2xl border border-border-default bg-surface-hover/25 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Node Library
</p>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">Authoring Browser</p>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{definitions.length} definitions
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{presentStepCount} on canvas
</span>
</div>
<p className="mt-1 text-xs text-content-muted">
Start from reference paths or production modules, then drop to starter steps and raw nodes only when needed.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{insertBindings.onSelectStep ? 'Insert enabled' : 'Browse only'}
</span>
</div>
<p className="text-xs text-content-muted">
Browse by runtime family and module contract, then insert nodes directly from the sidebar.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{sections.map(section => {
const Icon = section.icon
const isActive = activeSection === section.key
return (
<button
key={section.key}
type="button"
onClick={() => setActiveSection(section.key)}
aria-label={section.label}
className={`rounded-2xl border px-3 py-3 text-left transition-colors ${
isActive
? 'border-accent/40 bg-accent-light'
: 'border-border-default bg-surface hover:bg-surface-hover'
}`}
title={section.helper}
>
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-2 text-sm font-medium text-content">
<Icon size={14} />
{section.label}
</span>
{isActive && <ArrowRight size={14} className="text-content-secondary" />}
</div>
<p className="mt-2 text-xs text-content-muted">{section.helper}</p>
</button>
)
})}
</div>
</div>
<WorkflowNodeCatalogBrowser
definitions={definitions}
graphFamily={graphFamily}
variant="panel"
onSelectStep={onSelectStep}
renderIcon={renderIcon}
/>
{activeSectionMeta && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Current Section
</p>
<p className="mt-1 text-xs text-content-muted">
{activeSectionMeta.label}: {activeSectionMeta.helper}
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Active
</span>
</div>
</div>
)}
{activeSection === defaultSection && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Authoring Flow
</p>
<p className="mt-1 text-xs text-content-muted">
Keep the legacy workflow safe by starting from graph-safe assemblies, then drilling down only when the module-level path is in place.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Guided
</span>
</div>
<div className="mt-3 grid gap-2 md:grid-cols-2">
{authoringFlow.map(step => (
<div
key={step.title}
className="rounded-xl border border-border-default bg-surface px-3 py-2"
>
<div className="flex items-center gap-2">
<span className="rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-semibold text-content-secondary">
{step.index}
</span>
<p className="text-sm font-medium text-content">{step.title}</p>
</div>
<p className="mt-1 text-xs text-content-muted">{step.description}</p>
</div>
))}
</div>
</div>
)}
{activeSection === 'nodes' ? (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Raw Node Catalog
</p>
<p className="mt-1 text-xs text-content-muted">
Advanced mode for inserting individual legacy, bridge, or graph nodes after the higher-level path is established.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Escape Hatch
</span>
</div>
<WorkflowAuthoringSectionContent
activeSection={activeSection}
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
actions={insertBindings}
renderIcon={renderIcon}
/>
</div>
) : (
<WorkflowAuthoringSectionContent
activeSection={activeSection}
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
actions={insertBindings}
renderIcon={renderIcon}
/>
)}
</div>
)
}
@@ -0,0 +1,242 @@
import { ArrowRight, Boxes, CheckCircle2, CircleDashed, Library, Milestone, Sparkles } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
type WorkflowAuthoringOverviewProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
onInsertModule?: (bundleId: WorkflowModuleBundleId) => void
onInsertReferencePath?: (bundleId: WorkflowReferenceBundleId) => void
onSelectStep?: (step: string) => void
}
export function WorkflowAuthoringOverview({
definitions,
graphFamily,
activeSteps,
onInsertModule,
onInsertReferencePath,
onSelectStep,
}: WorkflowAuthoringOverviewProps) {
const {
description,
gapFillDefinitions,
moduleBundles,
priorities,
referenceBundles,
stageProgress,
title,
} = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
const priorityIcons = [Milestone, Boxes, Library] as const
return (
<div className="space-y-3">
{graphFamily !== 'mixed' && stageProgress.length > 0 && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Stage Status
</p>
<p className="mt-1 text-xs text-content-muted">
Track the canonical authoring path stage by stage and fill only the missing parts.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Operational
</span>
</div>
<div className="mt-3 grid gap-2">
{stageProgress.map(stage => {
const isComplete = stage.present >= stage.total
const Icon = isComplete ? CheckCircle2 : CircleDashed
const progressLabel = `${stage.present}/${stage.total} present`
return (
<div
key={stage.id}
className="rounded-xl border border-border-default bg-surface px-3 py-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex items-center gap-1 text-sm font-medium ${isComplete ? 'text-emerald-700 dark:text-emerald-300' : 'text-content'}`}>
<Icon size={14} />
{stage.title}
</span>
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5 text-[11px] text-content-muted">
{progressLabel}
</span>
</div>
<p className="mt-1 text-xs text-content-muted">{stage.description}</p>
</div>
{stage.actionKind === 'reference' && stage.bundleId && onInsertReferencePath && (
<button
type="button"
onClick={() => onInsertReferencePath(stage.bundleId as WorkflowReferenceBundleId)}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Milestone size={12} />
{stage.actionLabel}
</button>
)}
{stage.actionKind === 'module' && stage.bundleId && onInsertModule && (
<button
type="button"
onClick={() => onInsertModule(stage.bundleId as WorkflowModuleBundleId)}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-semibold text-content transition-colors hover:bg-surface-hover"
>
<Boxes size={12} />
{stage.actionLabel}
</button>
)}
{stage.actionKind === 'step' && stage.step && onSelectStep && (
<button
type="button"
onClick={() => onSelectStep(stage.step as string)}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover"
>
<ArrowRight size={12} />
{stage.actionLabel}
</button>
)}
</div>
</div>
)
})}
</div>
</div>
)}
<div className="rounded-2xl border border-accent/20 bg-accent-light p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Sparkles size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Recommended Path
</p>
</div>
<p className="mt-1 text-sm font-semibold text-content">{title}</p>
<p className="mt-1 text-xs text-content-muted">{description}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{activeSteps.length} on canvas
</span>
</div>
<div className="mt-3 space-y-1.5">
{priorities.map((priority, index) => {
const Icon = priorityIcons[index] ?? Library
return (
<div
key={priority.title}
className="rounded-xl border border-border-default bg-surface px-3 py-2"
>
<div className="flex items-start gap-2">
<span className="mt-0.5 rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-semibold text-content-secondary">
{index + 1}
</span>
<div className="min-w-0">
<span className="inline-flex items-center gap-1 text-sm font-medium text-content">
<Icon size={13} />
{priority.title}
</span>
<p className="mt-0.5 text-xs text-content-muted">{priority.description}</p>
</div>
</div>
</div>
)
})}
</div>
</div>
{(referenceBundles.length > 0 || moduleBundles.length > 0) && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Quick Start
</p>
<p className="mt-1 text-xs text-content-muted">
Insert the recommended baseline first, then add stage bundles only where the path should diverge.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Guided
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{referenceBundles.map(bundle => (
<button
key={bundle.id}
type="button"
onClick={() => onInsertReferencePath?.(bundle.id)}
disabled={!onInsertReferencePath}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-60"
>
<Milestone size={12} />
Insert {bundle.shortLabel}
</button>
))}
{moduleBundles.slice(0, 2).map(bundle => (
<button
key={bundle.id}
type="button"
onClick={() => onInsertModule?.(bundle.id)}
disabled={!onInsertModule}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-semibold text-content transition-colors hover:bg-surface-hover disabled:cursor-not-allowed disabled:opacity-60"
>
<Boxes size={12} />
Insert {bundle.shortLabel}
</button>
))}
</div>
</div>
)}
{graphFamily !== 'mixed' && onSelectStep && (
<div className="rounded-2xl border border-border-default bg-surface-hover/20 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Gap Fill
</p>
<p className="mt-1 text-xs text-content-muted">
Use starter-safe inserts only for missing links in the recommended chain.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
Minimal
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{gapFillDefinitions.map(definition => (
<button
key={definition.step}
type="button"
onClick={() => onSelectStep(definition.step)}
className="inline-flex items-center gap-1 rounded-xl border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover"
>
<ArrowRight size={12} />
Add {definition.label}
</button>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,97 @@
import type { ReactNode } from 'react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import { WorkflowAuthoringOverview } from './WorkflowAuthoringOverview'
import { WorkflowModuleBundlePanel } from './WorkflowModuleBundlePanel'
import { WorkflowNodeCatalogBrowser } from './WorkflowNodeCatalogBrowser'
import { WorkflowReferenceBundlePanel } from './WorkflowReferenceBundlePanel'
import { WorkflowStarterPathPanel } from './WorkflowStarterPathPanel'
import type { WorkflowAuthoringInsertHandlers } from './workflowAuthoringActions'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import type { WorkflowAuthoringSection } from './workflowAuthoringSections'
type WorkflowAuthoringSectionContentProps = {
activeSection: WorkflowAuthoringSection
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
actions?: WorkflowAuthoringInsertHandlers
renderIcon?: (iconName?: string, size?: number) => ReactNode
nodesVariant?: 'panel' | 'menu'
searchPlaceholder?: string
autoFocusSearch?: boolean
}
export function WorkflowAuthoringSectionContent({
activeSection,
definitions,
graphFamily,
activeSteps,
actions,
renderIcon,
nodesVariant = 'panel',
searchPlaceholder,
autoFocusSearch = false,
}: WorkflowAuthoringSectionContentProps) {
if (activeSection === 'overview') {
return (
<WorkflowAuthoringOverview
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onInsertModule={actions?.onInsertModule}
onInsertReferencePath={actions?.onInsertReferencePath}
onSelectStep={actions?.onSelectStep}
/>
)
}
if (activeSection === 'paths') {
return (
<WorkflowReferenceBundlePanel
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onInsertReferencePath={actions?.onInsertReferencePath}
/>
)
}
if (activeSection === 'modules') {
return (
<WorkflowModuleBundlePanel
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onInsertModule={actions?.onInsertModule}
/>
)
}
if (activeSection === 'starter') {
return (
<WorkflowStarterPathPanel
definitions={definitions}
graphFamily={graphFamily}
activeSteps={activeSteps}
onSelectStep={actions?.onSelectStep}
/>
)
}
if (activeSection === 'nodes') {
return (
<WorkflowNodeCatalogBrowser
definitions={definitions}
graphFamily={graphFamily}
variant={nodesVariant}
onSelectStep={actions?.onSelectStep}
renderIcon={renderIcon}
searchPlaceholder={searchPlaceholder}
autoFocusSearch={autoFocusSearch}
/>
)
}
return null
}
@@ -1,210 +1,448 @@
import {
BadgeInfo,
GitBranch,
LayoutGrid,
Loader2,
MousePointer2,
Play,
Plus,
RefreshCw,
Save,
Trash2,
} from 'lucide-react'
import { type ReactNode } from 'react'
import { GitBranch, LayoutGrid, Loader2, MousePointer2, Play, RefreshCw, Save, Trash2 } from 'lucide-react'
import type { WorkflowRolloutLinkedOutputType } from '../../api/workflows'
import { getOutputTypeRolloutPresentation } from '../admin/outputTypeRolloutPresentation'
import type { WorkflowOrderLineContextGroup } from './useWorkflowCanvasController'
import type { WorkflowAuthoringActions, WorkflowAuthoringEntryAction } from './workflowAuthoringActions'
type WorkflowExecutionModeOption = {
value: string
label: string
}
function ToolbarBadge({
children,
className = '',
title,
}: {
children: ReactNode
className?: string
title?: string
}) {
return (
<span
className={`inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] font-medium ${className}`}
title={title}
>
{children}
</span>
)
}
function ToolbarField({
label,
children,
}: {
label: string
children: ReactNode
}) {
return (
<label className="flex min-w-0 items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1 text-[11px] text-content-secondary">
<span className="whitespace-nowrap font-medium">{label}</span>
{children}
</label>
)
}
function ToolbarActionButton({
children,
title,
disabled = false,
onClick,
tone = 'default',
}: {
children: ReactNode
title?: string
disabled?: boolean
onClick?: () => void
tone?: 'default' | 'primary'
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm font-medium disabled:opacity-50 ${
tone === 'primary'
? 'bg-accent text-white hover:bg-accent-hover'
: 'border border-border-default text-content hover:bg-surface-hover'
}`}
>
{children}
</button>
)
}
interface WorkflowCanvasToolbarProps {
workflowName: string
blueprintLabel?: string | null
blueprintDescription?: string | null
authoringFamilyLabel: string
authoringFamilyClassName: string
graphFamilyLabel: string
graphFamilyClassName: string
executionMode: string
executionModeLabel: string
executionModeClassName: string
executionModeHint: string
rolloutBadgeLabel: string
rolloutBadgeClassName: string
rolloutStatusLabel: string
rolloutStatusClassName: string
rolloutSummary: string
linkedOutputTypeCount: number
linkedOutputTypes: WorkflowRolloutLinkedOutputType[]
dispatchContextKind: 'order_line' | 'cad_file' | null
dispatchContextLabel: string
dispatchContextId: string
dispatchContextSummary?: string | null
dispatchContextMeta?: string | null
orderLineContextGroups: WorkflowOrderLineContextGroup[]
executionModes: WorkflowExecutionModeOption[]
selectedEdgeCount: number
canAutoLayout: boolean
canPreflight: boolean
canDispatch: boolean
hasValidationErrors: boolean
isPreflightPending: boolean
isDispatchPending: boolean
isContextOptionsLoading: boolean
isSaving: boolean
rollbackPendingOutputTypeId?: string | null
preflightState: 'ready' | 'required' | 'stale' | 'blocked'
authoringActions: WorkflowAuthoringActions
authoringEntryAction: WorkflowAuthoringEntryAction
onDispatchContextIdChange: (value: string) => void
onExecutionModeChange: (value: string) => void
onOpenNodeMenu: () => void
onAutoLayout: () => void
onDeleteSelectedEdges: () => void
onPreflight: () => void
onDispatch: () => void
onSave: () => void
onRollbackOutputType: (outputTypeId: string) => void
}
export function WorkflowCanvasToolbar({
workflowName,
blueprintLabel,
blueprintDescription,
authoringFamilyLabel,
authoringFamilyClassName,
graphFamilyLabel,
graphFamilyClassName,
executionMode,
executionModeLabel,
executionModeClassName,
executionModeHint,
rolloutBadgeLabel,
rolloutBadgeClassName,
rolloutStatusLabel,
rolloutStatusClassName,
rolloutSummary,
linkedOutputTypeCount,
linkedOutputTypes,
dispatchContextKind,
dispatchContextLabel,
dispatchContextId,
dispatchContextSummary,
dispatchContextMeta,
orderLineContextGroups,
executionModes,
selectedEdgeCount,
canAutoLayout,
canPreflight,
canDispatch,
hasValidationErrors,
isPreflightPending,
isDispatchPending,
isContextOptionsLoading,
isSaving,
rollbackPendingOutputTypeId,
preflightState,
authoringActions,
authoringEntryAction,
onDispatchContextIdChange,
onExecutionModeChange,
onOpenNodeMenu,
onAutoLayout,
onDeleteSelectedEdges,
onPreflight,
onDispatch,
onSave,
onRollbackOutputType,
}: WorkflowCanvasToolbarProps) {
const AuthoringEntryIcon = authoringEntryAction.icon
const preflightBadgeClassName = {
ready: 'border-emerald-200 bg-emerald-50 text-emerald-700',
required: 'border-slate-200 bg-slate-100 text-slate-600',
stale: 'border-amber-200 bg-amber-50 text-amber-700',
blocked: 'border-red-200 bg-red-50 text-red-700',
}[preflightState]
const preflightBadgeLabel = {
ready: 'Preflight ready',
required: 'Preflight required',
stale: 'Preflight stale',
blocked: 'Preflight blocked',
}[preflightState]
const showSplitFamilyBadges = authoringFamilyLabel !== graphFamilyLabel || authoringFamilyClassName !== graphFamilyClassName
const selectedEdgeLabel = selectedEdgeCount > 1 ? `Delete (${selectedEdgeCount})` : 'Delete'
const hasRolloutControls = linkedOutputTypes.length > 0
return (
<div className="border-b border-border-default bg-surface px-3 py-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<div className="flex items-center gap-2 rounded-full border border-border-default bg-surface-hover/60 px-2 py-0.5 text-[11px] font-medium text-content-secondary">
<GitBranch size={13} />
Workflow Canvas
</div>
<h1 className="truncate text-sm font-semibold text-content">{workflowName}</h1>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${graphFamilyClassName}`}>
{graphFamilyLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${executionModeClassName}`}>
{executionModeLabel}
</span>
{blueprintLabel && (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
{blueprintLabel}
<div className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 flex flex-1 flex-col gap-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<ToolbarBadge className="bg-surface-hover/60 text-content-secondary">
<GitBranch size={13} />
Workflow Canvas
</ToolbarBadge>
<h1 className="max-w-[20rem] truncate text-sm font-semibold text-content">{workflowName}</h1>
{blueprintLabel && (
<ToolbarBadge className="border-transparent bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300">
{blueprintLabel}
</ToolbarBadge>
)}
{showSplitFamilyBadges ? (
<>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${authoringFamilyClassName}`}>
Authoring: {authoringFamilyLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${graphFamilyClassName}`}>
Graph: {graphFamilyLabel}
</span>
</>
) : (
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${authoringFamilyClassName}`}>
Family: {authoringFamilyLabel}
</span>
)}
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${executionModeClassName}`}>
{executionModeLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutBadgeClassName}`}>
{rolloutBadgeLabel}
</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutStatusClassName}`}>
{rolloutStatusLabel}
</span>
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${preflightBadgeClassName}`}>
{preflightBadgeLabel}
</span>
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-content-muted">
<p className="text-xs text-content-muted">
{linkedOutputTypeCount} linked output type{linkedOutputTypeCount === 1 ? '' : 's'} · {rolloutSummary}
</p>
<span className="hidden text-content-muted lg:inline">|</span>
<span className="text-xs text-content-muted">{executionModeHint}</span>
</div>
{blueprintDescription && <p className="text-[11px] text-content-muted">{blueprintDescription}</p>}
</div>
<div className="flex flex-wrap items-center gap-1.5 self-start">
<ToolbarActionButton
onClick={authoringActions.openNodeMenu}
disabled={!authoringActions.openNodeMenu}
title={authoringEntryAction.title}
>
<AuthoringEntryIcon size={14} />
{authoringEntryAction.label}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onAutoLayout}
disabled={!canAutoLayout}
title="Automatically align nodes into a readable graph layout"
>
<LayoutGrid size={14} />
Align
</ToolbarActionButton>
<ToolbarActionButton
onClick={onDeleteSelectedEdges}
disabled={selectedEdgeCount === 0}
title="Delete the currently selected connection(s)"
>
<Trash2 size={14} />
{selectedEdgeLabel}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onPreflight}
disabled={!canPreflight || isPreflightPending || hasValidationErrors}
title="Validate graph runtime readiness without dispatching tasks"
>
{isPreflightPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isPreflightPending ? 'Checking…' : 'Dry Run'}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onDispatch}
disabled={!canDispatch || isDispatchPending || hasValidationErrors}
title="Manual graph runtime dispatch for workflow debugging"
>
{isDispatchPending ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{isDispatchPending ? 'Dispatching…' : 'Run'}
</ToolbarActionButton>
<ToolbarActionButton
onClick={onSave}
disabled={isSaving || hasValidationErrors}
tone="primary"
>
<Save size={14} />
{isSaving ? 'Saving…' : 'Save'}
</ToolbarActionButton>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border-default/70 pt-1.5">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{dispatchContextKind === 'order_line' ? (
<ToolbarField label={dispatchContextLabel}>
<select
value={dispatchContextId}
onChange={event => onDispatchContextIdChange(event.target.value)}
className="max-w-[18rem] bg-transparent text-sm text-content focus:outline-none"
aria-label="Order line context"
disabled={isContextOptionsLoading || orderLineContextGroups.length === 0}
>
{orderLineContextGroups.length === 0 ? (
<option value="">
{isContextOptionsLoading ? 'Loading order lines…' : 'No order lines available'}
</option>
) : (
orderLineContextGroups.map(group => (
<optgroup key={group.orderId} label={group.orderLabel}>
{group.options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</optgroup>
))
)}
</select>
</ToolbarField>
) : (
<ToolbarField label={dispatchContextLabel}>
<input
value={dispatchContextId}
onChange={event => onDispatchContextIdChange(event.target.value)}
placeholder={dispatchContextKind === 'cad_file' ? 'cad file uuid' : 'context id'}
className="w-32 bg-transparent text-sm text-content focus:outline-none lg:w-40"
/>
</ToolbarField>
)}
<ToolbarField label="Mode">
<select
value={executionMode}
onChange={event => onExecutionModeChange(event.target.value)}
className="bg-transparent text-sm text-content focus:outline-none"
aria-label="Mode"
title={executionModeHint}
>
{executionModes.map(mode => (
<option key={mode.value} value={mode.value}>
{mode.label}
</option>
))}
</select>
</ToolbarField>
{dispatchContextSummary && (
<ToolbarBadge
className="max-w-[28rem] bg-surface-hover/60 text-content-secondary"
title={dispatchContextMeta ? `${dispatchContextSummary} · ${dispatchContextMeta}` : dispatchContextSummary}
>
<span className="font-medium text-content">{dispatchContextLabel}</span>
<span className="truncate">{dispatchContextSummary}</span>
{dispatchContextMeta && <span className="truncate text-content-muted">· {dispatchContextMeta}</span>}
</ToolbarBadge>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
{(blueprintDescription || executionModeHint) && (
<span className="inline-flex max-w-3xl items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5">
<BadgeInfo size={11} />
{blueprintDescription ?? executionModeHint}
</span>
)}
<span
className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5"
title="Right-click anywhere on the canvas to open the searchable node picker."
<div className="flex flex-wrap items-center gap-1.5 text-[11px] text-content-muted">
<ToolbarBadge
className="text-content-muted"
title={blueprintDescription ?? 'Right-click anywhere on the canvas to open the searchable node picker.'}
>
<MousePointer2 size={11} />
Right-click to add
</span>
<span
className="inline-flex items-center gap-1 rounded-full border border-border-default bg-surface px-2 py-0.5"
</ToolbarBadge>
<ToolbarBadge
className="text-content-muted"
title="Select an edge and press Delete, or use right-click / double-click to remove it."
>
<Trash2 size={11} />
Delete removes connections
</span>
</ToolbarBadge>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={onOpenNodeMenu}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover"
title="Open searchable node picker"
>
<Plus size={14} />
Node
</button>
<button
type="button"
onClick={onAutoLayout}
disabled={!canAutoLayout}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
title="Automatically align nodes into a readable graph layout"
>
<LayoutGrid size={14} />
Align
</button>
<button
type="button"
onClick={onDeleteSelectedEdges}
disabled={selectedEdgeCount === 0}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
title="Delete the currently selected connection(s)"
>
<Trash2 size={14} />
{selectedEdgeCount > 1 ? `Delete (${selectedEdgeCount})` : 'Delete'}
</button>
</div>
</div>
{hasRolloutControls && (
<details className="rounded-xl border border-border-default bg-surface-hover/40">
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-content-muted">
Rollout Controls
</span>
<span className="text-xs text-content-muted">
Linked output types can be forced back to legacy directly from the workflow editor.
</span>
</div>
<ToolbarBadge className="text-content-muted">
{linkedOutputTypes.length} output type{linkedOutputTypes.length === 1 ? '' : 's'}
</ToolbarBadge>
</summary>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 border-t border-border-default/70 pt-2">
<div className="flex flex-wrap items-center gap-2">
<label className="flex items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1.5 text-xs text-content-secondary">
<span className="whitespace-nowrap">Context</span>
<input
value={dispatchContextId}
onChange={event => onDispatchContextIdChange(event.target.value)}
placeholder="context id"
className="w-40 bg-transparent text-sm text-content focus:outline-none lg:w-52"
/>
</label>
<label className="flex items-center gap-2 rounded-lg border border-border-default bg-surface px-2 py-1.5 text-xs text-content-secondary">
<span className="whitespace-nowrap">Mode</span>
<select
value={executionMode}
onChange={event => onExecutionModeChange(event.target.value)}
className="bg-transparent text-sm text-content focus:outline-none"
>
{executionModes.map(mode => (
<option key={mode.value} value={mode.value}>
{mode.label}
</option>
))}
</select>
</label>
<button
onClick={onPreflight}
disabled={isPreflightPending || hasValidationErrors}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
title="Validate graph runtime readiness without dispatching tasks"
>
{isPreflightPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isPreflightPending ? 'Checking…' : 'Dry Run'}
</button>
<button
onClick={onDispatch}
disabled={isDispatchPending || hasValidationErrors}
className="flex items-center gap-1.5 rounded-lg border border-border-default px-2.5 py-1.5 text-sm text-content hover:bg-surface-hover disabled:opacity-50"
title="Manual graph runtime dispatch for workflow debugging"
>
{isDispatchPending ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
{isDispatchPending ? 'Dispatching…' : 'Run'}
</button>
<button
onClick={onSave}
disabled={isSaving || hasValidationErrors}
className="flex items-center gap-1.5 rounded-lg bg-accent px-2.5 py-1.5 text-sm text-white hover:bg-accent-hover disabled:opacity-50"
>
<Save size={14} />
{isSaving ? 'Saving…' : 'Save'}
</button>
</div>
<p className="text-[11px] text-content-muted">
{executionModeHint}
</p>
<div className="flex flex-col gap-2 border-t border-border-default px-3 py-2">
{linkedOutputTypes.map(outputType => {
const rolloutPresentation = getOutputTypeRolloutPresentation({
hasWorkflowLink: true,
workflowRolloutMode: outputType.workflow_rollout_mode,
})
const isRollbackPending = rollbackPendingOutputTypeId === outputType.id
const isAlreadyLegacy = outputType.workflow_rollout_mode === 'legacy_only'
return (
<div
key={outputType.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border-default bg-surface px-2.5 py-2"
>
<div className="min-w-0 flex flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="truncate text-sm font-medium text-content">{outputType.name}</span>
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${rolloutPresentation.badgeClassName}`}>
{rolloutPresentation.badgeLabel}
</span>
<span className="rounded-full border border-border-default px-2 py-0.5 text-[11px] font-medium text-content-secondary">
{outputType.artifact_kind}
</span>
{!outputType.is_active && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-700">
Inactive
</span>
)}
</div>
<p className="text-xs text-content-muted">{rolloutPresentation.rowSummary}</p>
</div>
<ToolbarActionButton
onClick={() => onRollbackOutputType(outputType.id)}
disabled={isRollbackPending || isAlreadyLegacy}
title={
isAlreadyLegacy
? `${outputType.name} is already legacy-authoritative.`
: `Set ${outputType.name} rollout mode back to legacy_only.`
}
>
{isRollbackPending ? <Loader2 size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isRollbackPending ? 'Reverting…' : 'Set Legacy'}
</ToolbarActionButton>
</div>
)
})}
</div>
</details>
)}
</div>
</div>
)
@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { Activity, Library, ShieldCheck, SlidersHorizontal } from 'lucide-react'
import { useMemo, type ReactNode } from 'react'
import { Activity, Library, ShieldCheck, SlidersHorizontal, type LucideIcon } from 'lucide-react'
import type {
WorkflowNodeDefinition,
WorkflowParams,
@@ -12,8 +12,10 @@ import { WorkflowNodeInspector } from './WorkflowNodeInspector'
import { WorkflowPreflightPanel } from './WorkflowPreflightPanel'
import { WorkflowRunsPanel } from './WorkflowRunsPanel'
import { WorkflowUtilityRail } from './WorkflowUtilityRail'
import { getWorkflowAuthoringEntryAction, type WorkflowAuthoringActions } from './workflowAuthoringActions'
import type { WorkflowCanvasNodeData } from './workflowGraphDraft'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringSurfaceModel } from './workflowAuthoringSurface'
export type WorkflowUtilityTab = 'inspector' | 'library' | 'runs' | 'preflight'
@@ -28,7 +30,8 @@ type WorkflowCanvasUtilitySidebarProps = {
nodeDefinitions: WorkflowNodeDefinition[]
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>
graphFamily: WorkflowGraphFamily
onInsertNode: (step: string) => void
activeSteps: string[]
authoringActions: WorkflowAuthoringActions
renderNodeIcon: (iconName?: string, size?: number) => ReactNode
workflowRuns: WorkflowRun[]
selectedRunId: string | null
@@ -48,7 +51,8 @@ export function WorkflowCanvasUtilitySidebar({
nodeDefinitions,
nodeDefinitionsByStep,
graphFamily,
onInsertNode,
activeSteps,
authoringActions,
renderNodeIcon,
workflowRuns,
selectedRunId,
@@ -58,12 +62,20 @@ export function WorkflowCanvasUtilitySidebar({
preflightResult,
isPreflightPending,
}: WorkflowCanvasUtilitySidebarProps) {
const surfaceModel = useMemo(
() => getWorkflowAuthoringSurfaceModel({ definitions: nodeDefinitions, graphFamily, activeSteps }),
[activeSteps, graphFamily, nodeDefinitions],
)
const authoringEntryAction = getWorkflowAuthoringEntryAction(surfaceModel)
const authoringTabLabel = authoringEntryAction.label === 'Author' ? 'Authoring' : 'Library'
const AuthoringTabIcon = authoringEntryAction.icon
if (nodeDefinitions.length === 0) return null
const utilityTabs: {
key: WorkflowUtilityTab
label: string
icon: typeof SlidersHorizontal
icon: LucideIcon
count?: number | null
disabled?: boolean
}[] = [
@@ -75,8 +87,8 @@ export function WorkflowCanvasUtilitySidebar({
},
{
key: 'library',
label: 'Library',
icon: Library,
label: authoringTabLabel,
icon: AuthoringTabIcon,
count: nodeDefinitions.length,
},
{
@@ -124,7 +136,8 @@ export function WorkflowCanvasUtilitySidebar({
<NodeDefinitionsPanel
definitions={nodeDefinitions}
graphFamily={graphFamily}
onSelectStep={onInsertNode}
activeSteps={activeSteps}
actions={authoringActions}
renderIcon={renderNodeIcon}
/>
)}
@@ -10,6 +10,12 @@ interface WorkflowListItem {
familyClassName: string
executionModeLabel: string
executionModeClassName: string
rolloutBadgeLabel: string
rolloutBadgeClassName: string
rolloutStatusLabel: string
rolloutStatusClassName: string
rolloutSummary: string
linkedOutputTypeCount: number
blueprintLabel?: string | null
isReference?: boolean
}
@@ -133,6 +139,18 @@ export function WorkflowListSidebar({
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.executionModeClassName}`}>
{item.executionModeLabel}
</span>
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.rolloutBadgeClassName}`}>
{item.rolloutBadgeLabel}
</span>
<span className={`ml-1 mt-1 inline-block rounded-full px-1.5 py-0.5 text-xs font-medium ${item.rolloutStatusClassName}`}>
{item.rolloutStatusLabel}
</span>
<p className="mt-1 text-xs text-content-muted">
{item.linkedOutputTypeCount} linked output type{item.linkedOutputTypeCount === 1 ? '' : 's'}.
</p>
<p className="mt-1 text-xs text-content-muted">
{item.rolloutSummary}
</p>
{item.isReference && (
<p className="mt-1 text-xs text-content-muted">
Canonical reference workflow for parity work.
@@ -0,0 +1,83 @@
import { Boxes, Wand2 } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
type WorkflowModuleBundlePanelProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
onInsertModule?: (bundleId: WorkflowModuleBundleId) => void
}
export function WorkflowModuleBundlePanel({
definitions,
graphFamily,
activeSteps,
onInsertModule,
}: WorkflowModuleBundlePanelProps) {
const { moduleBundles } = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
if (moduleBundles.length === 0) return null
return (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Boxes size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Production Modules
</p>
</div>
<p className="mt-1 text-xs text-content-muted">
Insert reusable subgraphs for core production stages instead of assembling every node from scratch.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{moduleBundles.length} bundles
</span>
</div>
<div className="space-y-2">
{moduleBundles.map(bundle => (
<div
key={bundle.id}
className="rounded-2xl border border-border-default bg-surface px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">{bundle.label}</p>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-content-secondary">
{bundle.stage}
</span>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] text-content-muted">
{bundle.presentCount}/{bundle.totalCount} present
</span>
</div>
<p className="text-xs text-content-muted">{bundle.description}</p>
<p className="text-[11px] text-content-muted">
{bundle.stepIds.join(' -> ')}
</p>
</div>
{onInsertModule ? (
<button
type="button"
onClick={() => onInsertModule(bundle.id)}
aria-label={`Insert ${bundle.label}`}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Wand2 size={12} />
Insert
</button>
) : null}
</div>
</div>
))}
</div>
</div>
)
}
@@ -1,18 +1,22 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { ArrowRight, Plus, Search } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { StepCategory, WorkflowNodeDefinition } from '../../api/workflows'
import {
AUTHORING_STAGE_DESCRIPTIONS,
AUTHORING_STAGE_LABELS,
AUTHORING_STAGE_STYLES,
CATEGORY_COLORS,
CATEGORY_LABELS,
FAMILY_FILTER_DESCRIPTIONS,
FAMILY_FILTER_LABELS,
FAMILY_FILTER_STYLES,
NODE_KIND_FILTER_LABELS,
NODE_LIBRARY_GROUP_DESCRIPTIONS,
NODE_LIBRARY_GROUP_LABELS,
NODE_LIBRARY_GROUP_STYLES,
getDefinitionBadges,
getDefinitionFamily,
getDefinitionModuleLabel,
getDefinitionModuleNamespace,
type WorkflowGraphFamily,
type WorkflowNodeFamilyFilter,
@@ -20,10 +24,11 @@ import {
type WorkflowNodeLibraryGroup,
} from './workflowNodeLibrary'
import {
buildWorkflowNodeCatalog,
buildWorkflowNodeCatalogModel,
filterWorkflowNodeDefinitions,
getAvailableFamilyFilters,
} from './workflowNodeCatalog'
import { STARTER_NODE_STEP_ORDER, STARTER_PATH_TITLES } from './workflowAuthoringGuidance'
type WorkflowNodeCatalogBrowserProps = {
definitions: WorkflowNodeDefinition[]
@@ -37,10 +42,21 @@ type WorkflowNodeCatalogBrowserProps = {
autoFocusSearch?: boolean
}
type WorkflowNodeCatalogModuleFilter = {
namespace: string
type WorkflowNodeCategoryFilter = 'all' | StepCategory
const CATEGORY_FILTERS: WorkflowNodeCategoryFilter[] = ['all', 'input', 'processing', 'rendering', 'output']
const CATEGORY_FILTER_LABELS: Record<WorkflowNodeCategoryFilter, string> = {
all: 'All Categories',
input: 'Input',
processing: 'Processing',
rendering: 'Rendering',
output: 'Output',
}
type FilterPillOption<T extends string> = {
value: T
label: string
count: number
}
function readContractList(contract: Record<string, unknown>, key: string) {
@@ -60,6 +76,42 @@ function formatContractLabel(value: string) {
.join(' ')
}
function FilterPillGroup<T extends string>({
title,
options,
activeValue,
onChange,
}: {
title: string
options: FilterPillOption<T>[]
activeValue: T
onChange: (value: T) => void
}) {
return (
<div className="space-y-1">
<p className="text-[11px] font-semibold uppercase tracking-wide text-content-secondary">
{title}
</p>
<div className="flex flex-wrap gap-2">
{options.map(option => (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
activeValue === option.value
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)
}
export function WorkflowNodeCatalogBrowser({
definitions,
graphFamily,
@@ -76,11 +128,15 @@ export function WorkflowNodeCatalogBrowser({
graphFamily === 'mixed' ? 'all' : graphFamily,
)
const [kindFilter, setKindFilter] = useState<WorkflowNodeKindFilter>('all')
const [categoryFilter, setCategoryFilter] = useState<WorkflowNodeCategoryFilter>('all')
const [moduleFilter, setModuleFilter] = useState<string>('all')
const [moduleQuery, setModuleQuery] = useState('')
useEffect(() => {
setFamilyFilter(graphFamily === 'mixed' ? 'all' : graphFamily)
setCategoryFilter('all')
setModuleFilter('all')
setModuleQuery('')
}, [graphFamily])
const availableFamilyFilters = useMemo(
@@ -97,25 +153,32 @@ export function WorkflowNodeCatalogBrowser({
})
}, [definitions, familyFilter, graphFamily, kindFilter, query])
const moduleFilters = useMemo<WorkflowNodeCatalogModuleFilter[]>(() => {
const modules = new Map<string, WorkflowNodeCatalogModuleFilter>()
const normalizedModuleQuery = moduleQuery.trim().toLowerCase()
for (const definition of filteredDefinitions) {
const namespace = getDefinitionModuleNamespace(definition)
const current = modules.get(namespace)
if (current) {
current.count += 1
continue
}
modules.set(namespace, {
namespace,
label: definition.module_key,
count: 1,
})
}
const visibleDefinitions = useMemo(() => {
const scopedByModule =
moduleFilter === 'all'
? filteredDefinitions
: filteredDefinitions.filter(definition => getDefinitionModuleNamespace(definition) === moduleFilter)
return Array.from(modules.values()).sort((a, b) => a.label.localeCompare(b.label))
}, [filteredDefinitions])
const scopedByModuleQuery = !normalizedModuleQuery
? scopedByModule
: scopedByModule.filter(definition => {
const namespace = getDefinitionModuleNamespace(definition).toLowerCase()
return (
namespace.includes(normalizedModuleQuery) ||
getDefinitionModuleLabel(definition).toLowerCase().includes(normalizedModuleQuery) ||
definition.module_key.toLowerCase().includes(normalizedModuleQuery) ||
definition.label.toLowerCase().includes(normalizedModuleQuery)
)
})
if (categoryFilter === 'all') return scopedByModuleQuery
return scopedByModuleQuery.filter(definition => definition.category === categoryFilter)
}, [categoryFilter, filteredDefinitions, moduleFilter, normalizedModuleQuery])
const catalogModel = useMemo(() => buildWorkflowNodeCatalogModel(visibleDefinitions), [visibleDefinitions])
const moduleFilters = catalogModel.moduleFilters
useEffect(() => {
if (moduleFilter !== 'all' && !moduleFilters.some(module => module.namespace === moduleFilter)) {
@@ -123,14 +186,36 @@ export function WorkflowNodeCatalogBrowser({
}
}, [moduleFilter, moduleFilters])
const visibleDefinitions = useMemo(() => {
if (moduleFilter === 'all') return filteredDefinitions
return filteredDefinitions.filter(definition => getDefinitionModuleNamespace(definition) === moduleFilter)
}, [filteredDefinitions, moduleFilter])
const catalogSections = useMemo(() => buildWorkflowNodeCatalog(visibleDefinitions), [visibleDefinitions])
const familySections = catalogModel.familySections
const firstVisibleDefinition = visibleDefinitions[0]
const totalModuleCount = moduleFilters.length
const quickInsertDefinitions = useMemo(() => {
const prioritizedSteps = STARTER_NODE_STEP_ORDER[graphFamily]
if (prioritizedSteps.length === 0) return visibleDefinitions.slice(0, 4)
const byStep = new Map(visibleDefinitions.map(definition => [definition.step, definition]))
return prioritizedSteps
.map(step => byStep.get(step))
.filter((definition): definition is WorkflowNodeDefinition => Boolean(definition))
.slice(0, variant === 'menu' ? 5 : 6)
}, [graphFamily, variant, visibleDefinitions])
const quickInsertTitle = graphFamily === 'mixed' ? 'Suggested nodes' : STARTER_PATH_TITLES[graphFamily]
const runtimeFilterOptions: FilterPillOption<WorkflowNodeKindFilter>[] = [
{ value: 'all', label: NODE_KIND_FILTER_LABELS.all },
{ value: 'legacy', label: NODE_KIND_FILTER_LABELS.legacy },
{ value: 'bridge', label: NODE_KIND_FILTER_LABELS.bridge },
{ value: 'graph', label: NODE_KIND_FILTER_LABELS.graph },
]
const familyFilterOptions = availableFamilyFilters.map(filter => ({
value: filter,
label: FAMILY_FILTER_LABELS[filter],
}))
const categoryFilterOptions: FilterPillOption<WorkflowNodeCategoryFilter>[] = CATEGORY_FILTERS.map(
filter => ({
value: filter,
label: CATEGORY_FILTER_LABELS[filter],
}),
)
return (
<div className="space-y-3">
@@ -149,10 +234,13 @@ export function WorkflowNodeCatalogBrowser({
</span>
)}
</div>
{moduleFilter !== 'all' && (
{(moduleFilter !== 'all' || moduleQuery) && (
<button
type="button"
onClick={() => setModuleFilter('all')}
onClick={() => {
setModuleFilter('all')
setModuleQuery('')
}}
className="text-[11px] font-medium text-accent hover:text-accent-hover"
>
Show all modules
@@ -178,39 +266,26 @@ export function WorkflowNodeCatalogBrowser({
</div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{(['all', 'legacy', 'bridge', 'graph'] as WorkflowNodeKindFilter[]).map(filter => (
<button
key={filter}
type="button"
onClick={() => setKindFilter(filter)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
kindFilter === filter
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{NODE_KIND_FILTER_LABELS[filter]}
</button>
))}
</div>
<FilterPillGroup
title="Runtime"
options={runtimeFilterOptions}
activeValue={kindFilter}
onChange={setKindFilter}
/>
<div className="flex flex-wrap gap-2">
{availableFamilyFilters.map(filter => (
<button
key={filter}
type="button"
onClick={() => setFamilyFilter(filter)}
className={`rounded-full px-2.5 py-1 text-[11px] font-medium transition-colors ${
familyFilter === filter
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
>
{FAMILY_FILTER_LABELS[filter]}
</button>
))}
</div>
<FilterPillGroup
title="Family"
options={familyFilterOptions}
activeValue={familyFilter}
onChange={setFamilyFilter}
/>
<FilterPillGroup
title="Category"
options={categoryFilterOptions}
activeValue={categoryFilter}
onChange={setCategoryFilter}
/>
{moduleFilters.length > 0 && (
<div className="space-y-1">
@@ -222,6 +297,15 @@ export function WorkflowNodeCatalogBrowser({
family + runtime scoped
</span>
</div>
<div className="relative">
<Search size={12} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-muted" />
<input
value={moduleQuery}
onChange={event => setModuleQuery(event.target.value)}
placeholder="Search modules"
className="w-full rounded-xl border border-border-default bg-surface px-8 py-2 text-xs text-content focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
@@ -244,9 +328,9 @@ export function WorkflowNodeCatalogBrowser({
? 'bg-accent text-white'
: 'border border-border-default bg-surface text-content-secondary hover:bg-surface-hover'
}`}
title={module.label}
title={`${module.label} · ${module.stages.map(stage => AUTHORING_STAGE_LABELS[stage]).join(' / ')}`}
>
{module.namespace}
{module.label}
<span className="ml-1 opacity-70">{module.count}</span>
</button>
))}
@@ -258,21 +342,51 @@ export function WorkflowNodeCatalogBrowser({
<div className="flex flex-wrap gap-2">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = catalogSections.find(section => section.group === group)?.definitions.length ?? 0
const count = catalogModel.runtimeCounts[group] ?? 0
if (count === 0) return null
return (
<span
key={group}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
title={NODE_LIBRARY_GROUP_DESCRIPTIONS[group]}
title={NODE_LIBRARY_GROUP_LABELS[group]}
>
<span>{NODE_KIND_FILTER_LABELS[group]}</span>
<span>{NODE_LIBRARY_GROUP_LABELS[group]}</span>
<span>{count}</span>
</span>
)
})}
</div>
{quickInsertDefinitions.length > 0 && (
<div className="rounded-xl border border-border-default bg-surface-hover/35 p-3">
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Quick Insert
</p>
<p className="mt-1 text-xs text-content-muted">{quickInsertTitle}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{quickInsertDefinitions.length} picks
</span>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{quickInsertDefinitions.map(definition => (
<button
key={`quick-${definition.step}`}
type="button"
onClick={() => onSelectStep?.(definition.step)}
disabled={!onSelectStep}
className="rounded-full border border-border-default bg-surface px-3 py-1.5 text-xs font-medium text-content transition-colors hover:bg-surface-hover disabled:cursor-default disabled:opacity-60"
title={definition.description}
>
{definition.label}
</button>
))}
</div>
</div>
)}
{visibleDefinitions.length === 0 && (
<div className="rounded-2xl border border-dashed border-border-default bg-surface-hover/40 px-4 py-8 text-center">
<p className="text-sm font-medium text-content">No matching nodes</p>
@@ -292,172 +406,258 @@ export function WorkflowNodeCatalogBrowser({
)}
<div className="space-y-3">
{catalogSections.map(section => {
const group = section.group as WorkflowNodeLibraryGroup
{familySections.map(familySection => {
const familyLabel = FAMILY_FILTER_LABELS[familySection.family]
const familyDescription = FAMILY_FILTER_DESCRIPTIONS[familySection.family]
const familyStyle = FAMILY_FILTER_STYLES[familySection.family]
return (
<div key={group} className="rounded-lg border border-border-default bg-surface-hover/40 p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}>
{NODE_LIBRARY_GROUP_LABELS[group]}
</span>
<span className="text-xs text-content-muted">{section.definitions.length}</span>
<div
key={familySection.family}
className="rounded-xl border border-border-default bg-surface-hover/35 p-3"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${familyStyle}`}>
{familyLabel}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{familySection.modules.length} modules
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{familySection.definitions.length} nodes
</span>
</div>
<p className="text-xs text-content-muted">{familyDescription}</p>
</div>
<div className="flex flex-wrap gap-2">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = familySection.runtimeCounts[group]
if (count === 0) return null
return (
<span
key={`${familySection.family}-${group}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
>
{NODE_KIND_FILTER_LABELS[group]} {count}
</span>
)
})}
</div>
</div>
<p className="mb-2 text-xs text-content-muted">{NODE_LIBRARY_GROUP_DESCRIPTIONS[group]}</p>
<div className="space-y-2">
{section.modules.map(moduleGroup => (
<div className="mt-3 space-y-3">
{familySection.modules.map(moduleGroup => (
<div
key={`${group}:${moduleGroup.namespace}`}
className="rounded-md border border-border-default bg-surface/70 px-2 py-2"
key={`${familySection.family}:${moduleGroup.namespace}`}
className="rounded-lg border border-border-default bg-surface/80 p-3"
>
<div className="flex items-center justify-between gap-2 py-1 text-xs text-content-secondary">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="rounded-full border border-border-default bg-surface px-1.5 py-0.5 font-medium">
{moduleGroup.label}
</span>
<span className="truncate rounded-full border border-border-default bg-surface px-1.5 py-0.5 font-mono text-[10px]">
{moduleGroup.namespace}
</span>
{moduleGroup.familyCounts.cad_file > 0 && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES.cad_file}`}>
{FAMILY_FILTER_LABELS.cad_file}
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-xs font-medium text-content">
{moduleGroup.label}
</span>
)}
{moduleGroup.familyCounts.order_line > 0 && (
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES.order_line}`}>
{FAMILY_FILTER_LABELS.order_line}
<span className="truncate rounded-full border border-border-default bg-surface px-2 py-0.5 font-mono text-[10px] text-content-muted">
{moduleGroup.namespace}
</span>
)}
{moduleGroup.stages.map(stage => (
<span
key={`${moduleGroup.namespace}-${stage}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${AUTHORING_STAGE_STYLES[stage]}`}
title={AUTHORING_STAGE_DESCRIPTIONS[stage]}
>
{AUTHORING_STAGE_LABELS[stage]}
</span>
))}
</div>
<div className="flex flex-wrap gap-1.5">
{(['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[]).map(group => {
const count = moduleGroup.runtimeCounts[group]
if (count === 0) return null
return (
<span
key={`${moduleGroup.namespace}-${group}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${NODE_LIBRARY_GROUP_STYLES[group]}`}
>
{NODE_KIND_FILTER_LABELS[group]} {count}
</span>
)
})}
</div>
</div>
<span className="text-content-muted">{moduleGroup.definitions.length}</span>
<span className="text-xs text-content-muted">{moduleGroup.definitions.length}</span>
</div>
<div className="mt-1 space-y-2">
{moduleGroup.categories.map(categorySection => {
const { category, definitions: categoryDefinitions } = categorySection
<div className="mt-3 space-y-3">
{moduleGroup.stageSections.map(stageSection => {
const stageLabel = AUTHORING_STAGE_LABELS[stageSection.stage]
const stageDescription = AUTHORING_STAGE_DESCRIPTIONS[stageSection.stage]
const stageStyle = AUTHORING_STAGE_STYLES[stageSection.stage]
return (
<div key={`${group}:${moduleGroup.namespace}:${category}`}>
<div className="mb-1 flex items-center justify-between gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category]}`}>
{CATEGORY_LABELS[category]}
<div
key={`${familySection.family}:${moduleGroup.namespace}:${stageSection.stage}`}
className="rounded-md border border-border-default bg-surface-hover/35 p-2"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${stageStyle}`}>
{stageLabel}
</span>
<p className="text-xs text-content-muted">{stageDescription}</p>
</div>
<span className="text-[10px] text-content-muted">
{stageSection.definitions.length}
</span>
<span className="text-[10px] text-content-muted">{categoryDefinitions.length}</span>
</div>
<div className="space-y-1">
{categoryDefinitions.map(definition => {
const family = getDefinitionFamily(definition)
const requiredInputs = readContractList(definition.input_contract, 'requires')
const providedOutputs = readContractList(definition.output_contract, 'provides')
const inputContext = readContractContext(definition.input_contract)
const outputContext = readContractContext(definition.output_contract)
const isActionable = Boolean(onSelectStep)
<div className="space-y-2">
{stageSection.categories.map(categorySection => {
const { category, definitions: categoryDefinitions } = categorySection
return (
<div
key={definition.step}
className={`rounded-lg border border-border-default bg-surface px-3 py-2 ${
isActionable ? 'transition-colors hover:bg-surface-hover' : ''
}`}
title={definition.description}
>
<div className="flex items-start gap-2">
{renderIcon && (
<span className="mt-0.5 text-content-secondary">
{renderIcon(definition.icon, variant === 'menu' ? 14 : 13)}
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<p className="truncate text-sm font-medium text-content">{definition.label}</p>
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES[family]}`}>
{FAMILY_FILTER_LABELS[family]}
</span>
{getDefinitionBadges(definition).map(badge => (
<span
key={`${definition.step}-${badge.label}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
>
{badge.label}
</span>
))}
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-content-muted">{definition.step}</p>
</div>
<div key={`${moduleGroup.namespace}:${stageSection.stage}:${category}`}>
<div className="mb-1 flex items-center justify-between gap-2">
<span className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${CATEGORY_COLORS[category]}`}>
{CATEGORY_LABELS[category]}
</span>
<span className="text-[10px] text-content-muted">{categoryDefinitions.length}</span>
</div>
{isActionable && (
<button
type="button"
onClick={() => onSelectStep?.(definition.step)}
className={`shrink-0 rounded-lg px-2 py-1 text-xs font-medium ${
variant === 'panel'
? 'border border-border-default text-content hover:bg-surface-hover'
: 'bg-accent text-white hover:bg-accent-hover'
}`}
>
{variant === 'panel' ? (
<span className="inline-flex items-center gap-1">
<Plus size={12} />
Insert
</span>
) : (
<span className="inline-flex items-center gap-1">
Use
<ArrowRight size={12} />
<div className="space-y-1">
{categoryDefinitions.map(definition => {
const family = getDefinitionFamily(definition)
const requiredInputs = readContractList(definition.input_contract, 'requires')
const providedOutputs = readContractList(definition.output_contract, 'provides')
const inputContext = readContractContext(definition.input_contract)
const outputContext = readContractContext(definition.output_contract)
const isActionable = Boolean(onSelectStep)
return (
<div
key={definition.step}
className={`rounded-lg border border-border-default bg-surface px-3 py-2 ${
isActionable ? 'transition-colors hover:bg-surface-hover' : ''
}`}
title={definition.description}
>
<div className="flex items-start gap-2">
{renderIcon && (
<span className="mt-0.5 text-content-secondary">
{renderIcon(definition.icon, variant === 'menu' ? 14 : 13)}
</span>
)}
</button>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<p className="truncate text-sm font-medium text-content">
{definition.label}
</p>
<span
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${FAMILY_FILTER_STYLES[family]}`}
>
{FAMILY_FILTER_LABELS[family]}
</span>
{getDefinitionBadges(definition).map(badge => (
<span
key={`${definition.step}-${badge.label}`}
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
>
{badge.label}
</span>
))}
</div>
<p className="mt-0.5 truncate font-mono text-[11px] text-content-muted">
{definition.step}
</p>
</div>
<p className="mt-1 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
{isActionable && (
<button
type="button"
onClick={() => onSelectStep?.(definition.step)}
aria-label={
variant === 'panel'
? `Insert ${definition.label}`
: `Use ${definition.label}`
}
className={`shrink-0 rounded-lg px-2 py-1 text-xs font-medium ${
variant === 'panel'
? 'border border-border-default text-content hover:bg-surface-hover'
: 'bg-accent text-white hover:bg-accent-hover'
}`}
>
{variant === 'panel' ? (
<span className="inline-flex items-center gap-1">
<Plus size={12} />
Insert
</span>
) : (
<span className="inline-flex items-center gap-1">
Use
<ArrowRight size={12} />
</span>
)}
</button>
)}
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
{inputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
In {formatContractLabel(inputContext)}
</span>
)}
{outputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
Out {formatContractLabel(outputContext)}
</span>
)}
{requiredInputs.slice(0, 2).map(input => (
<span
key={`${definition.step}-requires-${input}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Requires {formatContractLabel(input)}
</span>
))}
{providedOutputs.slice(0, 2).map(output => (
<span
key={`${definition.step}-provides-${output}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Provides {formatContractLabel(output)}
</span>
))}
{definition.artifact_roles_consumed.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-consumes-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Consumes {formatContractLabel(artifact)}
</span>
))}
{definition.artifact_roles_produced.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-produces-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Produces {formatContractLabel(artifact)}
</span>
))}
</div>
</div>
<p className="mt-1 line-clamp-2 text-xs text-content-muted">
{definition.description}
</p>
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
{inputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
In {formatContractLabel(inputContext)}
</span>
)}
{outputContext && (
<span className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary">
Out {formatContractLabel(outputContext)}
</span>
)}
{requiredInputs.slice(0, 2).map(input => (
<span
key={`${definition.step}-requires-${input}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Requires {formatContractLabel(input)}
</span>
))}
{providedOutputs.slice(0, 2).map(output => (
<span
key={`${definition.step}-provides-${output}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Provides {formatContractLabel(output)}
</span>
))}
{definition.artifact_roles_consumed.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-consumes-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Consumes {formatContractLabel(artifact)}
</span>
))}
{definition.artifact_roles_produced.slice(0, 1).map(artifact => (
<span
key={`${definition.step}-produces-${artifact}`}
className="rounded-full border border-border-default bg-surface-hover/80 px-1.5 py-0.5 text-content-secondary"
>
Produces {formatContractLabel(artifact)}
</span>
))}
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)
@@ -1,3 +1,5 @@
import { formatContractValue } from './workflowGraphDraft'
interface WorkflowNodeContractCardProps {
moduleLabel: string
moduleKey: string
@@ -10,16 +12,14 @@ interface WorkflowNodeContractCardProps {
inputContextLabel?: string | null
outputContextLabel?: string | null
requiredInputs: string[]
requiredAnyInputs: string[][]
consumedArtifacts: string[]
providedOutputs: string[]
producedArtifacts: string[]
}
function formatContractRole(role: string): string {
return role
.split('_')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
return formatContractValue(role)
}
function ContractRolePills({
@@ -43,6 +43,27 @@ function ContractRolePills({
)
}
function ContractAlternativeGroups({
groups,
}: {
groups: string[][]
}) {
if (groups.length === 0) return null
return (
<div className="space-y-1.5">
{groups.map(group => (
<div
key={group.join('|')}
className="rounded-lg border border-dashed border-border-default bg-surface px-2 py-1 text-[11px] text-content-secondary"
>
Any of: {group.map(formatContractRole).join(' / ')}
</div>
))}
</div>
)
}
export function WorkflowNodeContractCard({
moduleLabel,
moduleKey,
@@ -55,6 +76,7 @@ export function WorkflowNodeContractCard({
inputContextLabel,
outputContextLabel,
requiredInputs,
requiredAnyInputs,
consumedArtifacts,
providedOutputs,
producedArtifacts,
@@ -99,6 +121,12 @@ export function WorkflowNodeContractCard({
) : (
<p className="text-xs text-content-muted">No declared upstream requirements.</p>
)}
{requiredAnyInputs.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Alternative Inputs</p>
<ContractAlternativeGroups groups={requiredAnyInputs} />
</div>
)}
{consumedArtifacts.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">Artifacts Consumed</p>
@@ -1,4 +1,7 @@
import { useMemo, type ChangeEvent } from 'react'
import { useQuery } from '@tanstack/react-query'
import { listRenderTemplates } from '../../api/renderTemplates'
import type { WorkflowNodeDefinition, WorkflowNodeFieldDefinition, WorkflowParams } from '../../api/workflows'
import {
FAMILY_FILTER_LABELS,
@@ -10,6 +13,11 @@ import {
type WorkflowGraphFamily,
} from './workflowNodeLibrary'
import { WorkflowNodeContractCard } from './WorkflowNodeContractCard'
import { formatContractValue } from './workflowGraphDraft'
const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video']
const NOTIFY_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset']
function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) {
return fields.reduce<Record<string, WorkflowNodeFieldDefinition[]>>((sections, field) => {
@@ -25,12 +33,104 @@ function getContractValues(contract: Record<string, unknown> | undefined, key: s
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
}
function getContractAlternativeGroups(contract: Record<string, unknown> | undefined, key: string): string[][] {
const value = contract?.[key]
if (!Array.isArray(value)) return []
if (value.every(entry => typeof entry === 'string' && entry.trim().length > 0)) {
return [value as string[]]
}
return value
.filter((entry): entry is string[] => Array.isArray(entry))
.map(group => group.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0))
.filter(group => group.length > 0)
}
function getContractContextLabel(contract: Record<string, unknown> | undefined): string | null {
const value = contract?.context
if (value !== 'cad_file' && value !== 'order_line') return null
return value === 'cad_file' ? 'CAD File' : 'Order Line'
}
function getAlternativeInputGroupsForNode(step: string | undefined, contract: Record<string, unknown> | undefined): string[][] {
const groups = getContractAlternativeGroups(contract, 'requires_any')
if (step === 'output_save' && groups.length === 0) {
return [OUTPUT_SAVE_ALTERNATIVE_INPUTS]
}
if (step === 'notify') {
if (groups.length === 0) {
return [NOTIFY_ALTERNATIVE_INPUTS]
}
return [Array.from(new Set([...groups[0], 'blend_asset'])), ...groups.slice(1)]
}
return groups
}
function parseSelectValue(field: WorkflowNodeFieldDefinition, rawValue: string): unknown {
if (rawValue === '') return ''
const matchedOption = field.options.find(option => String(option.value) === rawValue)
return matchedOption ? matchedOption.value : rawValue
}
function clearDynamicTemplateInputParams(params: WorkflowParams): WorkflowParams {
return Object.fromEntries(
Object.entries(params).filter(([key]) => !key.startsWith(TEMPLATE_INPUT_PARAM_PREFIX)),
)
}
function getVariableLabels(fields: WorkflowNodeFieldDefinition[]): string[] {
return Array.from(
new Set(
fields
.map(field => field.label?.trim())
.filter((label): label is string => Boolean(label)),
),
)
}
function formatContractRole(value: string): string {
return formatContractValue(value)
}
function describeRequiredInputs(requiredInputs: string[], requiredAnyInputs: string[][]): string[] {
return [
...requiredInputs.map(role => formatContractRole(role)),
...requiredAnyInputs.map(group => `Any of: ${group.map(formatContractRole).join(' / ')}`),
]
}
type InputSocketDescriptor = {
id: string
label: string
tone: 'required' | 'alternative'
}
function buildInputSocketDescriptors(
requiredInputs: string[],
requiredAnyInputs: string[][],
): InputSocketDescriptor[] {
return [
...requiredInputs.map(role => ({
id: `required:${role}`,
label: formatContractRole(role),
tone: 'required' as const,
})),
...requiredAnyInputs.map(group => ({
id: `alternative:${group.join('|')}`,
label: `Any of: ${group.map(formatContractRole).join(' / ')}`,
tone: 'alternative' as const,
})),
]
}
function formatCountNoun(count: number, singular: string, plural: string): string {
return `${count} ${count === 1 ? singular : plural}`
}
type WorkflowNodeInspectorProps = {
params: WorkflowParams
onChange: (params: WorkflowParams) => void
@@ -59,8 +159,80 @@ export function WorkflowNodeInspector({
[graphFamily, nodeDefinitions],
)
const nodeSelectionGroups = groupDefinitionsForStepSelect(selectableNodeDefinitions)
const isResolveTemplateNode = step === 'resolve_template'
const { data: renderTemplates = [] } = useQuery({
queryKey: ['render-templates'],
queryFn: listRenderTemplates,
enabled: isResolveTemplateNode,
staleTime: 30_000,
})
const selectedTemplateId =
typeof params.template_id_override === 'string' ? params.template_id_override : ''
const selectedTemplate = useMemo(
() => renderTemplates.find(template => template.id === selectedTemplateId) ?? null,
[renderTemplates, selectedTemplateId],
)
const effectiveFields = useMemo(() => {
const baseFields = [...(nodeDefinition?.fields ?? [])]
if (!isResolveTemplateNode) return baseFields
const templateOptions = renderTemplates
.filter(template => template.is_active)
.sort((left, right) => left.name.localeCompare(right.name))
.map(template => ({
value: template.id,
label: template.output_type_names?.length
? `${template.name} (${template.output_type_names.join(', ')})`
: template.name,
}))
const selectedTemplateMissing =
selectedTemplateId.length > 0 && !templateOptions.some(option => option.value === selectedTemplateId)
const resolvedBaseFields = baseFields.map(field => {
if (field.key !== 'template_id_override') return field
return {
...field,
label: 'Template Override',
description:
'Select a specific render template to expose its workflow inputs and bypass category/output-type auto resolution.',
type: 'select' as const,
allow_blank: true,
options: selectedTemplateMissing
? [{ value: selectedTemplateId, label: `${selectedTemplateId} (manual UUID)` }, ...templateOptions]
: templateOptions,
}
})
const dynamicTemplateFields = (selectedTemplate?.workflow_input_schema ?? []).map(field => ({
...field,
key: `${TEMPLATE_INPUT_PARAM_PREFIX}${field.key}`,
section: field.section || 'Template Inputs',
}))
return [...resolvedBaseFields, ...dynamicTemplateFields]
}, [isResolveTemplateNode, nodeDefinition?.fields, renderTemplates, selectedTemplate, selectedTemplateId])
const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => {
if (field.key === 'template_id_override') {
const nextParams = clearDynamicTemplateInputParams(params)
if (value !== '') {
nextParams[field.key] = value
} else {
delete nextParams[field.key]
}
onChange(nextParams)
return
}
if (value === '' && field.allow_blank !== false) {
const nextParams = { ...params }
delete nextParams[field.key]
onChange(nextParams)
return
}
onChange({
...params,
[field.key]: value,
@@ -78,13 +250,24 @@ export function WorkflowNodeInspector({
updateField(field, Number(rawValue))
}
const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? [])
const fieldsBySection = groupFieldsBySection(effectiveFields)
const inputContextLabel = getContractContextLabel(nodeDefinition?.input_contract as Record<string, unknown> | undefined)
const outputContextLabel = getContractContextLabel(nodeDefinition?.output_contract as Record<string, unknown> | undefined)
const requiredAnyInputs = getAlternativeInputGroupsForNode(
step,
nodeDefinition?.input_contract as Record<string, unknown> | undefined,
)
const alternativeRoleSet = new Set(requiredAnyInputs.flat())
const requiredInputs = getContractValues(nodeDefinition?.input_contract as Record<string, unknown> | undefined, 'requires')
.filter(role => !alternativeRoleSet.has(role))
const providedOutputs = getContractValues(nodeDefinition?.output_contract as Record<string, unknown> | undefined, 'provides')
const consumedArtifacts = nodeDefinition?.artifact_roles_consumed ?? []
const producedArtifacts = nodeDefinition?.artifact_roles_produced ?? []
const staticVariableLabels = getVariableLabels(nodeDefinition?.fields ?? [])
const dynamicTemplateVariableLabels = getVariableLabels(selectedTemplate?.workflow_input_schema ?? [])
const inputDescriptions = describeRequiredInputs(requiredInputs, requiredAnyInputs)
const inputSocketDescriptors = buildInputSocketDescriptors(requiredInputs, requiredAnyInputs)
const totalVariableCount = staticVariableLabels.length + dynamicTemplateVariableLabels.length
return (
<div className="space-y-5">
@@ -147,16 +330,139 @@ export function WorkflowNodeInspector({
inputContextLabel={inputContextLabel}
outputContextLabel={outputContextLabel}
requiredInputs={requiredInputs}
requiredAnyInputs={requiredAnyInputs}
consumedArtifacts={consumedArtifacts}
providedOutputs={providedOutputs}
producedArtifacts={producedArtifacts}
/>
)}
{nodeDefinition && (
<div className="space-y-3 rounded-xl border border-border-default bg-surface-hover/40 p-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Authoring Model
</p>
<p className="mt-1 text-sm text-content">
Canvas connections define upstream artifacts, inspector fields define local node variables.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Wired Inputs
</p>
{inputDescriptions.length > 0 ? (
<>
<p className="mt-1 text-xs text-content-muted">
{formatCountNoun(inputDescriptions.length, 'canvas socket', 'canvas sockets')} {inputDescriptions.length === 1 ? 'is' : 'are'} required. Each entry below maps to one input handle on the node.
</p>
<div className="mt-2 space-y-1.5">
{inputSocketDescriptors.map((descriptor, index) => (
<div
key={descriptor.id}
className="flex items-start gap-2 rounded-md border border-border-default bg-surface-hover/50 px-2 py-1"
>
<span
className={`inline-flex rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
descriptor.tone === 'alternative'
? 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300'
: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300'
}`}
>
Socket {index + 1}
</span>
<span className="min-w-0 text-xs text-content">{descriptor.label}</span>
</div>
))}
</div>
</>
) : (
<p className="mt-1 text-xs text-content-muted">
This entry node does not declare additional upstream sockets.
</p>
)}
</div>
<div className="rounded-lg border border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Node Variables
</p>
{totalVariableCount > 0 ? (
<>
<p className="mt-1 text-xs text-content-muted">
{formatCountNoun(totalVariableCount, 'local variable', 'local variables')} {totalVariableCount === 1 ? 'is' : 'are'} edited in the inspector.
</p>
{staticVariableLabels.length > 0 && (
<p className="mt-2 text-xs text-content">
Static: {staticVariableLabels.join(', ')}
</p>
)}
{dynamicTemplateVariableLabels.length > 0 && (
<p className="mt-2 text-xs text-content">
Template-driven: {dynamicTemplateVariableLabels.join(', ')}
</p>
)}
</>
) : (
<p className="mt-1 text-xs text-content-muted">
This node has 0 local variables by design. Its behavior is driven entirely by connections and runtime context.
</p>
)}
</div>
</div>
{isResolveTemplateNode && (
<div className="rounded-lg border border-dashed border-border-default bg-surface px-3 py-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-content-secondary">
Dynamic Template Variables
</p>
{dynamicTemplateVariableLabels.length > 0 ? (
<>
<p className="mt-1 text-xs text-content-muted">
The selected template adds {dynamicTemplateVariableLabels.length} extra variable
{dynamicTemplateVariableLabels.length === 1 ? '' : 's'} to this node.
</p>
<p className="mt-2 text-xs text-content">
{dynamicTemplateVariableLabels.join(', ')}
</p>
</>
) : (
<p className="mt-1 text-xs text-content-muted">
Select a template override to expose template-defined variables for this node.
</p>
)}
</div>
)}
</div>
)}
{isResolveTemplateNode && !selectedTemplate && renderTemplates.length > 0 && (
<div className="rounded-xl border border-dashed border-border-default bg-surface-hover/50 px-3 py-3">
<p className="text-sm text-content">Template-specific inputs appear after selecting a template override.</p>
<p className="mt-1 text-xs text-content-muted">
Leave the field empty to keep legacy category/output-type resolution.
</p>
</div>
)}
{selectedTemplate && (selectedTemplate.workflow_input_schema?.length ?? 0) > 0 && (
<div className="rounded-xl border border-border-default bg-surface-hover/50 px-3 py-3">
<p className="text-sm font-medium text-content">{selectedTemplate.name}</p>
<p className="mt-1 text-xs text-content-muted">
{selectedTemplate.workflow_input_schema.length} template input
{selectedTemplate.workflow_input_schema.length === 1 ? '' : 's'} exposed for this node.
</p>
</div>
)}
{Object.keys(fieldsBySection).length === 0 && (
<p className="text-sm text-content-muted">
This node currently has no configurable settings in the editor.
</p>
<div className="rounded-xl border border-dashed border-border-default bg-surface-hover/50 px-3 py-3">
<p className="text-sm text-content">This node has no editor settings.</p>
<p className="mt-1 text-xs text-content-muted">
Configure it by wiring its declared inputs on the canvas. Each required upstream input gets its own socket on the node itself.
</p>
</div>
)}
{Object.entries(fieldsBySection).map(([section, fields]) => (
@@ -167,6 +473,8 @@ export function WorkflowNodeInspector({
{fields.map(field => {
const rawValue = params[field.key]
const value = rawValue ?? field.default
const fieldId = `workflow-node-field-${field.key}`
const fieldOptions = field.options ?? []
const disableRenderOverrideField =
(step === 'blender_still' || step === 'blender_turntable') &&
!customRenderSettingsEnabled &&
@@ -175,18 +483,24 @@ export function WorkflowNodeInspector({
return (
<div key={field.key}>
<label className="text-sm text-content-secondary mb-1 block">
<label htmlFor={fieldId} className="text-sm text-content-secondary mb-1 block">
{field.label}
{field.unit ? ` (${field.unit})` : ''}
</label>
{field.type === 'select' && (
<select
value={String(value ?? '')}
onChange={event => updateField(field, event.target.value)}
id={fieldId}
value={value == null ? '' : String(value)}
onChange={event => updateField(field, parseSelectValue(field, event.target.value))}
disabled={disableRenderOverrideField}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
>
{field.options.map(option => (
{field.allow_blank !== false && (
<option value="">
{field.key === 'template_id_override' ? 'Automatic resolution' : 'None'}
</option>
)}
{fieldOptions.map(option => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
@@ -195,6 +509,7 @@ export function WorkflowNodeInspector({
)}
{field.type === 'number' && (
<input
id={fieldId}
type="number"
min={field.min ?? undefined}
max={field.max ?? undefined}
@@ -206,8 +521,12 @@ export function WorkflowNodeInspector({
/>
)}
{field.type === 'boolean' && (
<label className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content">
<label
htmlFor={fieldId}
className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content"
>
<input
id={fieldId}
type="checkbox"
checked={Boolean(value)}
onChange={event => updateField(field, event.target.checked)}
@@ -219,8 +538,10 @@ export function WorkflowNodeInspector({
)}
{field.type === 'text' && (
<input
id={fieldId}
type="text"
value={value == null ? '' : String(value)}
maxLength={field.max_length ?? undefined}
onChange={event => updateField(field, event.target.value)}
disabled={disableRenderOverrideField}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
@@ -37,6 +37,23 @@ export function WorkflowPreflightPanel({
</span>
</div>
<div className="flex flex-wrap gap-2 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Mode: {preflight.execution_mode}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Global issues: {preflight.issues.length}
</span>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Node checks: {preflight.nodes.length}
</span>
{preflight.unsupported_node_ids.length > 0 && (
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5">
Unsupported nodes: {preflight.unsupported_node_ids.length}
</span>
)}
</div>
{(preflight.resolved_order_line_id || preflight.resolved_cad_file_id) && (
<div className="space-y-1 text-xs text-content-muted">
{preflight.resolved_order_line_id && <p>Order Line: {preflight.resolved_order_line_id}</p>}
@@ -44,6 +61,13 @@ export function WorkflowPreflightPanel({
</div>
)}
{preflight.unsupported_node_ids.length > 0 && (
<div className="space-y-1 rounded-md border border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
<p className="font-medium text-content">Unsupported Node IDs</p>
<p className="break-words">{preflight.unsupported_node_ids.join(', ')}</p>
</div>
)}
{preflight.issues.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
@@ -57,6 +81,11 @@ export function WorkflowPreflightPanel({
{issue.severity}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-content-muted">
<span>Code: {issue.code}</span>
{issue.step && <span>Step: {issue.step}</span>}
{issue.node_id && <span>Node: {issue.node_id}</span>}
</div>
</div>
))}
</div>
@@ -77,6 +106,14 @@ export function WorkflowPreflightPanel({
{node.status}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-2 text-[11px] text-content-muted">
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
Runtime: {node.execution_kind}
</span>
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
Supported: {node.supported ? 'yes' : 'no'}
</span>
</div>
{node.issues.length > 0 && (
<div className="mt-2 space-y-1">
{node.issues.map(issue => (
@@ -0,0 +1,83 @@
import { Milestone, Wand2 } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
type WorkflowReferenceBundlePanelProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
onInsertReferencePath?: (bundleId: WorkflowReferenceBundleId) => void
}
export function WorkflowReferenceBundlePanel({
definitions,
graphFamily,
activeSteps,
onInsertReferencePath,
}: WorkflowReferenceBundlePanelProps) {
const { referenceBundles } = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
if (referenceBundles.length === 0) return null
return (
<div className="space-y-2 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Milestone size={14} className="text-accent" />
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Reference Paths
</p>
</div>
<p className="mt-1 text-xs text-content-muted">
Insert complete canonical production routes when you want a full non-legacy baseline instead of assembling modules piecemeal.
</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{referenceBundles.length} paths
</span>
</div>
<div className="space-y-2">
{referenceBundles.map(bundle => (
<div
key={bundle.id}
className="rounded-2xl border border-border-default bg-surface px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-semibold text-content">{bundle.label}</p>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] font-medium text-content-secondary">
{bundle.stage}
</span>
<span className="rounded-full bg-surface-hover px-2 py-0.5 text-[11px] text-content-muted">
{bundle.presentCount}/{bundle.totalCount} present
</span>
</div>
<p className="text-xs text-content-muted">{bundle.description}</p>
<p className="text-[11px] text-content-muted">
{bundle.stepIds.join(' -> ')}
</p>
</div>
{onInsertReferencePath ? (
<button
type="button"
onClick={() => onInsertReferencePath(bundle.id)}
aria-label={`Insert ${bundle.label}`}
className="inline-flex items-center gap-1 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-accent-hover"
>
<Wand2 size={12} />
Insert
</button>
) : null}
</div>
</div>
))}
</div>
</div>
)
}
@@ -8,6 +8,27 @@ import {
getRunStatusClassName,
} from './workflowRunPresentation'
function formatDuration(durationS: number | null) {
if (durationS == null) return null
if (durationS < 1) return `${Math.round(durationS * 1000)} ms`
return `${durationS.toFixed(durationS >= 10 ? 0 : 1)} s`
}
function formatOutputPreview(output: Record<string, unknown> | null) {
if (!output) return null
return JSON.stringify(output, null, 2)
}
function getRolloutGateClassName(verdict: WorkflowRunComparison['rollout_gate_verdict']) {
if (verdict === 'pass') return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
if (verdict === 'warn') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
}
function formatRolloutStatus(status: WorkflowRunComparison['workflow_rollout_status']) {
return status === 'ready_for_rollout' ? 'Ready For Rollout' : 'Hold Legacy Authoritative'
}
interface WorkflowRunsPanelProps {
runs: WorkflowRun[]
selectedRunId: string | null
@@ -82,6 +103,29 @@ export function WorkflowRunsPanel({
</span>
</div>
<div className="grid gap-2 text-xs text-content-muted sm:grid-cols-2">
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<p className="font-medium text-content">Execution Mode</p>
<p>{EXECUTION_MODE_LABELS[selectedRun.execution_mode]}</p>
</div>
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<p className="font-medium text-content">Completed</p>
<p>{formatDateTime(selectedRun.completed_at)}</p>
</div>
{selectedRun.order_line_id && (
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<p className="font-medium text-content">Order Line</p>
<p className="break-all">{selectedRun.order_line_id}</p>
</div>
)}
{selectedRun.celery_task_id && (
<div className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<p className="font-medium text-content">Celery Task</p>
<p className="break-all">{selectedRun.celery_task_id}</p>
</div>
)}
</div>
{selectedRun.error_message && (
<p className="text-xs text-red-600 dark:text-red-300">{selectedRun.error_message}</p>
)}
@@ -90,6 +134,11 @@ export function WorkflowRunsPanel({
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Node Results
</p>
{selectedRun.node_results.length === 0 && (
<p className="rounded-md border border-dashed border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
No node results recorded yet for this run.
</p>
)}
{selectedRun.node_results.map(result => (
<div key={result.id} className="rounded-md border border-border-default bg-surface px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
@@ -98,9 +147,29 @@ export function WorkflowRunsPanel({
{result.status}
</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-content-muted">
{formatDuration(result.duration_s) && (
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
Duration: {formatDuration(result.duration_s)}
</span>
)}
<span className="rounded-full border border-border-default bg-surface-hover/70 px-2 py-0.5">
Recorded {formatDateTime(result.created_at)}
</span>
</div>
{result.log && (
<p className="mt-1 line-clamp-3 text-xs text-content-muted">{result.log}</p>
)}
{result.output && (
<details className="mt-2 rounded-md border border-border-default bg-surface-hover/40 px-2 py-2">
<summary className="cursor-pointer text-xs font-medium text-content">
Node output
</summary>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words text-[11px] text-content-muted">
{formatOutputPreview(result.output)}
</pre>
</details>
)}
</div>
))}
</div>
@@ -117,6 +186,26 @@ export function WorkflowRunsPanel({
<div className="space-y-1.5 rounded-md border border-border-default bg-surface px-2.5 py-2 text-xs text-content-muted">
<p className="text-sm text-content">{comparison.summary}</p>
<p>Status: {comparison.status}</p>
<div className="flex flex-wrap gap-2">
<span className={`rounded-full px-2 py-0.5 font-medium ${getRolloutGateClassName(comparison.rollout_gate_verdict)}`}>
Rollout Gate: {comparison.rollout_gate_verdict}
</span>
<span className={`rounded-full px-2 py-0.5 font-medium ${comparison.workflow_rollout_ready ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}`}>
{formatRolloutStatus(comparison.workflow_rollout_status)}
</span>
</div>
<div className="flex flex-wrap gap-2">
{comparison.exact_match != null && (
<span className={`rounded-full px-2 py-0.5 font-medium ${comparison.exact_match ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}`}>
Exact Match: {comparison.exact_match ? 'yes' : 'no'}
</span>
)}
{comparison.dimensions_match != null && (
<span className={`rounded-full px-2 py-0.5 font-medium ${comparison.dimensions_match ? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300' : 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'}`}>
Dimensions: {comparison.dimensions_match ? 'match' : 'mismatch'}
</span>
)}
</div>
<p>
Authoritative: {comparison.authoritative_output.image_width ?? '?'} x {comparison.authoritative_output.image_height ?? '?'}
</p>
@@ -126,6 +215,16 @@ export function WorkflowRunsPanel({
{comparison.mean_pixel_delta != null && (
<p>Mean Pixel Delta: {comparison.mean_pixel_delta.toFixed(6)}</p>
)}
{comparison.rollout_reasons.length > 0 && (
<div className="rounded-md border border-border-default bg-surface-hover/40 px-2 py-2">
<p className="font-medium text-content">Operator Decision</p>
<ul className="mt-1 space-y-1">
{comparison.rollout_reasons.map(reason => (
<li key={reason}>{reason}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
@@ -0,0 +1,93 @@
import { CheckCircle2, CircleDashed, Plus } from 'lucide-react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowAuthoringPlan } from './workflowAuthoringGuidance'
type WorkflowStarterPathPanelProps = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
onSelectStep?: (step: string) => void
}
export function WorkflowStarterPathPanel({
definitions,
graphFamily,
activeSteps,
onSelectStep,
}: WorkflowStarterPathPanelProps) {
const plan = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
if (graphFamily === 'mixed' || plan.starterItems.length === 0) return null
return (
<div className="space-y-3 rounded-2xl border border-border-default bg-surface-hover/30 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
Starter Path
</p>
<p className="mt-1 text-sm font-medium text-content">{plan.starterTitle}</p>
<p className="mt-1 text-xs text-content-muted">{plan.starterDescription}</p>
</div>
<span className="rounded-full border border-border-default bg-surface px-2 py-0.5 text-[11px] text-content-muted">
{plan.starterCompletedCount}/{plan.starterItems.length} present
</span>
</div>
<div className="space-y-2">
{plan.starterItems.map(item => {
const { definition, index, isPresent } = item
return (
<div
key={definition.step}
className="flex items-center justify-between gap-2 rounded-xl border border-border-default bg-surface px-3 py-2"
>
<div className="min-w-0 flex items-start gap-2">
<span className="mt-0.5 text-content-secondary">
{isPresent ? (
<CheckCircle2 size={15} className="text-emerald-600" />
) : (
<CircleDashed size={15} className="text-amber-600" />
)}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-1.5">
<span className="rounded-full border border-border-default bg-surface-hover/70 px-1.5 py-0.5 text-[10px] font-medium text-content-secondary">
{index}
</span>
<p className="truncate text-sm font-medium text-content">{definition.label}</p>
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-medium text-content-muted ring-1 ring-border-default">
{definition.category}
</span>
</div>
<p className="mt-0.5 line-clamp-2 text-xs text-content-muted">{definition.description}</p>
</div>
</div>
{!isPresent && onSelectStep && (
<button
type="button"
onClick={() => onSelectStep(definition.step)}
className="shrink-0 rounded-lg border border-border-default px-2 py-1 text-xs font-medium text-content hover:bg-surface-hover"
>
<span className="inline-flex items-center gap-1">
<Plus size={12} />
Add
</span>
</button>
)}
{isPresent && (
<span className="shrink-0 rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
Present
</span>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -23,7 +23,7 @@ export function WorkflowUtilityRail<T extends string>({
children,
}: WorkflowUtilityRailProps<T>) {
return (
<div className="flex w-[22rem] flex-col border-l border-border-default bg-surface">
<div className="flex w-full flex-col border-t border-border-default bg-surface xl:w-[22rem] xl:flex-shrink-0 xl:border-l xl:border-t-0">
<div className="border-b border-border-default px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-xl border border-border-default bg-surface-hover text-content-secondary">
@@ -67,7 +67,7 @@ export function WorkflowUtilityRail<T extends string>({
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">{children}</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 xl:max-h-none">{children}</div>
</div>
)
}
@@ -1,10 +1,21 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { addEdge, useEdgesState, useNodesState, type Connection, type Edge, type Node, type ReactFlowInstance } from '@xyflow/react'
import {
addEdge,
applyNodeChanges,
useEdgesState,
useNodesState,
type Connection,
type Edge,
type Node,
type NodeChange,
type ReactFlowInstance,
} from '@xyflow/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
dispatchWorkflowDraft,
getWorkflowOrderLineContexts,
getNodeDefinitions,
getWorkflowRunComparison,
getWorkflowRuns,
@@ -13,17 +24,25 @@ import {
type WorkflowDefinition,
type WorkflowExecutionMode,
type WorkflowNodeDefinition,
type WorkflowOrderLineContextGroup as WorkflowOrderLineContextGroupApi,
type WorkflowOrderLineContextOption as WorkflowOrderLineContextOptionApi,
type WorkflowParams,
type WorkflowPreflightResponse,
} from '../../api/workflows'
import {
applyAutoLayout,
buildWorkflowCanvasNodeData,
buildCurrentWorkflowConfig,
deriveWorkflowAuthoringFamily,
findOpenNodePosition,
graphNeedsAutoLayout,
inferNodeLabel,
inferNodeType,
inferStepFromNodeType,
normalizeWorkflowParams,
resolveParamsForStepChange,
resolveNodeCollisions,
shouldAutoLayoutAfterInsert,
type WorkflowCanvasNodeData,
validateWorkflowDraft,
workflowToGraph,
@@ -35,6 +54,11 @@ import {
GRAPH_FAMILY_LABELS,
isDefinitionAllowedForGraphFamily,
} from './workflowNodeLibrary'
import { createWorkflowModuleBundleInsertion, type WorkflowModuleBundleId } from './workflowModuleBundles'
import {
createWorkflowReferenceBundleInsertion,
type WorkflowReferenceBundleId,
} from './workflowReferenceBundles'
import type { WorkflowUtilityTab } from './WorkflowCanvasUtilitySidebar'
export type NodeMenuAnchor = {
@@ -43,20 +67,30 @@ export type NodeMenuAnchor = {
flowPosition: { x: number; y: number }
}
function buildNodeData(
step: string,
params: WorkflowParams = {},
definition?: WorkflowNodeDefinition,
overrides?: Partial<WorkflowCanvasNodeData>,
): WorkflowCanvasNodeData {
return {
label: overrides?.label ?? definition?.label ?? inferNodeLabel(step),
params: normalizeWorkflowParams(params),
step,
description: overrides?.description ?? definition?.description,
icon: overrides?.icon ?? definition?.icon,
category: overrides?.category ?? definition?.category,
}
export type WorkflowOrderLineContextOption = {
value: string
label: string
meta: string
}
export type WorkflowOrderLineContextGroup = {
orderId: string
orderLabel: string
options: WorkflowOrderLineContextOption[]
}
function normalizeOrderLineContextGroups(
groups: WorkflowOrderLineContextGroupApi[],
): WorkflowOrderLineContextGroup[] {
return groups.map(group => ({
orderId: group.order_id,
orderLabel: group.order_label,
options: group.options.map((option: WorkflowOrderLineContextOptionApi) => ({
value: option.value,
label: option.label,
meta: option.meta,
})),
}))
}
type UseWorkflowCanvasControllerArgs = {
@@ -73,14 +107,17 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
})
const nodeDefinitions = nodeDefinitionsData?.definitions ?? []
const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition]))
const definitionsLoaded = nodeDefinitions.length > 0
const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep)
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes)
const [nodes, setNodes] = useNodesState(initNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges)
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
const [selectedEdgeIds, setSelectedEdgeIds] = useState<string[]>([])
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
const [dispatchContextId, setDispatchContextId] = useState('')
const [preflightResult, setPreflightResult] = useState<WorkflowPreflightResponse | null>(null)
const [lastSuccessfulPreflightFingerprint, setLastSuccessfulPreflightFingerprint] = useState<string | null>(null)
const [executionMode, setExecutionMode] = useState<WorkflowExecutionMode>(workflow.config.ui?.execution_mode ?? 'legacy')
const [nodeMenuAnchor, setNodeMenuAnchor] = useState<NodeMenuAnchor | null>(null)
const [activeUtilityTab, setActiveUtilityTab] = useState<WorkflowUtilityTab>('library')
@@ -88,18 +125,66 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
const [reactFlowInstance, setReactFlowInstance] = useState<ReactFlowInstance<Node, Edge> | null>(null)
const validation = validateWorkflowDraft(nodes, edges, nodeDefinitionsByStep, nodeDefinitions.length > 0)
const selectedEdgeIds = useMemo(
() => edges.filter(edge => Boolean((edge as Edge & { selected?: boolean }).selected)).map(edge => edge.id),
[edges],
const authoringFamily = useMemo(
() => deriveWorkflowAuthoringFamily(workflow, nodes, nodeDefinitionsByStep, definitionsLoaded),
[definitionsLoaded, nodeDefinitionsByStep, nodes, workflow],
)
const currentWorkflowConfig = useMemo(
() => buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode, authoringFamily),
[authoringFamily, edges, executionMode, nodes, workflow],
)
const graphFamily = useMemo(
() =>
inferWorkflowFamily(
buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
nodeDefinitionsByStep,
),
[edges, executionMode, nodeDefinitionsByStep, nodes, workflow],
() => inferWorkflowFamily(currentWorkflowConfig, nodeDefinitionsByStep),
[currentWorkflowConfig, nodeDefinitionsByStep],
)
const isOrderLineGraph = graphFamily === 'order_line'
const { data: workflowOrderLineContexts = [], isFetching: isOrderLineContextsLoading } = useQuery({
queryKey: ['workflow-order-line-contexts'],
queryFn: () => getWorkflowOrderLineContexts(50),
enabled: isOrderLineGraph,
staleTime: 30_000,
})
const orderLineContextGroups = useMemo<WorkflowOrderLineContextGroup[]>(
() => normalizeOrderLineContextGroups(workflowOrderLineContexts).filter(group => group.options.length > 0),
[workflowOrderLineContexts],
)
const selectedOrderLineContext = useMemo(
() =>
orderLineContextGroups
.flatMap(group => group.options)
.find(option => option.value === dispatchContextId) ?? null,
[dispatchContextId, orderLineContextGroups],
)
const dispatchContextLabel = useMemo(() => {
if (isOrderLineGraph) return 'Order Line'
if (graphFamily === 'cad_file') return 'CAD File'
return 'Context'
}, [graphFamily, isOrderLineGraph])
const dispatchContextSummary = useMemo(() => {
if (isOrderLineGraph) return selectedOrderLineContext?.label ?? null
const trimmed = dispatchContextId.trim()
if (graphFamily === 'cad_file' && trimmed.length > 0) return 'CAD File'
return trimmed.length > 0 ? trimmed : null
}, [dispatchContextId, graphFamily, isOrderLineGraph, selectedOrderLineContext])
const dispatchContextMeta = useMemo(() => {
if (isOrderLineGraph) return selectedOrderLineContext?.meta ?? null
if (graphFamily !== 'cad_file') return null
const trimmed = dispatchContextId.trim()
if (!trimmed) return null
if (preflightResult?.context_id === trimmed && preflightResult.resolved_cad_file_id) {
return `${preflightResult.resolved_cad_file_id} · validated`
}
return trimmed
}, [dispatchContextId, graphFamily, isOrderLineGraph, preflightResult, selectedOrderLineContext])
const currentDispatchFingerprint = useMemo(
() => JSON.stringify({ contextId: dispatchContextId.trim(), config: currentWorkflowConfig }),
[currentWorkflowConfig, dispatchContextId],
)
const hasFreshSuccessfulPreflight =
preflightResult?.graph_dispatch_allowed === true &&
lastSuccessfulPreflightFingerprint === currentDispatchFingerprint
const { data: workflowRuns = [] } = useQuery({
queryKey: ['workflow-runs', workflow.id],
@@ -140,32 +225,47 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
context_id: contextId,
config,
}),
onSuccess: result => {
onSuccess: (result, variables) => {
setPreflightResult(result)
if (result.graph_dispatch_allowed) {
setLastSuccessfulPreflightFingerprint(JSON.stringify({ contextId: variables.contextId, config: variables.config }))
toast.success(result.summary)
} else {
setLastSuccessfulPreflightFingerprint(null)
toast.error(result.summary)
}
},
onError: (error: any) => {
setPreflightResult(null)
setLastSuccessfulPreflightFingerprint(null)
toast.error(error?.response?.data?.detail || 'Failed to preflight workflow')
},
})
useEffect(() => {
const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
setNodes(graph.nodes)
const nextNodes = graphNeedsAutoLayout(graph.nodes) ? applyAutoLayout(graph.nodes, graph.edges) : graph.nodes
setNodes(nextNodes)
setEdges(graph.edges)
setSelectedNodeId(null)
setSelectedEdgeIds([])
setSelectedRunId(null)
setNodeMenuAnchor(null)
setPreflightResult(null)
setLastSuccessfulPreflightFingerprint(null)
setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy')
setActiveUtilityTab('library')
}, [nodeDefinitionsData, setEdges, setNodes, workflow.config])
useEffect(() => {
if (!isOrderLineGraph) return
if (dispatchContextId.trim()) return
const firstOption = orderLineContextGroups[0]?.options[0]
if (firstOption) {
setDispatchContextId(firstOption.value)
}
}, [dispatchContextId, isOrderLineGraph, orderLineContextGroups])
useEffect(() => {
if (!selectedRunId && workflowRuns.length > 0) {
setSelectedRunId(workflowRuns[0].id)
@@ -177,18 +277,110 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
}, [selectedRunId, workflowRuns])
const onConnect = useCallback(
(connection: Connection) => setEdges(currentEdges => addEdge(connection, currentEdges)),
[setEdges],
(connection: Connection) => {
if (!connection.source || !connection.target) return
const sourceNode = nodes.find(node => node.id === connection.source)
const targetNode = nodes.find(node => node.id === connection.target)
const sourceData = sourceNode?.data as WorkflowCanvasNodeData | undefined
const targetData = targetNode?.data as WorkflowCanvasNodeData | undefined
const sourcePorts = sourceData?.outputPorts ?? []
const targetPorts = targetData?.inputPorts ?? []
if (sourcePorts.length === 0) {
toast.error('Selected source node does not expose any downstream outputs.')
return
}
if (targetPorts.length === 0) {
toast.error('Selected target node does not declare any upstream inputs.')
return
}
const requestedTargetPort = connection.targetHandle
? targetPorts.find(port => port.id === connection.targetHandle)
: undefined
const requestedSourcePort = connection.sourceHandle
? sourcePorts.find(port => port.id === connection.sourceHandle)
: undefined
const matchingTargetPort =
requestedTargetPort &&
sourcePorts.some(sourcePort =>
requestedTargetPort.roles.some(role => sourcePort.roles.includes(role)),
)
? requestedTargetPort
: targetPorts.find(port =>
sourcePorts.some(sourcePort => port.roles.some(role => sourcePort.roles.includes(role))),
)
if (!matchingTargetPort) {
toast.error('These nodes do not share a compatible input/output contract.')
return
}
const matchingSourcePort =
requestedSourcePort &&
matchingTargetPort.roles.some(role => requestedSourcePort.roles.includes(role))
? requestedSourcePort
: sourcePorts.find(port => matchingTargetPort.roles.some(role => port.roles.includes(role)))
if (!matchingSourcePort) {
toast.error('The selected source handle does not satisfy the target input contract.')
return
}
setEdges(currentEdges => {
const duplicateEdge = currentEdges.some(
edge => edge.source === connection.source && edge.target === connection.target,
)
if (duplicateEdge) {
toast.error('A connection between these nodes already exists.')
return currentEdges
}
return addEdge(
{
...connection,
sourceHandle: matchingSourcePort.id,
targetHandle: matchingTargetPort.id,
},
currentEdges,
)
})
},
[nodes, setEdges],
)
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
setNodes(currentNodes => {
const nextNodes = applyNodeChanges(changes, currentNodes)
const settledNodeIds = changes
.filter((change): change is Extract<NodeChange, { type: 'position' }> => change.type === 'position')
.filter(change => change.dragging !== true)
.map(change => change.id)
if (settledNodeIds.length === 0) {
return nextNodes
}
return resolveNodeCollisions(nextNodes, settledNodeIds)
})
},
[setNodes],
)
const onNodeClick = useCallback((_: ReactMouseEvent, node: Node) => {
setNodeMenuAnchor(null)
setSelectedEdgeIds([])
setSelectedNodeId(node.id)
setActiveUtilityTab('inspector')
}, [])
const onEdgeClick = useCallback((_: ReactMouseEvent, edge: Edge) => {
setNodeMenuAnchor(null)
setSelectedEdgeIds([edge.id])
setSelectedNodeId(null)
setEdges(currentEdges =>
currentEdges.map(currentEdge => ({
@@ -200,6 +392,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
const onPaneClick = useCallback(() => {
setNodeMenuAnchor(null)
setSelectedEdgeIds([])
setSelectedNodeId(null)
setEdges(currentEdges =>
currentEdges.map(edge => ({
@@ -226,8 +419,8 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
const handlePipelineStepChange = useCallback(
(stepName: string) => {
const definition = nodeDefinitionsByStep[stepName]
if (definition && !isDefinitionAllowedForGraphFamily(definition, graphFamily)) {
toast.error(`${definition.label} does not belong to the ${GRAPH_FAMILY_LABELS[graphFamily]} family.`)
if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) {
toast.error(`${definition.label} does not belong to the ${GRAPH_FAMILY_LABELS[authoringFamily]} authoring family.`)
return
}
@@ -235,12 +428,12 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
currentNodes.map(node => {
if (node.id !== selectedNodeId) return node
const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName)
const currentData = (node.data as WorkflowCanvasNodeData | undefined) ?? buildWorkflowCanvasNodeData(stepName)
return {
...node,
type: definition?.node_type ?? inferNodeType(stepName),
data: {
...buildNodeData(
...buildWorkflowCanvasNodeData(
stepName || inferStepFromNodeType(node.type),
resolveParamsForStepChange(definition, currentData.params),
definition,
@@ -251,7 +444,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
}),
)
},
[graphFamily, nodeDefinitionsByStep, selectedNodeId, setNodes],
[authoringFamily, nodeDefinitionsByStep, selectedNodeId, setNodes],
)
const openNodeMenu = useCallback(
@@ -287,27 +480,91 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
const insertNode = useCallback(
(step: string, preferredPosition?: { x: number; y: number }) => {
const definition = nodeDefinitionsByStep[step]
if (definition && !isDefinitionAllowedForGraphFamily(definition, graphFamily)) {
toast.error(`${definition.label} cannot be added to a ${GRAPH_FAMILY_LABELS[graphFamily]} workflow.`)
if (definition && !isDefinitionAllowedForGraphFamily(definition, authoringFamily)) {
toast.error(`${definition.label} cannot be added to a ${GRAPH_FAMILY_LABELS[authoringFamily]} workflow.`)
return
}
const type = definition?.node_type ?? inferNodeType(step)
const fallbackX = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.x)) + 220 : 120
const fallbackY = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.y)) + 40 : 120
const fallbackX = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.x)) + 312 : 120
const fallbackY = nodes.length > 0 ? Math.max(...nodes.map(node => node.position.y)) + 140 : 120
const position = findOpenNodePosition(nodes, preferredPosition ?? { x: fallbackX, y: fallbackY })
const newNode: Node = {
id: `${step}_${Date.now()}`,
type,
position: preferredPosition ?? { x: fallbackX, y: fallbackY },
data: buildNodeData(step, definition?.defaults ?? {}, definition),
position,
data: buildWorkflowCanvasNodeData(step, definition?.defaults ?? {}, definition),
}
const nextNodes = [...nodes, newNode]
const shouldAutoLayout = shouldAutoLayoutAfterInsert(nextNodes, newNode, preferredPosition ?? null)
const laidOutNodes = shouldAutoLayout ? applyAutoLayout(nextNodes, edges) : nextNodes
setNodes(currentNodes => [...currentNodes, newNode])
setNodes(laidOutNodes)
setSelectedNodeId(newNode.id)
setNodeMenuAnchor(null)
setActiveUtilityTab('inspector')
if (shouldAutoLayout) {
window.requestAnimationFrame(() => {
reactFlowInstance?.fitView({ padding: 0.2, duration: 220 })
})
}
},
[graphFamily, nodeDefinitionsByStep, nodes, setNodes],
[authoringFamily, edges, nodeDefinitionsByStep, nodes, reactFlowInstance, setNodes],
)
const insertModuleBundle = useCallback(
(bundleId: WorkflowModuleBundleId, preferredPosition?: { x: number; y: number }) => {
const insertion = createWorkflowModuleBundleInsertion({
bundleId,
graphFamily: authoringFamily,
nodeDefinitionsByStep,
existingNodes: nodes,
preferredPosition,
})
if (!insertion.ok) {
toast.error(insertion.reason)
return
}
const combinedNodes = [...nodes, ...insertion.nodes]
const combinedEdges = [...edges, ...insertion.edges]
setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes)
setEdges(combinedEdges)
setSelectedNodeId(insertion.nodes[0]?.id ?? null)
setNodeMenuAnchor(null)
setActiveUtilityTab('inspector')
toast.success(`${insertion.bundle.label} inserted`)
},
[authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes],
)
const insertReferenceBundle = useCallback(
(bundleId: WorkflowReferenceBundleId, preferredPosition?: { x: number; y: number }) => {
const insertion = createWorkflowReferenceBundleInsertion({
bundleId,
graphFamily: authoringFamily,
nodeDefinitionsByStep,
existingNodes: nodes,
preferredPosition,
})
if (!insertion.ok) {
toast.error(insertion.reason)
return
}
const combinedNodes = [...nodes, ...insertion.nodes]
const combinedEdges = [...edges, ...insertion.edges]
setNodes(graphNeedsAutoLayout(combinedNodes) ? applyAutoLayout(combinedNodes, combinedEdges) : combinedNodes)
setEdges(combinedEdges)
setSelectedNodeId(insertion.nodes[0]?.id ?? null)
setNodeMenuAnchor(null)
setActiveUtilityTab('inspector')
toast.success(`${insertion.bundle.label} inserted`)
},
[authoringFamily, edges, nodeDefinitionsByStep, nodes, setEdges, setNodes],
)
const handleOpenToolbarNodeMenu = useCallback(() => {
@@ -327,6 +584,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
const deleteEdgesById = useCallback((edgeIds: string[]) => {
if (edgeIds.length === 0) return
setEdges(currentEdges => currentEdges.filter(edge => !edgeIds.includes(edge.id)))
setSelectedEdgeIds(currentIds => currentIds.filter(edgeId => !edgeIds.includes(edgeId)))
setSelectedNodeId(null)
setNodeMenuAnchor(null)
toast.success(edgeIds.length === 1 ? 'Connection deleted' : `${edgeIds.length} connections deleted`)
@@ -348,6 +606,27 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
deleteEdgesById([edge.id])
}, [deleteEdgesById])
const handleSelectionChange = useCallback(
({ nodes: selectedNodes, edges: selectedEdges }: { nodes: Node[]; edges: Edge[] }) => {
if (selectedNodes.length > 0) {
setSelectedNodeId(selectedNodes[0].id)
setSelectedEdgeIds(selectedEdges.map(edge => edge.id))
setActiveUtilityTab('inspector')
return
}
if (selectedEdges.length > 0) {
setSelectedNodeId(null)
setSelectedEdgeIds(selectedEdges.map(edge => edge.id))
return
}
setSelectedNodeId(null)
setSelectedEdgeIds([])
},
[],
)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
@@ -383,8 +662,8 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
toast.error('Resolve workflow validation errors before saving.')
return
}
onSave(buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode))
}, [edges, executionMode, nodes, onSave, validation.errors.length, workflow])
onSave(currentWorkflowConfig)
}, [currentWorkflowConfig, onSave, validation.errors.length])
const handleDispatch = useCallback(() => {
if (!dispatchContextId.trim()) {
@@ -395,11 +674,15 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
toast.error('Resolve workflow validation errors before dispatching.')
return
}
if (!hasFreshSuccessfulPreflight) {
toast.error('Run a fresh Dry Run for the current graph and context before dispatching.')
return
}
dispatchMutation.mutate({
contextId: dispatchContextId.trim(),
config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
config: currentWorkflowConfig,
})
}, [dispatchContextId, dispatchMutation, edges, executionMode, nodes, validation.errors.length, workflow])
}, [currentWorkflowConfig, dispatchContextId, dispatchMutation, hasFreshSuccessfulPreflight, validation.errors.length])
const handlePreflight = useCallback(() => {
if (!dispatchContextId.trim()) {
@@ -412,14 +695,21 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
}
preflightMutation.mutate({
contextId: dispatchContextId.trim(),
config: buildCurrentWorkflowConfig(workflow, nodes, edges, executionMode),
config: currentWorkflowConfig,
})
}, [dispatchContextId, edges, executionMode, nodes, preflightMutation, validation.errors.length, workflow])
}, [currentWorkflowConfig, dispatchContextId, preflightMutation, validation.errors.length])
const selectedNode = useMemo(
() => nodes.find(node => node.id === selectedNodeId),
[nodes, selectedNodeId],
)
const preflightState = useMemo<'ready' | 'required' | 'stale' | 'blocked'>(() => {
if (!dispatchContextId.trim()) return 'required'
if (preflightResult && !preflightResult.graph_dispatch_allowed) return 'blocked'
if (hasFreshSuccessfulPreflight) return 'ready'
if (preflightResult) return 'stale'
return 'required'
}, [dispatchContextId, hasFreshSuccessfulPreflight, preflightResult])
return {
reactFlowWrapper,
@@ -439,7 +729,15 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
preflightMutation,
dispatchContextId,
setDispatchContextId,
isOrderLineGraph,
isOrderLineContextsLoading,
orderLineContextGroups,
dispatchContextLabel,
dispatchContextSummary,
dispatchContextMeta,
preflightResult,
preflightState,
hasFreshSuccessfulPreflight,
executionMode,
setExecutionMode,
nodeMenuAnchor,
@@ -447,6 +745,7 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
activeUtilityTab,
setActiveUtilityTab,
validation,
authoringFamily,
graphFamily,
onConnect,
onNodeClick,
@@ -457,11 +756,14 @@ export function useWorkflowCanvasController({ workflow, onSave }: UseWorkflowCan
handlePaneContextMenu,
handleNodeContextMenu,
insertNode,
insertModuleBundle,
insertReferenceBundle,
handleOpenToolbarNodeMenu,
handleAutoLayout,
handleDeleteSelectedEdges,
onEdgeContextMenu,
onEdgeDoubleClick,
handleSelectionChange,
handleSave,
handleDispatch,
handlePreflight,
@@ -0,0 +1,79 @@
import { Library, Sparkles, type LucideIcon } from 'lucide-react'
import type { WorkflowModuleBundleId } from './workflowModuleBundles'
import type { WorkflowReferenceBundleId } from './workflowReferenceBundles'
import type { WorkflowAuthoringSurfaceModel } from './workflowAuthoringSurface'
export type WorkflowAuthoringPosition = {
x: number
y: number
}
export type WorkflowAuthoringActions = {
openNodeMenu?: () => void
insertNode?: (step: string, preferredPosition?: WorkflowAuthoringPosition) => void
insertModule?: (bundleId: WorkflowModuleBundleId, preferredPosition?: WorkflowAuthoringPosition) => void
insertReferencePath?: (bundleId: WorkflowReferenceBundleId, preferredPosition?: WorkflowAuthoringPosition) => void
}
export type WorkflowAuthoringInsertHandlers = {
onSelectStep?: (step: string) => void
onInsertModule?: (bundleId: WorkflowModuleBundleId) => void
onInsertReferencePath?: (bundleId: WorkflowReferenceBundleId) => void
}
type BindWorkflowAuthoringInsertActionsOptions = {
preferredPosition?: WorkflowAuthoringPosition
onAfterInsert?: () => void
}
export type WorkflowAuthoringEntryAction = {
label: string
title: string
helper: string
icon: LucideIcon
}
function wrapInsertAction<TArg>(
action: ((arg: TArg, preferredPosition?: WorkflowAuthoringPosition) => void) | undefined,
preferredPosition?: WorkflowAuthoringPosition,
onAfterInsert?: () => void,
) {
if (!action) return undefined
return (arg: TArg) => {
action(arg, preferredPosition)
onAfterInsert?.()
}
}
export function bindWorkflowAuthoringInsertActions(
actions: WorkflowAuthoringActions | undefined,
{ preferredPosition, onAfterInsert }: BindWorkflowAuthoringInsertActionsOptions = {},
): WorkflowAuthoringInsertHandlers {
return {
onSelectStep: wrapInsertAction(actions?.insertNode, preferredPosition, onAfterInsert),
onInsertModule: wrapInsertAction(actions?.insertModule, preferredPosition, onAfterInsert),
onInsertReferencePath: wrapInsertAction(actions?.insertReferencePath, preferredPosition, onAfterInsert),
}
}
export function getWorkflowAuthoringEntryAction(
surfaceModel: WorkflowAuthoringSurfaceModel,
): WorkflowAuthoringEntryAction {
if (surfaceModel.defaultSection === 'overview') {
return {
label: 'Author',
title: 'Open guided workflow authoring browser',
helper: 'Open reference paths, production modules, starter steps, and raw nodes.',
icon: Sparkles,
}
}
return {
label: 'Node',
title: 'Open raw node browser',
helper: 'Open the searchable node catalog directly on the canvas.',
icon: Library,
}
}
@@ -0,0 +1,296 @@
import type { WorkflowNodeDefinition } from '../../api/workflows'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import { getWorkflowModuleBundles, type WorkflowModuleBundleDefinition, type WorkflowModuleBundleId } from './workflowModuleBundles'
import {
getWorkflowReferenceBundles,
type WorkflowReferenceBundleDefinition,
type WorkflowReferenceBundleId,
} from './workflowReferenceBundles'
export const STARTER_NODE_STEP_ORDER: Record<WorkflowGraphFamily, string[]> = {
cad_file: [
'resolve_step_path',
'occ_object_extract',
'occ_glb_export',
'stl_cache_generate',
'thumbnail_save',
],
order_line: [
'order_line_setup',
'resolve_template',
'auto_populate_materials',
'glb_bbox',
'material_map_resolve',
'blender_still',
'output_save',
'notify',
],
mixed: [],
}
export const STARTER_PATH_TITLES: Record<WorkflowGraphFamily, string> = {
cad_file: 'CAD intake assembly',
order_line: 'Still-render assembly',
mixed: 'Starter path',
}
export const STARTER_PATH_DESCRIPTIONS: Record<WorkflowGraphFamily, string> = {
cad_file: 'Use this module sequence to take a CAD file through extraction, preview generation, and published output.',
order_line: 'Use this module sequence to keep the non-legacy still graph parallel to the legacy render path.',
mixed: 'Reference sequence for assembling a workflow graph.',
}
export type WorkflowAuthoringPriority = {
title: string
description: string
}
export type WorkflowAuthoringFlowStep = {
index: number
title: string
description: string
}
export type WorkflowAuthoringBundleStatus<TBundle> = TBundle & {
presentCount: number
totalCount: number
}
export type WorkflowStarterPathItem = {
index: number
definition: WorkflowNodeDefinition
isPresent: boolean
}
export type WorkflowAuthoringStageProgress = {
id: string
title: string
description: string
present: number
total: number
actionLabel: string
actionKind: 'reference' | 'module' | 'step'
bundleId?: WorkflowReferenceBundleId | WorkflowModuleBundleId
step?: string
}
export type WorkflowAuthoringPlan = {
title: string
description: string
priorities: WorkflowAuthoringPriority[]
authoringFlow: WorkflowAuthoringFlowStep[]
referenceBundles: WorkflowAuthoringBundleStatus<WorkflowReferenceBundleDefinition>[]
moduleBundles: WorkflowAuthoringBundleStatus<WorkflowModuleBundleDefinition>[]
starterTitle: string
starterDescription: string
starterItems: WorkflowStarterPathItem[]
starterCompletedCount: number
stageProgress: WorkflowAuthoringStageProgress[]
gapFillDefinitions: WorkflowNodeDefinition[]
}
function haveSameStepSet(left: string[], right: string[]) {
if (left.length !== right.length) return false
const rightSet = new Set(right)
return left.every(step => rightSet.has(step))
}
function getAuthoringPriorities(graphFamily: WorkflowGraphFamily): WorkflowAuthoringPriority[] {
if (graphFamily === 'order_line') {
return [
{
title: 'Start with the Still Render Reference path',
description: 'Insert the full non-legacy still production baseline first, then tune modules or individual nodes.',
},
{
title: 'Swap stages with production modules',
description: 'Use render and publish bundles to change whole stages without breaking graph-safe sequencing.',
},
{
title: 'Use raw nodes last',
description: 'Drop to legacy, bridge, or native graph nodes only after the production path already exists on canvas.',
},
]
}
if (graphFamily === 'cad_file') {
return [
{
title: 'Start with the CAD intake assembly',
description: 'Build the import and preview chain first so downstream consumers always receive consistent artifacts.',
},
{
title: 'Extend with stage bundles',
description: 'Use reusable intake modules before inserting isolated conversion or utility nodes.',
},
{
title: 'Use raw nodes last',
description: 'Only drop to individual nodes for edge cases or targeted debugging once the intake baseline is present.',
},
]
}
return [
{
title: 'Split the graph by responsibility',
description: 'Keep mixed graphs organized by building complete stages first, then add isolated nodes only for orchestration glue.',
},
{
title: 'Prefer reusable modules over hand-wiring',
description: 'Use grouped bundles whenever a stage can be expressed as a reusable production slice.',
},
{
title: 'Use raw nodes last',
description: 'Reach for the full catalog only after the baseline path is readable and stable.',
},
]
}
export function getWorkflowAuthoringFlow(graphFamily: WorkflowGraphFamily): WorkflowAuthoringFlowStep[] {
const starterPathLabel = graphFamily === 'mixed' ? 'Starter Path' : STARTER_PATH_TITLES[graphFamily]
return [
{
index: 1,
title: 'Reference Path',
description:
graphFamily === 'order_line'
? 'Start from the canonical non-legacy still graph before changing individual stages.'
: 'Start from the canonical path when you want a full baseline instead of assembling from scratch.',
},
{
index: 2,
title: 'Production Modules',
description: 'Swap or extend whole production stages with reusable graph-safe bundles.',
},
{
index: 3,
title: starterPathLabel,
description: 'Fill gaps one required step at a time and verify the minimum viable chain stays intact.',
},
{
index: 4,
title: 'Raw Node Catalog',
description: 'Use individual nodes only for advanced authoring, experiments, or edge-case overrides.',
},
]
}
export function getWorkflowAuthoringPlan(
definitions: WorkflowNodeDefinition[],
graphFamily: WorkflowGraphFamily,
activeSteps: string[],
): WorkflowAuthoringPlan {
const activeStepSet = new Set(activeSteps)
const definitionsByStep = new Map(definitions.map(definition => [definition.step, definition]))
const title = graphFamily === 'mixed' ? 'Guided Authoring' : STARTER_PATH_TITLES[graphFamily]
const description =
graphFamily === 'mixed'
? 'Keep the graph readable by preferring complete paths and bundles before raw node-level edits.'
: STARTER_PATH_DESCRIPTIONS[graphFamily]
const referenceBundles = getWorkflowReferenceBundles(definitions, graphFamily)
.map(bundle => ({
...bundle,
presentCount: bundle.stepIds.filter(step => activeStepSet.has(step)).length,
totalCount: bundle.stepIds.length,
}))
const moduleBundles = getWorkflowModuleBundles(definitions, graphFamily)
.map(bundle => ({
...bundle,
presentCount: bundle.stepIds.filter(step => activeStepSet.has(step)).length,
totalCount: bundle.stepIds.length,
}))
const starterDefinitions =
graphFamily === 'mixed'
? []
: STARTER_NODE_STEP_ORDER[graphFamily]
.map(step => definitionsByStep.get(step))
.filter((definition): definition is WorkflowNodeDefinition => Boolean(definition))
const starterItems = starterDefinitions.map((definition, index) => ({
index: index + 1,
definition,
isPresent: activeStepSet.has(definition.step),
}))
const starterCompletedCount = starterItems.filter(item => item.isPresent).length
const gapFillDefinitions = starterItems
.filter(item => !item.isPresent)
.map(item => item.definition)
.slice(0, 3)
const stageProgress = (() => {
if (graphFamily === 'mixed') return [] as WorkflowAuthoringStageProgress[]
const stages: WorkflowAuthoringStageProgress[] = []
const referenceBundle = referenceBundles[0]
if (referenceBundle) {
stages.push({
id: referenceBundle.id,
title: referenceBundle.label,
description: referenceBundle.description,
present: referenceBundle.presentCount,
total: referenceBundle.totalCount,
actionLabel:
referenceBundle.presentCount === 0
? `Insert ${referenceBundle.shortLabel}`
: `Reapply ${referenceBundle.shortLabel}`,
actionKind: 'reference',
bundleId: referenceBundle.id,
})
}
for (const bundle of moduleBundles) {
if (referenceBundle && haveSameStepSet(bundle.stepIds, referenceBundle.stepIds)) {
continue
}
if (bundle.presentCount === bundle.totalCount) continue
stages.push({
id: bundle.id,
title: bundle.label,
description: bundle.description,
present: bundle.presentCount,
total: bundle.totalCount,
actionLabel:
bundle.presentCount === 0
? `Insert ${bundle.shortLabel}`
: `Complete ${bundle.shortLabel}`,
actionKind: 'module',
bundleId: bundle.id,
})
}
const nextMissingStarterDefinition = starterItems.find(item => !item.isPresent)?.definition
if (nextMissingStarterDefinition) {
stages.push({
id: nextMissingStarterDefinition.step,
title: `Next missing step: ${nextMissingStarterDefinition.label}`,
description: 'Use a single-step insert only when you need to patch a gap without dropping an entire stage bundle.',
present: 0,
total: 1,
actionLabel: `Add ${nextMissingStarterDefinition.label}`,
actionKind: 'step',
step: nextMissingStarterDefinition.step,
})
}
return stages
})()
return {
title,
description,
priorities: getAuthoringPriorities(graphFamily),
authoringFlow: getWorkflowAuthoringFlow(graphFamily),
referenceBundles,
moduleBundles,
starterTitle: STARTER_PATH_TITLES[graphFamily],
starterDescription: STARTER_PATH_DESCRIPTIONS[graphFamily],
starterItems,
starterCompletedCount,
stageProgress,
gapFillDefinitions,
}
}
@@ -0,0 +1,81 @@
import {
Boxes,
Compass,
Library,
Milestone,
type LucideIcon,
} from 'lucide-react'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
export type WorkflowAuthoringSection = 'overview' | 'paths' | 'modules' | 'starter' | 'nodes'
type WorkflowAuthoringSectionConfig = {
key: WorkflowAuthoringSection
label: string
helper: string
icon: LucideIcon
}
type WorkflowAuthoringSectionOptions = {
graphFamily: WorkflowGraphFamily
includeOverview?: boolean
hasReferencePaths?: boolean
hasModules?: boolean
hasStarter?: boolean
}
export function getWorkflowAuthoringSections({
graphFamily,
includeOverview = false,
hasReferencePaths = false,
hasModules = false,
hasStarter = false,
}: WorkflowAuthoringSectionOptions): WorkflowAuthoringSectionConfig[] {
const sections: WorkflowAuthoringSectionConfig[] = []
if (includeOverview) {
sections.push({
key: 'overview',
label: 'Overview',
helper: 'Start with guided authoring modes before dropping to raw nodes.',
icon: Compass,
})
}
if (hasReferencePaths) {
sections.push({
key: 'paths',
label: 'Paths',
helper: 'Insert complete canonical production paths.',
icon: Milestone,
})
}
if (hasModules) {
sections.push({
key: 'modules',
label: 'Modules',
helper: 'Insert reusable production bundles.',
icon: Boxes,
})
}
if (hasStarter || graphFamily !== 'mixed') {
sections.push({
key: 'starter',
label: 'Starter',
helper: 'Follow the canonical family-safe assembly path.',
icon: Milestone,
})
}
sections.push({
key: 'nodes',
label: 'Nodes',
helper: 'Browse the full node catalog.',
icon: Library,
})
return sections
}
@@ -0,0 +1,145 @@
import { useEffect, useMemo, useState } from 'react'
import type { WorkflowNodeDefinition } from '../../api/workflows'
import {
bindWorkflowAuthoringInsertActions,
type WorkflowAuthoringActions,
type WorkflowAuthoringInsertHandlers,
type WorkflowAuthoringPosition,
} from './workflowAuthoringActions'
import { getWorkflowAuthoringPlan, type WorkflowAuthoringPlan } from './workflowAuthoringGuidance'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
import {
getWorkflowAuthoringSections,
type WorkflowAuthoringSection,
} from './workflowAuthoringSections'
type WorkflowAuthoringSurfaceModelOptions = {
definitions: WorkflowNodeDefinition[]
graphFamily: WorkflowGraphFamily
activeSteps: string[]
}
export type WorkflowAuthoringSurfaceModel = {
defaultSection: WorkflowAuthoringSection
sections: ReturnType<typeof getWorkflowAuthoringSections>
plan: WorkflowAuthoringPlan
}
type UseWorkflowAuthoringSurfaceOptions = WorkflowAuthoringSurfaceModelOptions & {
actions?: WorkflowAuthoringActions
preferredPosition?: WorkflowAuthoringPosition
onAfterInsert?: () => void
}
export type WorkflowAuthoringSurfaceController = WorkflowAuthoringSurfaceModel & {
activeSection: WorkflowAuthoringSection
activeSectionMeta: WorkflowAuthoringSurfaceModel['sections'][number] | null
insertBindings: WorkflowAuthoringInsertHandlers
setActiveSection: (section: WorkflowAuthoringSection) => void
}
export function getDefaultWorkflowAuthoringSection(
graphFamily: WorkflowGraphFamily,
): WorkflowAuthoringSection {
return graphFamily === 'mixed' ? 'nodes' : 'overview'
}
export function getWorkflowAuthoringSurfaceModel({
definitions,
graphFamily,
activeSteps,
}: WorkflowAuthoringSurfaceModelOptions): WorkflowAuthoringSurfaceModel {
const plan = getWorkflowAuthoringPlan(definitions, graphFamily, activeSteps)
const defaultSection = getDefaultWorkflowAuthoringSection(graphFamily)
const sections = getWorkflowAuthoringSections({
graphFamily,
includeOverview: graphFamily !== 'mixed',
hasReferencePaths: plan.referenceBundles.length > 0,
hasModules: plan.moduleBundles.length > 0,
hasStarter: graphFamily !== 'mixed' && plan.starterItems.length > 0,
})
return {
defaultSection,
sections,
plan,
}
}
export function getWorkflowAuthoringSurfaceSections({
definitions,
graphFamily,
activeSteps,
}: WorkflowAuthoringSurfaceModelOptions) {
return getWorkflowAuthoringSurfaceModel({
definitions,
graphFamily,
activeSteps,
}).sections
}
export function resolveWorkflowAuthoringSection(
activeSection: WorkflowAuthoringSection,
sections: WorkflowAuthoringSurfaceModel['sections'],
defaultSection: WorkflowAuthoringSection,
): WorkflowAuthoringSection {
if (sections.some(section => section.key === activeSection)) {
return activeSection
}
if (sections.some(section => section.key === defaultSection)) {
return defaultSection
}
return sections[0]?.key ?? 'nodes'
}
export function useWorkflowAuthoringSurface({
definitions,
graphFamily,
activeSteps,
actions,
preferredPosition,
onAfterInsert,
}: UseWorkflowAuthoringSurfaceOptions): WorkflowAuthoringSurfaceController {
const surfaceModel = useMemo(
() => getWorkflowAuthoringSurfaceModel({ definitions, graphFamily, activeSteps }),
[activeSteps, definitions, graphFamily],
)
const { defaultSection, plan, sections } = surfaceModel
const insertBindings = useMemo(
() =>
bindWorkflowAuthoringInsertActions(actions, {
preferredPosition,
onAfterInsert,
}),
[actions, onAfterInsert, preferredPosition],
)
const [activeSection, setActiveSection] = useState<WorkflowAuthoringSection>(defaultSection)
useEffect(() => {
setActiveSection(defaultSection)
}, [defaultSection])
useEffect(() => {
setActiveSection(currentSection =>
resolveWorkflowAuthoringSection(currentSection, sections, defaultSection),
)
}, [defaultSection, sections])
const activeSectionMeta = useMemo(
() => sections.find(section => section.key === activeSection) ?? null,
[activeSection, sections],
)
return {
defaultSection,
sections,
plan,
activeSection,
activeSectionMeta,
insertBindings,
setActiveSection,
}
}
@@ -24,11 +24,22 @@ export function inferWorkflowFamily(
): WorkflowGraphFamily {
const nodes = Array.isArray(config.nodes) ? config.nodes : []
if (nodes.length > 0) {
const families = new Set(nodes.map(node => getNodeFamily(node.step, nodeDefinitionsByStep)))
const families = new Set(
nodes
.map(node => getNodeFamily(node.step, nodeDefinitionsByStep))
.filter((family): family is Exclude<ReturnType<typeof getNodeFamily>, 'shared'> => family !== 'shared'),
)
if (families.size === 1) {
return Array.from(families)[0]
}
return 'mixed'
if (families.size > 1) {
return 'mixed'
}
}
const configuredFamily = config.ui?.family
if (configuredFamily === 'cad_file' || configuredFamily === 'order_line' || configuredFamily === 'mixed') {
return configuredFamily
}
const presetType = config.ui?.preset ?? 'custom'
@@ -8,7 +8,7 @@ import type {
WorkflowNodeDefinition,
WorkflowParams,
} from '../../api/workflows'
import { getNodeFamily, type WorkflowNodeDefinitionMap } from './workflowNodeLibrary'
import { getNodeFamily, type WorkflowGraphFamily, type WorkflowNodeDefinitionMap } from './workflowNodeLibrary'
export type WorkflowCanvasNodeData = {
label: string
@@ -17,6 +17,21 @@ export type WorkflowCanvasNodeData = {
description?: string
icon?: string
category?: StepCategory
inputContextLabel?: string | null
outputContextLabel?: string | null
inputPorts?: WorkflowCanvasPort[]
outputPorts?: WorkflowCanvasPort[]
requiredAnyInputs?: string[][]
editableFieldCount?: number
editableFieldLabels?: string[]
dynamicVariableHint?: string | null
}
export type WorkflowCanvasPort = {
id: string
label: string
roles: string[]
kind: 'required' | 'alternative' | 'provided'
}
export type WorkflowValidationResult = {
@@ -24,12 +39,21 @@ export type WorkflowValidationResult = {
warnings: string[]
}
export const WORKFLOW_NODE_WIDTH = 240
export const WORKFLOW_NODE_MIN_HEIGHT = 188
export const WORKFLOW_NODE_HORIZONTAL_GAP = 72
export const WORKFLOW_NODE_VERTICAL_GAP = 44
export const WORKFLOW_LAYOUT_PADDING_X = 56
export const WORKFLOW_LAYOUT_PADDING_Y = 48
type WorkflowNodeContractContext = 'cad_file' | 'order_line'
type WorkflowSemanticState = {
availableValues: Set<string>
executedSteps: Set<string>
}
const TEMPLATE_INPUT_PARAM_PREFIX = 'template_input__'
const CAD_FILE_ENTRY_STEPS = new Set([
'resolve_step_path',
'occ_object_extract',
@@ -44,7 +68,6 @@ const ORDER_LINE_SETUP_REQUIRED_STEPS = new Set([
'resolve_template',
'material_map_resolve',
'auto_populate_materials',
'glb_bbox',
'blender_still',
'blender_turntable',
'output_save',
@@ -67,6 +90,20 @@ const ROOT_CONTEXT_VALUES: Record<WorkflowNodeContractContext, string[]> = {
const ORDER_LINE_SETUP_COMPATIBILITY_VALUES = ['cad_materials', 'glb_preview', 'bbox']
const OUTPUT_SAVE_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video']
const NOTIFY_ALTERNATIVE_INPUTS = ['rendered_image', 'rendered_frames', 'rendered_video', 'workflow_result', 'blend_asset']
const CONTRACT_TOKEN_LABELS: Record<string, string> = {
api: 'API',
bbox: 'Bounding Box',
cad: 'CAD',
fps: 'FPS',
glb: 'GLB',
gpu: 'GPU',
id: 'ID',
occ: 'OCC',
step: 'STEP',
stl: 'STL',
usd: 'USD',
}
function getContractContext(
contract: Record<string, unknown> | undefined,
@@ -75,6 +112,14 @@ function getContractContext(
return value === 'cad_file' || value === 'order_line' ? value : null
}
export function getContractContextLabel(
contract: Record<string, unknown> | undefined,
): string | null {
const context = getContractContext(contract)
if (!context) return null
return context === 'cad_file' ? 'CAD File' : 'Order Line'
}
function getContractValues(
contract: Record<string, unknown> | undefined,
key: string,
@@ -101,13 +146,222 @@ function getContractAlternativeValues(
.filter(group => group.length > 0)
}
function formatContractValue(value: string): string {
export function formatContractValue(value: string): string {
return value
.split('_')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.map(part => {
const normalized = part.trim().toLowerCase()
if (!normalized) return ''
return CONTRACT_TOKEN_LABELS[normalized] ?? (normalized.charAt(0).toUpperCase() + normalized.slice(1))
})
.filter(part => part.length > 0)
.join(' ')
}
function dedupeRoles(values: string[]): string[] {
return Array.from(new Set(values.filter(value => value.trim().length > 0)))
}
function createPortId(prefix: string, roles: string[]): string {
return `${prefix}:${roles.join('|')}`
}
function formatAlternativePortLabel(roles: string[]): string {
if (roles.length === 1) return formatContractValue(roles[0])
return `Any of ${roles.map(formatContractValue).join(' / ')}`
}
function getNodeAlternativeInputGroups(definition: WorkflowNodeDefinition | undefined): string[][] {
if (!definition) return []
const alternativeGroups = getContractAlternativeValues(
definition.input_contract as Record<string, unknown> | undefined,
'requires_any',
)
if (definition.step === 'output_save' && alternativeGroups.length === 0) {
alternativeGroups.push(OUTPUT_SAVE_ALTERNATIVE_INPUTS)
}
if (definition.step === 'notify') {
if (alternativeGroups.length === 0) {
alternativeGroups.push(NOTIFY_ALTERNATIVE_INPUTS)
} else {
alternativeGroups[0] = Array.from(new Set([...alternativeGroups[0], 'blend_asset']))
}
}
return alternativeGroups.map(group => dedupeRoles(group))
}
function getNodeRequiredInputRoles(definition: WorkflowNodeDefinition | undefined): string[] {
if (!definition) return []
const alternativeRoles = new Set(getNodeAlternativeInputGroups(definition).flat())
const directRoles = dedupeRoles([
...getContractValues(definition.input_contract as Record<string, unknown> | undefined, 'requires'),
...(definition.artifact_roles_consumed ?? []),
])
return directRoles.filter(role => !alternativeRoles.has(role))
}
function getNodeProvidedOutputRoles(definition: WorkflowNodeDefinition | undefined): string[] {
if (!definition) return []
return dedupeRoles([
...getContractValues(definition.output_contract as Record<string, unknown> | undefined, 'provides'),
...(definition.artifact_roles_produced ?? []),
])
}
function getNodeEditableFieldLabels(definition: WorkflowNodeDefinition | undefined): string[] {
if (!definition) return []
return Array.from(
new Set(
definition.fields
.map(field => field.label?.trim())
.filter((label): label is string => Boolean(label)),
),
)
}
function getDynamicVariableHint(definition: WorkflowNodeDefinition | undefined): string | null {
if (!definition) return null
const providedRoles = new Set(getNodeProvidedOutputRoles(definition))
if (providedRoles.has('workflow_input_schema') || providedRoles.has('template_inputs')) {
return 'Template-selected variables appear after choosing a template.'
}
return null
}
export function buildWorkflowCanvasNodeData(
step: string,
params: WorkflowParams = {},
definition?: WorkflowNodeDefinition,
overrides?: Partial<WorkflowCanvasNodeData>,
): WorkflowCanvasNodeData {
const requiredInputRoles = getNodeRequiredInputRoles(definition)
const alternativeInputGroups = getNodeAlternativeInputGroups(definition)
const providedOutputRoles = getNodeProvidedOutputRoles(definition)
return {
label: overrides?.label ?? definition?.label ?? inferNodeLabel(step),
params: normalizeWorkflowParams(params),
step,
description: overrides?.description ?? definition?.description,
icon: overrides?.icon ?? definition?.icon,
category: overrides?.category ?? definition?.category,
inputContextLabel: getContractContextLabel(definition?.input_contract as Record<string, unknown> | undefined),
outputContextLabel: getContractContextLabel(definition?.output_contract as Record<string, unknown> | undefined),
inputPorts: [
...requiredInputRoles.map(role => ({
id: createPortId('input', [role]),
label: formatContractValue(role),
roles: [role],
kind: 'required' as const,
})),
...alternativeInputGroups.map(group => ({
id: createPortId('input-any', group),
label: formatAlternativePortLabel(group),
roles: group,
kind: 'alternative' as const,
})),
],
outputPorts: providedOutputRoles.map(role => ({
id: createPortId('output', [role]),
label: formatContractValue(role),
roles: [role],
kind: 'provided' as const,
})),
requiredAnyInputs: alternativeInputGroups,
editableFieldCount: definition?.fields.length ?? 0,
editableFieldLabels: getNodeEditableFieldLabels(definition),
dynamicVariableHint: getDynamicVariableHint(definition),
}
}
function getCompatiblePortAssignments(
sourceData: WorkflowCanvasNodeData | undefined,
targetData: WorkflowCanvasNodeData | undefined,
preferred?: {
sourceHandle?: string | null
targetHandle?: string | null
},
usedTargetHandleIds?: Set<string>,
): {
sourceHandle?: string
targetHandle?: string
} {
const sourcePorts = sourceData?.outputPorts ?? []
const targetPorts = targetData?.inputPorts ?? []
if (sourcePorts.length === 0 || targetPorts.length === 0) {
return {}
}
const preferredTarget = preferred?.targetHandle
? targetPorts.find(port => port.id === preferred.targetHandle)
: undefined
const preferredSource = preferred?.sourceHandle
? sourcePorts.find(port => port.id === preferred.sourceHandle)
: undefined
if (preferredTarget && preferredSource) {
const matches = preferredTarget.roles.some(role => preferredSource.roles.includes(role))
if (matches) {
return {
sourceHandle: preferredSource.id,
targetHandle: preferredTarget.id,
}
}
}
if (preferredTarget) {
const matchingSource = sourcePorts.find(port =>
preferredTarget.roles.some(role => port.roles.includes(role)),
)
if (matchingSource) {
return {
sourceHandle: matchingSource.id,
targetHandle: preferredTarget.id,
}
}
}
const compatibleTargets = targetPorts
.filter(port =>
sourcePorts.some(sourcePort => port.roles.some(role => sourcePort.roles.includes(role))),
)
.sort((left, right) => {
const leftUsed = usedTargetHandleIds?.has(left.id) ? 1 : 0
const rightUsed = usedTargetHandleIds?.has(right.id) ? 1 : 0
return leftUsed - rightUsed
})
const resolvedTarget = compatibleTargets[0]
if (!resolvedTarget) {
return {}
}
const resolvedSource =
preferredSource && resolvedTarget.roles.some(role => preferredSource.roles.includes(role))
? preferredSource
: sourcePorts.find(port => resolvedTarget.roles.some(role => port.roles.includes(role)))
if (!resolvedSource) {
return {}
}
return {
sourceHandle: resolvedSource.id,
targetHandle: resolvedTarget.id,
}
}
function inferWorkflowContextKind(
nodes: Node[],
nodeDefinitionsByStep: WorkflowNodeDefinitionMap,
@@ -119,12 +373,17 @@ function inferWorkflowContextKind(
const step = ((node.data as WorkflowCanvasNodeData | undefined)?.step as string | undefined) ?? inferStepFromNodeType(node.type)
const definition = nodeDefinitionsByStep[step]
if (definition) {
families.add(definition.family)
if (definition.family === 'cad_file' || definition.family === 'order_line') {
families.add(definition.family)
}
continue
}
if (!definitionsLoaded) {
families.add(getNodeFamily(step, nodeDefinitionsByStep))
const fallbackFamily = getNodeFamily(step, nodeDefinitionsByStep)
if (fallbackFamily === 'cad_file' || fallbackFamily === 'order_line') {
families.add(fallbackFamily)
}
}
}
@@ -132,6 +391,54 @@ function inferWorkflowContextKind(
return Array.from(families)[0]
}
function readConfiguredWorkflowFamily(config: WorkflowConfig): WorkflowGraphFamily | null {
const family = config.ui?.family
return family === 'cad_file' || family === 'order_line' || family === 'mixed' ? family : null
}
function inferFallbackWorkflowFamily(
config: WorkflowConfig,
nodeDefinitionsByStep: WorkflowNodeDefinitionMap,
): WorkflowGraphFamily {
const configuredFamily = readConfiguredWorkflowFamily(config)
if (configuredFamily) return configuredFamily
const nodes = Array.isArray(config.nodes) ? config.nodes : []
if (nodes.length > 0) {
const families = new Set(
nodes
.map(node => getNodeFamily(node.step, nodeDefinitionsByStep))
.filter((family): family is WorkflowNodeContractContext => family === 'cad_file' || family === 'order_line'),
)
if (families.size === 1) {
return Array.from(families)[0]
}
if (families.size > 1) {
return 'mixed'
}
}
return config.ui?.preset === 'custom' ? 'mixed' : 'order_line'
}
export function deriveWorkflowAuthoringFamily(
workflow: WorkflowDefinition,
nodes: Node[],
nodeDefinitionsByStep: WorkflowNodeDefinitionMap,
definitionsLoaded: boolean,
): WorkflowGraphFamily {
const configuredFamily = readConfiguredWorkflowFamily(workflow.config)
if (configuredFamily === 'cad_file' || configuredFamily === 'order_line') {
return configuredFamily
}
const inferredFamily = inferWorkflowContextKind(nodes, nodeDefinitionsByStep, definitionsLoaded)
if (inferredFamily) return inferredFamily
if (configuredFamily === 'mixed') return 'mixed'
return inferFallbackWorkflowFamily(workflow.config, nodeDefinitionsByStep)
}
function getFieldKeys(definition: WorkflowNodeDefinition | undefined): Set<string> {
return new Set((definition?.fields ?? []).map(field => field.key))
}
@@ -158,6 +465,14 @@ export function resolveParamsForStepChange(
}
}
if (definition.step === 'resolve_template') {
for (const [key, value] of Object.entries(currentParams)) {
if (key.startsWith(TEMPLATE_INPUT_PARAM_PREFIX) && !isFieldValueEmpty(value)) {
nextParams[key] = value
}
}
}
return normalizeWorkflowParams(nextParams)
}
@@ -384,6 +699,12 @@ export function validateWorkflowDraft(
if (step === 'notify') {
requiredValues.delete('workflow_result')
requiredValues.delete('blend_asset')
if (alternativeRequiredGroups.length === 0) {
alternativeRequiredGroups.push(NOTIFY_ALTERNATIVE_INPUTS)
} else if (alternativeRequiredGroups.length > 0) {
alternativeRequiredGroups[0] = Array.from(new Set([...alternativeRequiredGroups[0], 'blend_asset']))
}
}
const compatibilityValues = new Set<string>()
@@ -391,6 +712,9 @@ export function validateWorkflowDraft(
for (const value of ORDER_LINE_SETUP_COMPATIBILITY_VALUES) {
compatibilityValues.add(value)
}
if (definition.legacy_compatible) {
compatibilityValues.add('material_assignments')
}
}
for (const group of alternativeRequiredGroups) {
@@ -437,24 +761,51 @@ export function workflowToGraph(
config: WorkflowConfig,
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>,
): { nodes: Node[]; edges: Edge[] } {
const nodes = config.nodes.map(node => ({
id: node.id,
type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step),
position: node.ui?.position ?? { x: 0, y: 0 },
data: buildWorkflowCanvasNodeData(
node.step,
node.params ?? {},
nodeDefinitionsByStep[node.step],
{ label: node.ui?.label },
) satisfies WorkflowCanvasNodeData,
}))
const nodeById = new Map(nodes.map(node => [node.id, node]))
const usedTargetHandlesByNodeId = new Map<string, Set<string>>()
return {
nodes: config.nodes.map(node => ({
id: node.id,
type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step),
position: node.ui?.position ?? { x: 0, y: 0 },
data: {
label: node.ui?.label ?? nodeDefinitionsByStep[node.step]?.label ?? inferNodeLabel(node.step),
params: normalizeWorkflowParams(node.params ?? {}),
step: node.step,
description: nodeDefinitionsByStep[node.step]?.description,
icon: nodeDefinitionsByStep[node.step]?.icon,
category: nodeDefinitionsByStep[node.step]?.category,
} satisfies WorkflowCanvasNodeData,
})),
nodes,
edges: config.edges.map((edge, index) => ({
id: `e_${edge.from}_${edge.to}_${index}`,
source: edge.from,
target: edge.to,
...(() => {
const sourceNode = nodeById.get(edge.from)
const targetNode = nodeById.get(edge.to)
const usedTargetHandles =
usedTargetHandlesByNodeId.get(edge.to) ??
(() => {
const nextSet = new Set<string>()
usedTargetHandlesByNodeId.set(edge.to, nextSet)
return nextSet
})()
const handles = getCompatiblePortAssignments(
sourceNode?.data as WorkflowCanvasNodeData | undefined,
targetNode?.data as WorkflowCanvasNodeData | undefined,
undefined,
usedTargetHandles,
)
if (handles.targetHandle) {
usedTargetHandles.add(handles.targetHandle)
}
return {
id: `e_${edge.from}_${edge.to}_${index}`,
source: edge.from,
target: edge.to,
sourceHandle: handles.sourceHandle,
targetHandle: handles.targetHandle,
}
})(),
})),
}
}
@@ -464,12 +815,19 @@ export function buildCurrentWorkflowConfig(
nodes: Node[],
edges: Edge[],
executionMode: WorkflowExecutionMode,
authoringFamily?: WorkflowGraphFamily,
): WorkflowConfig {
const nextFamily =
authoringFamily ??
readConfiguredWorkflowFamily(workflow.config) ??
inferFallbackWorkflowFamily(workflow.config, {})
return {
version: workflow.config.version ?? 1,
ui: {
...(workflow.config.ui ?? {}),
execution_mode: executionMode,
family: nextFamily,
},
nodes: nodes.map(node => {
const step =
@@ -499,28 +857,238 @@ export function buildCurrentWorkflowConfig(
}
}
type WorkflowNodeBounds = {
left: number
right: number
top: number
bottom: number
}
function getNodeBounds(position: { x: number; y: number }): WorkflowNodeBounds {
return {
left: position.x,
right: position.x + WORKFLOW_NODE_WIDTH,
top: position.y,
bottom: position.y + WORKFLOW_NODE_MIN_HEIGHT,
}
}
function nodesOverlapAtPosition(
position: { x: number; y: number },
otherPosition: { x: number; y: number },
): boolean {
const a = getNodeBounds(position)
const b = getNodeBounds(otherPosition)
return !(
a.right + WORKFLOW_NODE_HORIZONTAL_GAP <= b.left ||
b.right + WORKFLOW_NODE_HORIZONTAL_GAP <= a.left ||
a.bottom + WORKFLOW_NODE_VERTICAL_GAP <= b.top ||
b.bottom + WORKFLOW_NODE_VERTICAL_GAP <= a.top
)
}
export function graphNeedsAutoLayout(nodes: Node[]): boolean {
if (nodes.length <= 1) return false
const invalidPosition = nodes.some(
node =>
!Number.isFinite(node.position.x) ||
!Number.isFinite(node.position.y),
)
if (invalidPosition) return true
const uniquePositions = new Set(nodes.map(node => `${Math.round(node.position.x)}:${Math.round(node.position.y)}`))
if (uniquePositions.size <= Math.ceil(nodes.length / 2)) return true
for (let index = 0; index < nodes.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < nodes.length; otherIndex += 1) {
if (nodesOverlapAtPosition(nodes[index].position, nodes[otherIndex].position)) {
return true
}
}
}
return false
}
export function findOpenNodePosition(
nodes: Node[],
preferredPosition: { x: number; y: number },
): { x: number; y: number } {
const horizontalStep = WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP
const verticalStep = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP
const normalized = {
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, preferredPosition.x),
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, preferredPosition.y),
}
const isPositionFree = (candidate: { x: number; y: number }) =>
nodes.every(node => !nodesOverlapAtPosition(candidate, node.position))
if (isPositionFree(normalized)) {
return normalized
}
for (let radius = 0; radius < 12; radius += 1) {
const horizontalRange = radius + 1
const verticalRange = radius + 1
for (let row = -verticalRange; row <= verticalRange; row += 1) {
for (let column = -horizontalRange; column <= horizontalRange; column += 1) {
const candidate = {
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, normalized.x + column * horizontalStep),
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, normalized.y + row * verticalStep),
}
if (isPositionFree(candidate)) {
return candidate
}
}
}
}
return {
x: normalized.x + horizontalStep,
y: normalized.y + verticalStep,
}
}
export function shouldAutoLayoutAfterInsert(
nodes: Node[],
insertedNode: Node,
preferredPosition?: { x: number; y: number } | null,
): boolean {
if (graphNeedsAutoLayout(nodes)) return true
if (!preferredPosition) return false
const horizontalThreshold = Math.max(WORKFLOW_NODE_HORIZONTAL_GAP, WORKFLOW_NODE_WIDTH * 0.55)
const verticalThreshold = Math.max(WORKFLOW_NODE_VERTICAL_GAP, WORKFLOW_NODE_MIN_HEIGHT * 0.55)
return (
Math.abs(insertedNode.position.x - preferredPosition.x) > horizontalThreshold ||
Math.abs(insertedNode.position.y - preferredPosition.y) > verticalThreshold
)
}
type WorkflowCollisionDelta = {
x: number
y: number
}
function getCollisionResolutionDelta(
anchorPosition: { x: number; y: number },
overlappingPosition: { x: number; y: number },
): WorkflowCollisionDelta {
const anchor = getNodeBounds(anchorPosition)
const overlapping = getNodeBounds(overlappingPosition)
const anchorCenterX = (anchor.left + anchor.right) / 2
const anchorCenterY = (anchor.top + anchor.bottom) / 2
const overlappingCenterX = (overlapping.left + overlapping.right) / 2
const overlappingCenterY = (overlapping.top + overlapping.bottom) / 2
const sameColumnThreshold = (WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP) * 0.45
const sameColumn = Math.abs(anchorCenterX - overlappingCenterX) <= sameColumnThreshold
const pushRight = anchor.right + WORKFLOW_NODE_HORIZONTAL_GAP - overlapping.left
const pushLeft = anchor.left - WORKFLOW_NODE_HORIZONTAL_GAP - overlapping.right
const pushDown = anchor.bottom + WORKFLOW_NODE_VERTICAL_GAP - overlapping.top
const pushUp = anchor.top - WORKFLOW_NODE_VERTICAL_GAP - overlapping.bottom
const horizontalDelta = overlappingCenterX >= anchorCenterX ? pushRight : pushLeft
const verticalDelta = overlappingCenterY >= anchorCenterY ? pushDown : pushUp
const horizontalMagnitude = Math.abs(horizontalDelta)
const verticalMagnitude = Math.abs(verticalDelta)
if (sameColumn && verticalMagnitude <= horizontalMagnitude * 1.25) {
return { x: 0, y: verticalDelta }
}
if (horizontalMagnitude < verticalMagnitude) {
return { x: horizontalDelta, y: 0 }
}
return { x: 0, y: verticalDelta }
}
export function resolveNodeCollisions(nodes: Node[], anchorNodeIds: string[]): Node[] {
if (nodes.length <= 1 || anchorNodeIds.length === 0) return nodes
const nextNodes = nodes.map(node => ({ ...node, position: { ...node.position } }))
const nodeIndexById = new Map(nextNodes.map((node, index) => [node.id, index]))
const lockedNodeIds = new Set(anchorNodeIds.filter(nodeId => nodeIndexById.has(nodeId)))
if (lockedNodeIds.size === 0) return nextNodes
const queue = [...lockedNodeIds]
const maxIterations = nextNodes.length * nextNodes.length * 4
let iterations = 0
while (queue.length > 0 && iterations < maxIterations) {
const anchorNodeId = queue.shift()!
const anchorIndex = nodeIndexById.get(anchorNodeId)
if (anchorIndex === undefined) continue
const anchorNode = nextNodes[anchorIndex]
for (let index = 0; index < nextNodes.length; index += 1) {
const candidate = nextNodes[index]
if (candidate.id === anchorNode.id || lockedNodeIds.has(candidate.id)) continue
if (!nodesOverlapAtPosition(anchorNode.position, candidate.position)) continue
const delta = getCollisionResolutionDelta(anchorNode.position, candidate.position)
let candidatePosition = {
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, candidate.position.x + delta.x),
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, candidate.position.y + delta.y),
}
const nodesWithoutCandidate = nextNodes.filter(node => node.id !== candidate.id)
let attempts = 0
while (
nodesWithoutCandidate.some(node => nodesOverlapAtPosition(candidatePosition, node.position)) &&
attempts < 12
) {
candidatePosition = {
x: Math.max(WORKFLOW_LAYOUT_PADDING_X, candidatePosition.x + delta.x),
y: Math.max(WORKFLOW_LAYOUT_PADDING_Y, candidatePosition.y + delta.y),
}
attempts += 1
}
if (nodesWithoutCandidate.some(node => nodesOverlapAtPosition(candidatePosition, node.position))) {
candidatePosition = findOpenNodePosition(nodesWithoutCandidate, candidatePosition)
}
nextNodes[index] = {
...candidate,
position: candidatePosition,
}
queue.push(candidate.id)
iterations += 1
}
}
return nextNodes
}
export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
if (nodes.length === 0) return nodes
const HORIZONTAL_SPACING = 280
const VERTICAL_SPACING = 140
const PADDING_X = 48
const PADDING_Y = 48
const horizontalSpacing = WORKFLOW_NODE_WIDTH + WORKFLOW_NODE_HORIZONTAL_GAP
const verticalSpacing = WORKFLOW_NODE_MIN_HEIGHT + WORKFLOW_NODE_VERTICAL_GAP
const nodeById = new Map(nodes.map(node => [node.id, node]))
const inDegree = new Map<string, number>()
const adjacency = new Map<string, string[]>()
const parentsByNodeId = new Map<string, string[]>()
const layerByNodeId = new Map<string, number>()
for (const node of nodes) {
inDegree.set(node.id, 0)
adjacency.set(node.id, [])
parentsByNodeId.set(node.id, [])
layerByNodeId.set(node.id, 0)
}
for (const edge of edges) {
if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) continue
adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target])
parentsByNodeId.set(edge.target, [...(parentsByNodeId.get(edge.target) ?? []), edge.source])
inDegree.set(edge.target, (inDegree.get(edge.target) ?? 0) + 1)
}
@@ -561,7 +1129,62 @@ export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
layers.set(layer, [...(layers.get(layer) ?? []), node])
}
const positioned = new Map<string, Node>()
const sortedLayers = [...layers.entries()].sort(([a], [b]) => a - b)
for (const [layer, layerNodes] of sortedLayers) {
const sortedNodes = [...layerNodes].sort((a, b) => {
const aParents = parentsByNodeId.get(a.id) ?? []
const bParents = parentsByNodeId.get(b.id) ?? []
const averageParentY = (parentIds: string[], fallbackNode: Node) => {
if (parentIds.length === 0) return fallbackNode.position.y
const total = parentIds.reduce((sum, parentId) => {
const parentNode = positioned.get(parentId) ?? nodeById.get(parentId)
return sum + (parentNode?.position.y ?? fallbackNode.position.y)
}, 0)
return total / parentIds.length
}
return (
averageParentY(aParents, a) - averageParentY(bParents, b) ||
a.position.y - b.position.y ||
a.position.x - b.position.x
)
})
let nextY = WORKFLOW_LAYOUT_PADDING_Y
for (const node of sortedNodes) {
const parentIds = parentsByNodeId.get(node.id) ?? []
const parentCenterY =
parentIds.length === 0
? WORKFLOW_LAYOUT_PADDING_Y + WORKFLOW_NODE_MIN_HEIGHT / 2
: parentIds.reduce((sum, parentId) => {
const parentNode = positioned.get(parentId) ?? nodeById.get(parentId)
const parentY = parentNode?.position.y ?? WORKFLOW_LAYOUT_PADDING_Y
return sum + parentY + WORKFLOW_NODE_MIN_HEIGHT / 2
}, 0) / parentIds.length
const desiredTop = Math.max(
WORKFLOW_LAYOUT_PADDING_Y,
Math.round(parentCenterY - WORKFLOW_NODE_MIN_HEIGHT / 2),
)
const resolvedTop = Math.max(desiredTop, nextY)
positioned.set(node.id, {
...node,
position: {
x: WORKFLOW_LAYOUT_PADDING_X + layer * horizontalSpacing,
y: resolvedTop,
},
})
nextY = resolvedTop + verticalSpacing
}
}
return nodes.map(node => {
const laidOutNode = positioned.get(node.id)
if (laidOutNode) return laidOutNode
const layer = layerByNodeId.get(node.id) ?? 0
const layerNodes = [...(layers.get(layer) ?? [])].sort((a, b) => {
const aLabel = ((a.data as WorkflowCanvasNodeData | undefined)?.label as string | undefined) ?? a.id
@@ -573,8 +1196,8 @@ export function applyAutoLayout(nodes: Node[], edges: Edge[]) {
return {
...node,
position: {
x: PADDING_X + layer * HORIZONTAL_SPACING,
y: PADDING_Y + Math.max(index, 0) * VERTICAL_SPACING,
x: WORKFLOW_LAYOUT_PADDING_X + layer * horizontalSpacing,
y: WORKFLOW_LAYOUT_PADDING_Y + Math.max(index, 0) * verticalSpacing,
},
}
})
@@ -0,0 +1,156 @@
import type { Edge, Node } from '@xyflow/react'
import type { WorkflowNodeDefinition, WorkflowParams } from '../../api/workflows'
import { inferNodeLabel, inferNodeType, normalizeWorkflowParams, type WorkflowCanvasNodeData } from './workflowGraphDraft'
import {
AUTHORING_STAGE_LABELS,
type WorkflowAuthoringStage,
type WorkflowGraphFamily,
} from './workflowNodeLibrary'
export type WorkflowModuleBundleId =
| 'cad_intake_core'
| 'still_render_core'
| 'output_publish_notify'
export type WorkflowModuleBundleDefinition = {
id: WorkflowModuleBundleId
label: string
shortLabel: string
description: string
family: Exclude<WorkflowGraphFamily, 'mixed'>
stepIds: string[]
stage: string
stageId: WorkflowAuthoringStage
}
type WorkflowModuleBundleInsertionResult =
| {
ok: true
bundle: WorkflowModuleBundleDefinition
nodes: Node[]
edges: Edge[]
}
| {
ok: false
reason: string
}
const WORKFLOW_MODULE_BUNDLE_REGISTRY: WorkflowModuleBundleDefinition[] = [
{
id: 'cad_intake_core',
label: 'CAD Intake Core',
shortLabel: 'CAD Intake',
description: 'Resolve STEP, extract CAD structure, generate GLB, cache STL, and publish the preview thumbnail.',
family: 'cad_file',
stepIds: ['resolve_step_path', 'occ_object_extract', 'occ_glb_export', 'stl_cache_generate', 'thumbnail_save'],
stage: AUTHORING_STAGE_LABELS.cad_intake,
stageId: 'cad_intake',
},
{
id: 'still_render_core',
label: 'Still Render Core',
shortLabel: 'Still Render',
description: 'Prepare order-line context, resolve template and materials, compute geometry, and run the still render.',
family: 'order_line',
stepIds: ['order_line_setup', 'resolve_template', 'auto_populate_materials', 'glb_bbox', 'material_map_resolve', 'blender_still'],
stage: AUTHORING_STAGE_LABELS.render,
stageId: 'render',
},
{
id: 'output_publish_notify',
label: 'Publish And Notify',
shortLabel: 'Publish',
description: 'Persist the rendered output and emit downstream completion signals.',
family: 'order_line',
stepIds: ['output_save', 'notify'],
stage: AUTHORING_STAGE_LABELS.publish,
stageId: 'publish',
},
]
function buildNodeData(
step: string,
params: WorkflowParams = {},
definition?: WorkflowNodeDefinition,
): WorkflowCanvasNodeData {
return {
label: definition?.label ?? inferNodeLabel(step),
params: normalizeWorkflowParams(params),
step,
description: definition?.description,
icon: definition?.icon,
category: definition?.category,
}
}
export function getWorkflowModuleBundle(bundleId: WorkflowModuleBundleId) {
return WORKFLOW_MODULE_BUNDLE_REGISTRY.find(bundle => bundle.id === bundleId) ?? null
}
export function getWorkflowModuleBundles(
nodeDefinitions: WorkflowNodeDefinition[],
graphFamily: WorkflowGraphFamily,
): WorkflowModuleBundleDefinition[] {
const availableSteps = new Set(nodeDefinitions.map(definition => definition.step))
return WORKFLOW_MODULE_BUNDLE_REGISTRY.filter(bundle => {
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) return false
return bundle.stepIds.every(step => availableSteps.has(step))
})
}
export function createWorkflowModuleBundleInsertion(args: {
bundleId: WorkflowModuleBundleId
graphFamily: WorkflowGraphFamily
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>
existingNodes: Node[]
preferredPosition?: { x: number; y: number }
}): WorkflowModuleBundleInsertionResult {
const { bundleId, graphFamily, nodeDefinitionsByStep, existingNodes, preferredPosition } = args
const bundle = getWorkflowModuleBundle(bundleId)
if (!bundle) {
return { ok: false, reason: 'Unknown workflow module.' }
}
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) {
return { ok: false, reason: `${bundle.label} does not belong to the active authoring family.` }
}
const missingStep = bundle.stepIds.find(step => !nodeDefinitionsByStep[step])
if (missingStep) {
return { ok: false, reason: `Workflow module is missing definition for ${missingStep}.` }
}
const anchorX = preferredPosition?.x ?? (existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.x)) + 220 : 120)
const anchorY = preferredPosition?.y ?? (existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.y)) + 60 : 140)
const timestamp = Date.now()
const spacingX = 220
const nodes = bundle.stepIds.map((step, index) => {
const definition = nodeDefinitionsByStep[step]
return {
id: `${bundle.id}_${step}_${timestamp}_${index}`,
type: definition?.node_type ?? inferNodeType(step),
position: {
x: anchorX + index * spacingX,
y: anchorY,
},
data: buildNodeData(step, definition?.defaults ?? {}, definition),
} satisfies Node
})
const edges = nodes.slice(1).map((node, index) => ({
id: `${nodes[index].id}->${node.id}`,
source: nodes[index].id,
target: node.id,
}) satisfies Edge)
return {
ok: true,
bundle,
nodes,
edges,
}
}
@@ -1,14 +1,17 @@
import type { StepCategory, WorkflowNodeDefinition, WorkflowNodeFamily } from '../../api/workflows'
import {
AUTHORING_STAGE_ORDER,
compareNodeDefinitions,
getDefinitionAuthoringStage,
getDefinitionFamily,
getDefinitionModuleLabel,
getDefinitionModuleNamespace,
getDefinitionSearchText,
getPrimaryLibraryGroup,
getDefinitionModuleNamespace,
getDefinitionModuleLabel,
isDefinitionAllowedForGraphFamily,
matchesNodeKindFilter,
NODE_CATEGORY_ORDER,
type WorkflowAuthoringStage,
type WorkflowGraphFamily,
type WorkflowNodeFamilyFilter,
type WorkflowNodeKindFilter,
@@ -30,23 +33,92 @@ export type WorkflowNodeCatalogCategorySection = {
export type WorkflowNodeCatalogModuleSection = {
namespace: string
label: string
stage: WorkflowAuthoringStage
definitions: WorkflowNodeDefinition[]
categories: WorkflowNodeCatalogCategorySection[]
familyCounts: Record<WorkflowNodeFamily, number>
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
export type WorkflowNodeCatalogGroupSection = {
group: WorkflowNodeLibraryGroup
export type WorkflowNodeCatalogStageSection = {
stage: WorkflowAuthoringStage
definitions: WorkflowNodeDefinition[]
modules: WorkflowNodeCatalogModuleSection[]
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
export type WorkflowNodeCatalogModuleStageSection = {
stage: WorkflowAuthoringStage
definitions: WorkflowNodeDefinition[]
categories: WorkflowNodeCatalogCategorySection[]
}
export type WorkflowNodeCatalogFamilyModuleSection = {
namespace: string
label: string
definitions: WorkflowNodeDefinition[]
stages: WorkflowAuthoringStage[]
stageSections: WorkflowNodeCatalogModuleStageSection[]
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
export type WorkflowNodeCatalogFamilySection = {
family: WorkflowNodeFamily
definitions: WorkflowNodeDefinition[]
modules: WorkflowNodeCatalogFamilyModuleSection[]
stageSections: WorkflowNodeCatalogStageSection[]
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
export type WorkflowNodeCatalogModuleFilter = {
namespace: string
label: string
count: number
stages: WorkflowAuthoringStage[]
}
export type WorkflowNodeCatalogModel = {
definitions: WorkflowNodeDefinition[]
moduleFilters: WorkflowNodeCatalogModuleFilter[]
familySections: WorkflowNodeCatalogFamilySection[]
stageSections: WorkflowNodeCatalogStageSection[]
runtimeCounts: Record<WorkflowNodeLibraryGroup, number>
}
function buildModuleSection(
stage: WorkflowAuthoringStage,
moduleDefinitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogModuleSection {
const definitions = [...moduleDefinitions].sort(compareNodeDefinitions)
return {
namespace: getDefinitionModuleNamespace(definitions[0]),
label: getDefinitionModuleLabel(definitions[0]),
stage,
definitions,
categories: NODE_CATEGORY_ORDER.map(category => ({
category,
definitions: definitions.filter(definition => definition.category === category),
})).filter(section => section.definitions.length > 0),
familyCounts: {
cad_file: definitions.filter(definition => getDefinitionFamily(definition) === 'cad_file').length,
shared: definitions.filter(definition => getDefinitionFamily(definition) === 'shared').length,
order_line: definitions.filter(definition => getDefinitionFamily(definition) === 'order_line').length,
},
runtimeCounts: {
legacy: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
}
export function getAvailableFamilyFilters(
graphFamily: WorkflowGraphFamily,
): WorkflowNodeFamilyFilter[] {
return graphFamily === 'mixed'
? ['all', 'cad_file', 'order_line']
: [graphFamily]
? ['all', 'cad_file', 'shared', 'order_line']
: [graphFamily, 'shared']
}
export function filterWorkflowNodeDefinitions(
@@ -62,7 +134,12 @@ export function filterWorkflowNodeDefinitions(
return definitions
.filter(definition => isDefinitionAllowedForGraphFamily(definition, graphFamily))
.filter(definition => familyFilter === 'all' || getDefinitionFamily(definition) === familyFilter)
.filter(definition => {
if (familyFilter === 'all') return true
const family = getDefinitionFamily(definition)
if (familyFilter === 'shared') return family === 'shared'
return family === familyFilter || family === 'shared'
})
.filter(definition => matchesNodeKindFilter(definition, kindFilter))
.filter(definition => !normalizedQuery || getDefinitionSearchText(definition).includes(normalizedQuery))
.sort(compareNodeDefinitions)
@@ -70,48 +147,173 @@ export function filterWorkflowNodeDefinitions(
export function buildWorkflowNodeCatalog(
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogGroupSection[] {
const groupEntries = new Map<
WorkflowNodeLibraryGroup,
Map<string, WorkflowNodeDefinition[]>
>()
): WorkflowNodeCatalogStageSection[] {
const stageEntries = new Map<WorkflowAuthoringStage, Map<string, WorkflowNodeDefinition[]>>()
for (const definition of definitions) {
const group = getPrimaryLibraryGroup(definition)
const stage = getDefinitionAuthoringStage(definition)
const namespace = getDefinitionModuleNamespace(definition)
const modules = groupEntries.get(group) ?? new Map<string, WorkflowNodeDefinition[]>()
const modules = stageEntries.get(stage) ?? new Map<string, WorkflowNodeDefinition[]>()
modules.set(namespace, [...(modules.get(namespace) ?? []), definition])
groupEntries.set(group, modules)
stageEntries.set(stage, modules)
}
return (['legacy', 'bridge', 'graph'] as WorkflowNodeLibraryGroup[])
.map(group => {
const moduleEntries = groupEntries.get(group) ?? new Map<string, WorkflowNodeDefinition[]>()
const modules = Array.from(moduleEntries.entries())
.map(([namespace, moduleDefinitions]) => {
const definitionsForModule = [...moduleDefinitions].sort(compareNodeDefinitions)
return {
namespace,
label: getDefinitionModuleLabel(definitionsForModule[0]),
definitions: definitionsForModule,
categories: NODE_CATEGORY_ORDER.map(category => ({
category,
definitions: definitionsForModule.filter(definition => definition.category === category),
})).filter(section => section.definitions.length > 0),
familyCounts: {
cad_file: definitionsForModule.filter(definition => getDefinitionFamily(definition) === 'cad_file').length,
order_line: definitionsForModule.filter(definition => getDefinitionFamily(definition) === 'order_line').length,
},
}
})
return AUTHORING_STAGE_ORDER
.map(stage => {
const moduleEntries = stageEntries.get(stage) ?? new Map<string, WorkflowNodeDefinition[]>()
const modules = Array.from(moduleEntries.values())
.map(moduleDefinitions => buildModuleSection(stage, moduleDefinitions))
.sort((a, b) => a.label.localeCompare(b.label))
const groupDefinitions = modules.flatMap(module => module.definitions)
const stageDefinitions = modules.flatMap(module => module.definitions)
return {
group,
definitions: groupDefinitions,
stage,
definitions: stageDefinitions,
modules,
runtimeCounts: {
legacy: stageDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: stageDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: stageDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
})
.filter(section => section.definitions.length > 0)
}
function buildModuleStageSection(
stage: WorkflowAuthoringStage,
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogModuleStageSection {
const sortedDefinitions = [...definitions].sort(compareNodeDefinitions)
return {
stage,
definitions: sortedDefinitions,
categories: NODE_CATEGORY_ORDER.map(category => ({
category,
definitions: sortedDefinitions.filter(definition => definition.category === category),
})).filter(section => section.definitions.length > 0),
}
}
function buildFamilyModuleSections(
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogFamilyModuleSection[] {
const moduleEntries = new Map<
string,
{
label: string
stages: Map<WorkflowAuthoringStage, WorkflowNodeDefinition[]>
}
>()
for (const definition of definitions) {
const namespace = getDefinitionModuleNamespace(definition)
const stage = getDefinitionAuthoringStage(definition)
const existing = moduleEntries.get(namespace) ?? {
label: getDefinitionModuleLabel(definition),
stages: new Map<WorkflowAuthoringStage, WorkflowNodeDefinition[]>(),
}
existing.stages.set(stage, [...(existing.stages.get(stage) ?? []), definition])
moduleEntries.set(namespace, existing)
}
return Array.from(moduleEntries.entries())
.map(([namespace, entry]) => {
const stageSections = AUTHORING_STAGE_ORDER
.map(stage => {
const stageDefinitions = entry.stages.get(stage) ?? []
if (stageDefinitions.length === 0) return null
return buildModuleStageSection(stage, stageDefinitions)
})
.filter((section): section is WorkflowNodeCatalogModuleStageSection => Boolean(section))
const moduleDefinitions = stageSections.flatMap(section => section.definitions)
return {
namespace,
label: entry.label,
definitions: moduleDefinitions,
stages: stageSections.map(section => section.stage),
stageSections,
runtimeCounts: {
legacy: moduleDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: moduleDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: moduleDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
})
.sort((a, b) => a.label.localeCompare(b.label))
}
function buildFamilySections(
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogFamilySection[] {
const familyEntries = new Map<WorkflowNodeFamily, WorkflowNodeDefinition[]>()
for (const definition of definitions) {
const family = getDefinitionFamily(definition)
familyEntries.set(family, [...(familyEntries.get(family) ?? []), definition])
}
return (['cad_file', 'shared', 'order_line'] as WorkflowNodeFamily[])
.map(family => {
const familyDefinitions = [...(familyEntries.get(family) ?? [])].sort(compareNodeDefinitions)
if (familyDefinitions.length === 0) return null
return {
family,
definitions: familyDefinitions,
modules: buildFamilyModuleSections(familyDefinitions),
stageSections: buildWorkflowNodeCatalog(familyDefinitions),
runtimeCounts: {
legacy: familyDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: familyDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: familyDefinitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
})
.filter((section): section is WorkflowNodeCatalogFamilySection => Boolean(section))
}
export function buildWorkflowNodeCatalogModel(
definitions: WorkflowNodeDefinition[],
): WorkflowNodeCatalogModel {
const stageSections = buildWorkflowNodeCatalog(definitions)
const familySections = buildFamilySections(definitions)
const moduleFilterEntries = new Map<string, WorkflowNodeCatalogModuleFilter>()
for (const section of stageSections) {
for (const module of section.modules) {
const existing = moduleFilterEntries.get(module.namespace)
if (existing) {
existing.count += module.definitions.length
if (!existing.stages.includes(section.stage)) {
existing.stages.push(section.stage)
}
continue
}
moduleFilterEntries.set(module.namespace, {
namespace: module.namespace,
label: module.label,
count: module.definitions.length,
stages: [section.stage],
})
}
}
const moduleFilters = Array.from(moduleFilterEntries.values())
.sort((a, b) => a.label.localeCompare(b.label))
return {
definitions,
moduleFilters,
familySections,
stageSections,
runtimeCounts: {
legacy: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'legacy').length,
bridge: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'bridge').length,
graph: definitions.filter(definition => getPrimaryLibraryGroup(definition) === 'graph').length,
},
}
}
@@ -1,9 +1,16 @@
import type { StepCategory, WorkflowNodeDefinition, WorkflowNodeFamily } from '../../api/workflows'
export type WorkflowNodeFamilyFilter = 'all' | WorkflowNodeFamily
export type WorkflowGraphFamily = WorkflowNodeFamily | 'mixed'
export type WorkflowGraphFamily = Exclude<WorkflowNodeFamily, 'shared'> | 'mixed'
export type WorkflowNodeKindFilter = 'all' | 'legacy' | 'bridge' | 'graph'
export type WorkflowNodeLibraryGroup = 'legacy' | 'bridge' | 'graph'
export type WorkflowAuthoringStage =
| 'cad_intake'
| 'scene_prep'
| 'materials'
| 'render'
| 'publish'
| 'orchestration'
export const CATEGORY_LABELS: Record<StepCategory, string> = {
input: 'Input',
@@ -24,6 +31,7 @@ export const NODE_CATEGORY_ORDER: StepCategory[] = ['input', 'processing', 'rend
export const FAMILY_FILTER_LABELS: Record<WorkflowNodeFamilyFilter, string> = {
all: 'All Nodes',
cad_file: 'CAD Intake',
shared: 'Shared',
order_line: 'Order Rendering',
}
@@ -52,13 +60,51 @@ export const NODE_LIBRARY_GROUP_DESCRIPTIONS: Record<WorkflowNodeLibraryGroup, s
graph: 'Native graph runtime nodes for the non-legacy editor flow.',
}
export const AUTHORING_STAGE_ORDER: WorkflowAuthoringStage[] = [
'cad_intake',
'scene_prep',
'materials',
'render',
'publish',
'orchestration',
]
export const AUTHORING_STAGE_LABELS: Record<WorkflowAuthoringStage, string> = {
cad_intake: 'CAD Intake',
scene_prep: 'Scene Prep',
materials: 'Materials',
render: 'Render',
publish: 'Publish',
orchestration: 'Orchestration',
}
export const AUTHORING_STAGE_DESCRIPTIONS: Record<WorkflowAuthoringStage, string> = {
cad_intake: 'Import CAD sources, extract geometry, and prepare downstream preview assets.',
scene_prep: 'Resolve context, templates, geometry metadata, and upstream render state.',
materials: 'Map, normalize, or override materials before render execution.',
render: 'Generate stills, thumbnails, or other rendered artifacts.',
publish: 'Persist outputs and emit downstream completion signals.',
orchestration: 'Support glue, control flow, or utility nodes that do not belong to a single production stage.',
}
export const AUTHORING_STAGE_STYLES: Record<WorkflowAuthoringStage, string> = {
cad_intake: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
scene_prep: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
materials: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
render: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300',
publish: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
orchestration: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300',
}
export const FAMILY_FILTER_DESCRIPTIONS: Record<WorkflowNodeFamily, string> = {
cad_file: 'Start with a CAD file context and produce previews, caches, or derived assets.',
shared: 'Reusable nodes that can be dropped into either CAD-intake or order-rendering workflows.',
order_line: 'Start with an order line context and run production rendering/output steps.',
}
export const FAMILY_FILTER_STYLES: Record<WorkflowNodeFamily, string> = {
cad_file: 'bg-sky-100 text-sky-700 dark:bg-sky-900/40 dark:text-sky-300',
shared: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
order_line: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
}
@@ -86,8 +132,16 @@ const CAD_FILE_NODE_STEPS = new Set([
'thumbnail_save',
])
const SHARED_NODE_STEPS = new Set([
'glb_bbox',
])
export function getNodeFamily(step: string, nodeDefinitionsByStep?: WorkflowNodeDefinitionMap): WorkflowNodeFamily {
return nodeDefinitionsByStep?.[step]?.family ?? (CAD_FILE_NODE_STEPS.has(step) ? 'cad_file' : 'order_line')
if (nodeDefinitionsByStep?.[step]?.family) {
return nodeDefinitionsByStep[step].family
}
if (SHARED_NODE_STEPS.has(step)) return 'shared'
return CAD_FILE_NODE_STEPS.has(step) ? 'cad_file' : 'order_line'
}
export function getDefinitionFamily(
@@ -103,7 +157,8 @@ export function isDefinitionAllowedForGraphFamily(
nodeDefinitionsByStep?: WorkflowNodeDefinitionMap,
): boolean {
if (graphFamily === 'mixed') return true
return getDefinitionFamily(definition, nodeDefinitionsByStep) === graphFamily
const family = getDefinitionFamily(definition, nodeDefinitionsByStep)
return family === 'shared' || family === graphFamily
}
export function compareNodeDefinitions(a: WorkflowNodeDefinition, b: WorkflowNodeDefinition) {
@@ -125,12 +180,47 @@ export function getDefinitionModuleLabel(definition: WorkflowNodeDefinition): st
.join(' ')
}
export function getDefinitionAuthoringStage(definition: WorkflowNodeDefinition): WorkflowAuthoringStage {
const moduleKey = definition.module_key.toLowerCase()
if (moduleKey.startsWith('cad.')) {
if (definition.category === 'rendering') return 'render'
if (definition.category === 'output') return 'publish'
return 'cad_intake'
}
if (
moduleKey.startsWith('order_line.')
|| moduleKey.startsWith('geometry.')
|| moduleKey.startsWith('context.')
) {
return 'scene_prep'
}
if (moduleKey.startsWith('materials.')) {
return 'materials'
}
if (moduleKey.startsWith('render.') || moduleKey.startsWith('rendering.')) {
return 'render'
}
if (moduleKey.startsWith('media.') || moduleKey.startsWith('notifications.')) {
return 'publish'
}
if (definition.category === 'rendering') return 'render'
if (definition.category === 'output') return 'publish'
if (definition.category === 'input' || definition.category === 'processing') return 'orchestration'
return 'orchestration'
}
export function groupDefinitionsForStepSelect(definitions: WorkflowNodeDefinition[]) {
const groups = new Map<string, WorkflowNodeDefinition[]>()
for (const definition of [...definitions].sort(compareNodeDefinitions)) {
const family = getDefinitionFamily(definition)
const groupLabel = `${FAMILY_FILTER_LABELS[family]} · ${getDefinitionModuleLabel(definition)} · ${CATEGORY_LABELS[definition.category]}`
const groupLabel = `${FAMILY_FILTER_LABELS[family]} · ${AUTHORING_STAGE_LABELS[getDefinitionAuthoringStage(definition)]} · ${getDefinitionModuleLabel(definition)}`
groups.set(groupLabel, [...(groups.get(groupLabel) ?? []), definition])
}
@@ -145,6 +235,9 @@ export function groupDefinitionsByFamily(
cad_file: definitions
.filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'cad_file')
.sort(compareNodeDefinitions),
shared: definitions
.filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'shared')
.sort(compareNodeDefinitions),
order_line: definitions
.filter(definition => getDefinitionFamily(definition, nodeDefinitionsByStep) === 'order_line')
.sort(compareNodeDefinitions),
@@ -0,0 +1,60 @@
import type { WorkflowCanvasPort } from './workflowGraphDraft'
const DIRECT_PORT_BADGE_LABELS: Record<string, string> = {
bbox: 'Bounding Box',
blend_asset: 'Blend Asset',
cad_file_ref: 'CAD File Ref',
cad_materials: 'CAD Materials',
cad_file_record: 'CAD File',
glb_preview: 'GLB Preview',
glb_reuse_path: 'GLB Reuse Path',
material_assignments: 'Material Assignments',
material_catalog_updates: 'Catalog Updates',
media_asset: 'Media Asset',
notification_event: 'Notification Event',
order_line_context: 'Order Context',
order_line_record: 'Order Line',
output_profile: 'Output Profile',
render_template: 'Render Template',
rendered_frames: 'Rendered Frames',
rendered_image: 'Rendered Image',
rendered_video: 'Rendered Video',
step_path: 'STEP Path',
template_inputs: 'Template Inputs',
template_path: 'Template Path',
usd_render_path: 'USD Render Path',
workflow_input_schema: 'Input Schema',
workflow_result: 'Workflow Result',
}
const ALTERNATIVE_PORT_BADGE_LABELS: Record<string, string> = {
blend_asset: 'Blend Asset',
rendered_frames: 'Frames',
rendered_image: 'Image',
rendered_video: 'Video',
workflow_result: 'Workflow Result',
}
function getAlternativeRoleBadgeLabel(role: string): string {
return ALTERNATIVE_PORT_BADGE_LABELS[role] ?? DIRECT_PORT_BADGE_LABELS[role] ?? role
}
export function getWorkflowNodePortBadgeLabel(port: WorkflowCanvasPort): string {
if (port.kind === 'alternative') {
return `Any: ${port.roles.map(getAlternativeRoleBadgeLabel).join(' / ')}`
}
if (port.roles.length === 1) {
return DIRECT_PORT_BADGE_LABELS[port.roles[0]] ?? port.label
}
return port.label
}
export function getWorkflowNodePortTitle(port: WorkflowCanvasPort): string {
if (port.kind === 'alternative') {
return `Accepts any of: ${port.roles.map(role => DIRECT_PORT_BADGE_LABELS[role] ?? role).join(' / ')}`
}
return port.label
}
@@ -0,0 +1,203 @@
import type { Edge, Node } from '@xyflow/react'
import {
buildWorkflowBlueprintConfig,
type WorkflowNodeDefinition,
type WorkflowConfig,
type WorkflowParams,
} from '../../api/workflows'
import {
inferNodeLabel,
inferNodeType,
normalizeWorkflowParams,
type WorkflowCanvasNodeData,
} from './workflowGraphDraft'
import type { WorkflowGraphFamily } from './workflowNodeLibrary'
export type WorkflowReferenceBundleId = 'still_render_reference'
| 'cad_intake_reference'
export type WorkflowReferenceBundleDefinition = {
id: WorkflowReferenceBundleId
label: string
shortLabel: string
description: string
family: Exclude<WorkflowGraphFamily, 'mixed'>
stepIds: string[]
stage: string
}
type WorkflowReferenceBundleInsertionResult =
| {
ok: true
bundle: WorkflowReferenceBundleDefinition
nodes: Node[]
edges: Edge[]
}
| {
ok: false
reason: string
}
const WORKFLOW_REFERENCE_BUNDLE_REGISTRY: WorkflowReferenceBundleDefinition[] = [
{
id: 'cad_intake_reference',
label: 'CAD Intake Reference',
shortLabel: 'CAD Intake Reference',
description: 'Insert the canonical CAD intake path so extraction, geometry export, STL cache generation, and thumbnail publishing stay wired in a known-good order.',
family: 'cad_file',
stepIds: [
'resolve_step_path',
'occ_object_extract',
'occ_glb_export',
'stl_cache_generate',
'thumbnail_save',
],
stage: 'Reference Path',
},
{
id: 'still_render_reference',
label: 'Still Render Reference',
shortLabel: 'Still Reference',
description: 'Insert the complete canonical non-legacy still-render path, including material resolution, render, output, and notification branches.',
family: 'order_line',
stepIds: [
'order_line_setup',
'resolve_template',
'auto_populate_materials',
'glb_bbox',
'material_map_resolve',
'blender_still',
'output_save',
'notify',
],
stage: 'Reference Path',
},
]
function buildNodeData(
step: string,
params: WorkflowParams = {},
definition?: WorkflowNodeDefinition,
overrides?: Partial<WorkflowCanvasNodeData>,
): WorkflowCanvasNodeData {
return {
label: overrides?.label ?? definition?.label ?? inferNodeLabel(step),
params: normalizeWorkflowParams(params),
step,
description: overrides?.description ?? definition?.description,
icon: overrides?.icon ?? definition?.icon,
category: overrides?.category ?? definition?.category,
}
}
function getReferenceTemplate(bundleId: WorkflowReferenceBundleId) {
const toTemplate = (config: WorkflowConfig) => ({
nodes: config.nodes,
edges: config.edges,
})
switch (bundleId) {
case 'cad_intake_reference':
return toTemplate(buildWorkflowBlueprintConfig('cad_intake'))
case 'still_render_reference':
return toTemplate(buildWorkflowBlueprintConfig('still_graph_reference'))
default:
return null
}
}
export function getWorkflowReferenceBundle(bundleId: WorkflowReferenceBundleId) {
return WORKFLOW_REFERENCE_BUNDLE_REGISTRY.find(bundle => bundle.id === bundleId) ?? null
}
export function getWorkflowReferenceBundles(
nodeDefinitions: WorkflowNodeDefinition[],
graphFamily: WorkflowGraphFamily,
): WorkflowReferenceBundleDefinition[] {
const availableSteps = new Set(nodeDefinitions.map(definition => definition.step))
return WORKFLOW_REFERENCE_BUNDLE_REGISTRY.filter(bundle => {
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) return false
return bundle.stepIds.every(step => availableSteps.has(step))
})
}
export function createWorkflowReferenceBundleInsertion(args: {
bundleId: WorkflowReferenceBundleId
graphFamily: WorkflowGraphFamily
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>
existingNodes: Node[]
preferredPosition?: { x: number; y: number }
}): WorkflowReferenceBundleInsertionResult {
const { bundleId, graphFamily, nodeDefinitionsByStep, existingNodes, preferredPosition } = args
const bundle = getWorkflowReferenceBundle(bundleId)
if (!bundle) {
return { ok: false, reason: 'Unknown workflow reference path.' }
}
if (graphFamily !== 'mixed' && bundle.family !== graphFamily) {
return { ok: false, reason: `${bundle.label} does not belong to the active authoring family.` }
}
const missingStep = bundle.stepIds.find(step => !nodeDefinitionsByStep[step])
if (missingStep) {
return { ok: false, reason: `Workflow reference path is missing definition for ${missingStep}.` }
}
const template = getReferenceTemplate(bundleId)
if (!template) {
return { ok: false, reason: 'Workflow reference path template is unavailable.' }
}
const existingMaxX = existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.x)) : null
const existingMaxY = existingNodes.length > 0 ? Math.max(...existingNodes.map(node => node.position.y)) : null
const anchorX = preferredPosition?.x ?? (existingMaxX !== null ? existingMaxX + 260 : 120)
const anchorY = preferredPosition?.y ?? (existingMaxY !== null ? existingMaxY + 80 : 120)
const timestamp = Date.now()
const templatePositions = template.nodes.map(node => node.ui?.position ?? { x: 0, y: 0 })
const minX = Math.min(...templatePositions.map(position => position.x))
const minY = Math.min(...templatePositions.map(position => position.y))
const nodeIdMap = new Map<string, string>()
const nodes = template.nodes.map(node => {
const definition = nodeDefinitionsByStep[node.step]
const templatePosition = node.ui?.position ?? { x: 0, y: 0 }
const id = `${bundle.id}_${node.id}_${timestamp}`
nodeIdMap.set(node.id, id)
return {
id,
type: definition?.node_type ?? node.ui?.type ?? inferNodeType(node.step),
position: {
x: anchorX + (templatePosition.x - minX),
y: anchorY + (templatePosition.y - minY),
},
data: buildNodeData(
node.step,
node.params ?? definition?.defaults ?? {},
definition,
node.ui?.label
? {
label: node.ui.label,
}
: undefined,
),
} satisfies Node
})
const edges = template.edges.map(edge => ({
id: `${nodeIdMap.get(edge.from)}->${nodeIdMap.get(edge.to)}`,
source: nodeIdMap.get(edge.from) ?? edge.from,
target: nodeIdMap.get(edge.to) ?? edge.to,
}) satisfies Edge)
return {
ok: true,
bundle,
nodes,
edges,
}
}
@@ -0,0 +1,76 @@
import type { WorkflowRolloutSummary } from '../../api/workflows'
export interface WorkflowRolloutPresentation {
badgeLabel: string
badgeClassName: string
statusLabel: string
statusClassName: string
summary: string
}
export function getWorkflowRolloutPresentation(
rollout: WorkflowRolloutSummary,
): WorkflowRolloutPresentation {
if (rollout.linked_output_type_count === 0) {
return {
badgeLabel: 'Unlinked',
badgeClassName: 'bg-surface-muted text-content-muted',
statusLabel: 'Legacy Only',
statusClassName: 'bg-slate-100 text-slate-700',
summary: 'No output types are linked to this workflow yet.',
}
}
if (rollout.has_blocking_contracts) {
return {
badgeLabel: 'Contract Blocked',
badgeClassName: 'bg-red-100 text-red-700',
statusLabel: 'Do Not Promote',
statusClassName: 'bg-red-100 text-red-700',
summary: 'One or more linked output types are contract-invalid for this workflow.',
}
}
const rolloutModes = new Set(rollout.rollout_modes)
if (rolloutModes.has('graph')) {
return {
badgeLabel: rolloutModes.size > 1 ? 'Mixed Rollout' : 'Graph Authoritative',
badgeClassName: 'bg-status-success-bg text-status-success-text',
statusLabel: rollout.latest_rollout_ready ? 'Ready For Rollout' : 'Production: Graph',
statusClassName: rollout.latest_rollout_ready
? 'bg-emerald-100 text-emerald-700'
: 'bg-green-100 text-green-700',
summary:
rolloutModes.size > 1
? 'Some linked output types are already graph-authoritative while others still hold legacy or shadow.'
: 'Linked output types dispatch through the graph runtime with legacy fallback armed.',
}
}
if (rolloutModes.has('shadow')) {
return {
badgeLabel: 'Shadow',
badgeClassName: 'bg-status-info-bg text-status-info-text',
statusLabel:
rollout.latest_rollout_status === 'ready_for_rollout'
? 'Ready For Rollout'
: 'Legacy Authoritative',
statusClassName:
rollout.latest_rollout_status === 'ready_for_rollout'
? 'bg-emerald-100 text-emerald-700'
: 'bg-sky-100 text-sky-700',
summary:
rollout.latest_rollout_gate_verdict != null
? `Latest shadow verdict: ${rollout.latest_rollout_gate_verdict}.`
: 'Shadow runs can observe parity while legacy remains authoritative.',
}
}
return {
badgeLabel: 'Legacy Only',
badgeClassName: 'bg-surface-muted text-content-muted',
statusLabel: 'Production: Legacy',
statusClassName: 'bg-slate-100 text-slate-700',
summary: 'Linked output types keep this workflow attached for authoring, but production stays on legacy.',
}
}