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 31773 additions and 2752 deletions
@@ -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>
)