fix(workflows): UI revamp — node palette, schedule modal, typeahead fields
Some checks failed
Stuffle/agos-client/pipeline/head There was a failure building this commit

- Fix NodePalette: body was invisible due to flex height issue in ReactFlow Panel.
  Replaced hover-to-expand UX with simple pinned toggle (per WORKFLOW_BUILDER_REVAMP.md).
  Panel now uses maxHeight instead of flex, shows/hides body via pinned state.

- Add ScheduleModal: Outlook-style visual schedule editor with frequency
  presets (minutes, hourly, daily, weekly, monthly), day-of-week picker,
  time picker, timezone selector. Generates cron expression automatically.
  Shows human-readable summary.

- Add TypeaheadSelect: reusable typeahead search/select component used for
  agent selection, workflow template selection, and event type selection.
  Supports lazy loading, search filtering, clear button.

- WorkflowInspector rewrite:
  - Template section moved above trigger (was below)
  - Template uses TypeaheadSelect searching real workflows by name/ID
  - Removed template name field — shows selected ID + name label
  - Added separator between template and trigger sections
  - Agent fields (webhook, schedule) now use TypeaheadSelect with real API data
  - Event trigger uses TypeaheadSelect with multi-select chips for Agos event types
  - Schedule trigger replaced cron text field with modal opener button

- NodeInspector: all input/textarea fields now use nbl-input--sm / nbl-textarea--sm
  CSS classes for compact form layout (32 inputs + 14 textareas updated)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-21 18:27:46 +05:30
parent 78849580f6
commit 28bcd6796d
5 changed files with 890 additions and 221 deletions

View File

@@ -89,7 +89,7 @@ function NotifyChannelsEditor({
<div>
<label style={FIELD_LABEL_STYLE}>Target</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={ch.target}
placeholder={ch.type === 'email' ? 'user@example.com' : ch.type === 'slack' ? '#channel' : 'user-id'}
onChange={e => {
@@ -248,7 +248,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Prompt</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={5}
style={{ resize: 'vertical' }}
value={node.prompt ?? ''}
@@ -264,7 +264,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Runtime key</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.source_key ?? ''}
placeholder="e.g. resume_file"
onChange={e => updateConfig({ source_key: e.target.value })}
@@ -274,7 +274,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Placeholder</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.placeholder ?? ''}
placeholder="Hint shown in run form"
onChange={e => updateConfig({ placeholder: e.target.value })}
@@ -283,7 +283,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Accept</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.accept ?? ''}
placeholder=".pdf,.docx"
onChange={e => updateConfig({ accept: e.target.value })}
@@ -294,7 +294,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Default value</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.default_value ?? ''}
placeholder="Optional fallback"
onChange={e => updateConfig({ default_value: e.target.value })}
@@ -318,7 +318,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Tool name</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.tool ?? ''}
placeholder="e.g. web_search"
onChange={e => onUpdateNode({ tool: e.target.value })}
@@ -327,7 +327,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Structured input</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={5}
style={{ resize: 'vertical' }}
value={node.config?.tool_input ?? ''}
@@ -357,7 +357,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Expression</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.expression ?? ''}
placeholder={'{{reason_1.output.score}} > 0.8'}
onChange={e => updateConfig({ expression: e.target.value })}
@@ -367,7 +367,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Judge prompt</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={4}
style={{ resize: 'vertical' }}
value={node.config?.judge_prompt ?? ''}
@@ -385,7 +385,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Expression</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.expression ?? ''}
placeholder={'{{reason_1.output.category}}'}
onChange={e => updateConfig({ expression: e.target.value })}
@@ -397,7 +397,7 @@ export function NodeInspector({
<div key={`case_${i}`} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<label style={FIELD_LABEL_STYLE}>Label</label>
<input className="nbl-input" value={c.label} onChange={e => {
<input className="nbl-input nbl-input--sm" value={c.label} onChange={e => {
const cases = [...(node.config?.cases ?? [])]
cases[i] = { ...c, label: e.target.value }
updateConfig({ cases })
@@ -405,7 +405,7 @@ export function NodeInspector({
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Value</label>
<input className="nbl-input" value={c.value} onChange={e => {
<input className="nbl-input nbl-input--sm" value={c.value} onChange={e => {
const cases = [...(node.config?.cases ?? [])]
cases[i] = { ...c, value: e.target.value }
updateConfig({ cases })
@@ -440,7 +440,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Source expression (array)</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.source_expression ?? ''}
placeholder={'{{input.items}}'}
onChange={e => updateConfig({ source_expression: e.target.value })}
@@ -450,7 +450,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Item variable</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.item_variable ?? 'item'}
onChange={e => updateConfig({ item_variable: e.target.value })}
/>
@@ -458,7 +458,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Index variable</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.index_variable ?? 'index'}
onChange={e => updateConfig({ index_variable: e.target.value })}
/>
@@ -468,7 +468,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Concurrency</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
type="number"
min={1}
max={20}
@@ -479,7 +479,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Max iterations</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
type="number"
min={1}
max={10000}
@@ -519,7 +519,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>N (branches to wait for)</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
type="number"
min={1}
value={node.config?.merge_n ?? 1}
@@ -531,7 +531,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Timeout (seconds)</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
type="number"
min={0}
value={node.config?.merge_timeout_seconds ?? 300}
@@ -560,7 +560,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Delay (seconds)</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
type="number"
min={0}
value={node.config?.delay_seconds ?? 5}
@@ -570,7 +570,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Until expression (optional)</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.delay_until_expression ?? ''}
placeholder="Wait until this evaluates to true"
onChange={e => updateConfig({ delay_until_expression: e.target.value })}
@@ -602,7 +602,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>URL</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.http_url ?? ''}
placeholder="https://api.example.com/{{input.path}}"
onChange={e => updateConfig({ http_url: e.target.value })}
@@ -612,7 +612,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Headers (JSON)</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={3}
style={{ resize: 'vertical' }}
value={node.config?.http_headers ? JSON.stringify(node.config.http_headers, null, 2) : ''}
@@ -641,7 +641,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Body template</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={5}
style={{ resize: 'vertical' }}
value={node.config?.http_body_template ?? ''}
@@ -670,7 +670,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Timeout (ms)</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
type="number"
min={1000}
max={120000}
@@ -683,7 +683,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Credential ID</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.http_credential_id ?? ''}
placeholder="credential-id from AGOS vault"
onChange={e => updateConfig({ http_credential_id: e.target.value })}
@@ -693,7 +693,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Response extract path (JSONPath)</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.http_response_extract_path ?? ''}
placeholder="$.data.results"
onChange={e => updateConfig({ http_response_extract_path: e.target.value })}
@@ -720,7 +720,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Source code</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={10}
style={{ resize: 'vertical', fontFamily: 'var(--nbl-mono)', fontSize: 11 }}
value={node.config?.code_source ?? ''}
@@ -734,7 +734,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Timeout (seconds)</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
type="number"
min={1}
max={300}
@@ -754,7 +754,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Allowed packages (one per line)</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={3}
style={{ resize: 'vertical' }}
value={(node.config?.code_packages ?? []).join('\n')}
@@ -771,7 +771,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Approval title</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={node.config?.approval_title ?? ''}
placeholder="Review and approve this step"
onChange={e => updateConfig({ approval_title: e.target.value })}
@@ -780,7 +780,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Description</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={3}
style={{ resize: 'vertical' }}
value={node.config?.approval_description ?? ''}
@@ -792,7 +792,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Timeout (seconds)</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
type="number"
min={60}
max={604800}
@@ -835,7 +835,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Message template</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={4}
style={{ resize: 'vertical' }}
value={node.config?.notify_template ?? ''}
@@ -867,7 +867,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Output prompt</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={3}
style={{ resize: 'vertical' }}
value={node.prompt ?? ''}
@@ -878,7 +878,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Value template</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={6}
style={{ resize: 'vertical' }}
value={node.config?.value_template ?? ''}
@@ -893,7 +893,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Output schema</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={5}
style={{ resize: 'vertical' }}
value={node.config?.output_schema ?? ''}
@@ -924,7 +924,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Key</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={route.key ?? ''}
placeholder="optional key"
onChange={e => updateOutputRoute(index, { key: e.target.value })}
@@ -941,7 +941,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Event type</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={route.event_type ?? ''}
placeholder="workflow_output_emitted"
onChange={e => updateOutputRoute(index, { event_type: e.target.value })}
@@ -968,11 +968,11 @@ export function NodeInspector({
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8, alignItems: 'end' }}>
<div>
<label style={FIELD_LABEL_STYLE}>Key</label>
<input className="nbl-input" value={param.key} onChange={e => updateRuntimeParam(index, { key: e.target.value })} />
<input className="nbl-input nbl-input--sm" value={param.key} onChange={e => updateRuntimeParam(index, { key: e.target.value })} />
</div>
<div>
<label style={FIELD_LABEL_STYLE}>Label</label>
<input className="nbl-input" value={param.label} onChange={e => updateRuntimeParam(index, { label: e.target.value })} />
<input className="nbl-input nbl-input--sm" value={param.label} onChange={e => updateRuntimeParam(index, { label: e.target.value })} />
</div>
<button
onClick={() => removeRuntimeParam(index)}
@@ -1003,7 +1003,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Placeholder</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={param.placeholder ?? ''}
onChange={e => updateRuntimeParam(index, { placeholder: e.target.value })}
/>
@@ -1013,7 +1013,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Default value</label>
<input
className="nbl-input"
className="nbl-input nbl-input--sm"
value={param.default_value === undefined ? '' : String(param.default_value)}
onChange={e => updateRuntimeParam(index, { default_value: e.target.value })}
/>
@@ -1030,7 +1030,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Description</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={2}
style={{ resize: 'vertical' }}
value={param.description ?? ''}
@@ -1041,7 +1041,7 @@ export function NodeInspector({
<div>
<label style={FIELD_LABEL_STYLE}>Options</label>
<textarea
className="nbl-textarea"
className="nbl-textarea nbl-textarea--sm"
rows={3}
style={{ resize: 'vertical' }}
value={serializeRuntimeParamOptions(param)}

View File

@@ -0,0 +1,301 @@
/**
* ScheduleModal — Outlook-style schedule specifier modal.
*
* Lets users configure a schedule visually instead of writing raw cron.
* Supports: every N minutes, hourly, daily, weekly, monthly presets.
* Generates the cron expression automatically.
*/
import { useState, useMemo } from 'react'
import { Modal } from '@/components/agos-ux'
import { Calendar, Clock } from 'lucide-react'
type Frequency = 'every_n_minutes' | 'hourly' | 'daily' | 'weekly' | 'monthly'
interface ScheduleModalProps {
initialCron?: string
initialTimezone?: string
onApply: (cron: string, timezone: string) => void
onClose: () => void
}
const TIMEZONES = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Europe/London',
'Europe/Berlin',
'Europe/Paris',
'Asia/Kolkata',
'Asia/Tokyo',
'Asia/Shanghai',
'Asia/Singapore',
'Australia/Sydney',
'Pacific/Auckland',
]
const DAYS_OF_WEEK = [
{ value: 0, label: 'Sun' },
{ value: 1, label: 'Mon' },
{ value: 2, label: 'Tue' },
{ value: 3, label: 'Wed' },
{ value: 4, label: 'Thu' },
{ value: 5, label: 'Fri' },
{ value: 6, label: 'Sat' },
]
function parseCronToState(cron?: string): {
frequency: Frequency
minutes: number
hour: number
minute: number
daysOfWeek: number[]
dayOfMonth: number
} {
const defaults = { frequency: 'daily' as Frequency, minutes: 15, hour: 9, minute: 0, daysOfWeek: [1], dayOfMonth: 1 }
if (!cron) return defaults
const parts = cron.trim().split(/\s+/)
if (parts.length < 5) return defaults
const [min, hr, dom, , dow] = parts
if (min.startsWith('*/')) {
return { ...defaults, frequency: 'every_n_minutes', minutes: parseInt(min.slice(2)) || 15 }
}
if (min !== '*' && hr === '*') {
return { ...defaults, frequency: 'hourly', minute: parseInt(min) || 0 }
}
if (dow !== '*' && dow !== '?') {
const days = dow.split(',').map(d => parseInt(d)).filter(d => !isNaN(d))
return { ...defaults, frequency: 'weekly', hour: parseInt(hr) || 9, minute: parseInt(min) || 0, daysOfWeek: days.length ? days : [1] }
}
if (dom !== '*') {
return { ...defaults, frequency: 'monthly', hour: parseInt(hr) || 9, minute: parseInt(min) || 0, dayOfMonth: parseInt(dom) || 1 }
}
return { ...defaults, frequency: 'daily', hour: parseInt(hr) || 9, minute: parseInt(min) || 0 }
}
function buildCron(state: ReturnType<typeof parseCronToState>): string {
switch (state.frequency) {
case 'every_n_minutes': return `*/${state.minutes} * * * *`
case 'hourly': return `${state.minute} * * * *`
case 'daily': return `${state.minute} ${state.hour} * * *`
case 'weekly': return `${state.minute} ${state.hour} * * ${state.daysOfWeek.join(',')}`
case 'monthly': return `${state.minute} ${state.hour} ${state.dayOfMonth} * *`
}
}
function describeCron(state: ReturnType<typeof parseCronToState>, timezone: string): string {
const pad = (n: number) => String(n).padStart(2, '0')
const time = `${pad(state.hour)}:${pad(state.minute)}`
switch (state.frequency) {
case 'every_n_minutes': return `Every ${state.minutes} minutes`
case 'hourly': return `Every hour at :${pad(state.minute)}`
case 'daily': return `Every day at ${time} ${timezone}`
case 'weekly': {
const days = state.daysOfWeek.map(d => DAYS_OF_WEEK.find(dw => dw.value === d)?.label).filter(Boolean).join(', ')
return `Every ${days} at ${time} ${timezone}`
}
case 'monthly': return `Day ${state.dayOfMonth} of every month at ${time} ${timezone}`
}
}
const PILL_STYLE = (active: boolean) => ({
padding: '6px 12px',
borderRadius: 8,
cursor: 'pointer',
border: active ? '1px solid var(--nbl-agos)' : '1px solid var(--nbl-border)',
background: active ? 'color-mix(in srgb, var(--nbl-agos) 12%, var(--nbl-bg))' : 'var(--nbl-bg)',
color: active ? 'var(--nbl-agos)' : 'var(--nbl-text-muted)',
fontSize: 11,
fontWeight: 700,
} as const)
const LABEL_STYLE = { fontSize: 10, color: 'var(--nbl-text-muted)', marginBottom: 4 } as const
export function ScheduleModal({ initialCron, initialTimezone, onApply, onClose }: ScheduleModalProps) {
const [state, setState] = useState(() => parseCronToState(initialCron))
const [timezone, setTimezone] = useState(initialTimezone || 'UTC')
const cronString = useMemo(() => buildCron(state), [state])
const description = useMemo(() => describeCron(state, timezone), [state, timezone])
const setFreq = (f: Frequency) => setState(s => ({ ...s, frequency: f }))
return (
<Modal onClose={onClose} title="Configure Schedule" maxWidth="26rem" footer={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 10, color: 'var(--nbl-text-muted)', fontFamily: 'var(--font-mono)' }}>
{cronString}
</span>
<button
onClick={() => { onApply(cronString, timezone); onClose() }}
style={{
padding: '8px 18px', borderRadius: 10, cursor: 'pointer',
border: '1px solid var(--nbl-agos)',
background: 'color-mix(in srgb, var(--nbl-agos) 14%, var(--nbl-bg))',
color: 'var(--nbl-agos)', fontSize: 12, fontWeight: 700,
}}
>
Apply
</button>
</div>
}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Human-readable summary */}
<div style={{
padding: '10px 14px', borderRadius: 10,
background: 'color-mix(in srgb, var(--nbl-agos) 8%, var(--nbl-bg))',
border: '1px solid color-mix(in srgb, var(--nbl-agos) 20%, var(--nbl-border))',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<Calendar size={14} style={{ color: 'var(--nbl-agos)', flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--nbl-text)' }}>{description}</span>
</div>
{/* Frequency selector */}
<div>
<div style={LABEL_STYLE}>Repeat</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{([
{ v: 'every_n_minutes' as Frequency, l: 'Minutes' },
{ v: 'hourly' as Frequency, l: 'Hourly' },
{ v: 'daily' as Frequency, l: 'Daily' },
{ v: 'weekly' as Frequency, l: 'Weekly' },
{ v: 'monthly' as Frequency, l: 'Monthly' },
]).map(f => (
<button key={f.v} type="button" onClick={() => setFreq(f.v)} style={PILL_STYLE(state.frequency === f.v)}>
{f.l}
</button>
))}
</div>
</div>
{/* Every N minutes */}
{state.frequency === 'every_n_minutes' && (
<div>
<div style={LABEL_STYLE}>Every</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
className="nbl-input nbl-input--sm"
type="number" min={1} max={59}
value={state.minutes}
onChange={e => setState(s => ({ ...s, minutes: Math.max(1, Math.min(59, Number(e.target.value) || 1)) }))}
style={{ width: 70 }}
/>
<span style={{ fontSize: 11, color: 'var(--nbl-text-muted)' }}>minutes</span>
</div>
</div>
)}
{/* Hourly — minute offset */}
{state.frequency === 'hourly' && (
<div>
<div style={LABEL_STYLE}>At minute</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
className="nbl-input nbl-input--sm"
type="number" min={0} max={59}
value={state.minute}
onChange={e => setState(s => ({ ...s, minute: Math.max(0, Math.min(59, Number(e.target.value) || 0)) }))}
style={{ width: 70 }}
/>
<span style={{ fontSize: 11, color: 'var(--nbl-text-muted)' }}>past the hour</span>
</div>
</div>
)}
{/* Daily / weekly / monthly — time picker */}
{(state.frequency === 'daily' || state.frequency === 'weekly' || state.frequency === 'monthly') && (
<div>
<div style={LABEL_STYLE}>Time</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Clock size={13} style={{ color: 'var(--nbl-text-muted)' }} />
<input
className="nbl-input nbl-input--sm"
type="number" min={0} max={23}
value={state.hour}
onChange={e => setState(s => ({ ...s, hour: Math.max(0, Math.min(23, Number(e.target.value) || 0)) }))}
style={{ width: 55 }}
/>
<span style={{ fontSize: 12, color: 'var(--nbl-text-muted)' }}>:</span>
<input
className="nbl-input nbl-input--sm"
type="number" min={0} max={59}
value={state.minute}
onChange={e => setState(s => ({ ...s, minute: Math.max(0, Math.min(59, Number(e.target.value) || 0)) }))}
style={{ width: 55 }}
/>
</div>
</div>
)}
{/* Weekly — day of week selector */}
{state.frequency === 'weekly' && (
<div>
<div style={LABEL_STYLE}>On days</div>
<div style={{ display: 'flex', gap: 4 }}>
{DAYS_OF_WEEK.map(d => {
const active = state.daysOfWeek.includes(d.value)
return (
<button
key={d.value}
type="button"
onClick={() => {
setState(s => {
const next = active
? s.daysOfWeek.filter(v => v !== d.value)
: [...s.daysOfWeek, d.value].sort()
return { ...s, daysOfWeek: next.length ? next : [d.value] }
})
}}
style={{
width: 34, height: 34, borderRadius: 8,
border: active ? '1px solid var(--nbl-agos)' : '1px solid var(--nbl-border)',
background: active ? 'color-mix(in srgb, var(--nbl-agos) 14%, var(--nbl-bg))' : 'var(--nbl-bg)',
color: active ? 'var(--nbl-agos)' : 'var(--nbl-text-muted)',
fontSize: 10, fontWeight: 700, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{d.label}
</button>
)
})}
</div>
</div>
)}
{/* Monthly — day of month */}
{state.frequency === 'monthly' && (
<div>
<div style={LABEL_STYLE}>Day of month</div>
<input
className="nbl-input nbl-input--sm"
type="number" min={1} max={28}
value={state.dayOfMonth}
onChange={e => setState(s => ({ ...s, dayOfMonth: Math.max(1, Math.min(28, Number(e.target.value) || 1)) }))}
style={{ width: 70 }}
/>
</div>
)}
{/* Timezone */}
<div>
<div style={LABEL_STYLE}>Timezone</div>
<select
className="nbl-input nbl-input--sm"
value={timezone}
onChange={e => setTimezone(e.target.value)}
>
{TIMEZONES.map(tz => <option key={tz} value={tz}>{tz}</option>)}
</select>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,189 @@
/**
* TypeaheadSelect — reusable type-ahead search + select dropdown.
*
* Used for agent selection, event type selection, and template (workflow) selection.
* Fetches options lazily on focus, filters on typing.
*/
import { useState, useRef, useEffect, useCallback } from 'react'
import { Search, X, ChevronDown } from 'lucide-react'
export interface TypeaheadOption {
value: string
label: string
description?: string
}
interface TypeaheadSelectProps {
value: string
onChange: (value: string) => void
options: TypeaheadOption[]
loading?: boolean
onSearch?: (query: string) => void
placeholder?: string
/** Show the label next to the value when a selection is made */
showLabelOnSelect?: boolean
className?: string
}
export function TypeaheadSelect({
value,
onChange,
options,
loading,
onSearch,
placeholder = 'Search...',
showLabelOnSelect,
}: TypeaheadSelectProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const selectedOption = options.find(o => o.value === value)
// Close on outside click
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
setQuery('')
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const handleQueryChange = useCallback((q: string) => {
setQuery(q)
onSearch?.(q)
}, [onSearch])
const filtered = query
? options.filter(o =>
o.label.toLowerCase().includes(query.toLowerCase()) ||
o.value.toLowerCase().includes(query.toLowerCase()) ||
(o.description?.toLowerCase().includes(query.toLowerCase()))
)
: options
return (
<div ref={containerRef} style={{ position: 'relative' }}>
{/* Display field */}
{!open ? (
<button
type="button"
onClick={() => { setOpen(true); setTimeout(() => inputRef.current?.focus(), 0) }}
className="nbl-input nbl-input--sm"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 6, cursor: 'pointer', textAlign: 'left', width: '100%',
}}
>
<span style={{
fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
color: value ? 'var(--nbl-text)' : 'var(--nbl-text-ghost)',
flex: 1,
}}>
{value
? (showLabelOnSelect && selectedOption
? <><span style={{ fontFamily: 'var(--font-mono)', fontSize: 10 }}>{value}</span> <span style={{ color: 'var(--nbl-text-muted)', marginLeft: 4 }}>{selectedOption.label}</span></>
: (selectedOption?.label || value))
: placeholder}
</span>
{value ? (
<X
size={12}
style={{ color: 'var(--nbl-text-muted)', flexShrink: 0, cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onChange('') }}
/>
) : (
<ChevronDown size={12} style={{ color: 'var(--nbl-text-muted)', flexShrink: 0 }} />
)}
</button>
) : (
<div style={{ position: 'relative' }}>
<Search
size={12}
style={{
position: 'absolute', left: 8, top: '50%', transform: 'translateY(-50%)',
color: 'var(--nbl-text-ghost)', pointerEvents: 'none',
}}
/>
<input
ref={inputRef}
className="nbl-input nbl-input--sm"
value={query}
onChange={e => handleQueryChange(e.target.value)}
placeholder={placeholder}
style={{ paddingLeft: 26, width: '100%' }}
onKeyDown={e => {
if (e.key === 'Escape') { setOpen(false); setQuery('') }
if (e.key === 'Enter' && filtered.length === 1) {
onChange(filtered[0].value)
setOpen(false)
setQuery('')
}
}}
/>
</div>
)}
{/* Dropdown */}
{open && (
<div style={{
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 20,
marginTop: 4,
background: 'var(--nbl-surface)',
border: '1px solid var(--nbl-border)',
borderRadius: 10,
boxShadow: '0 8px 24px rgba(0,0,0,0.18)',
maxHeight: 220,
overflow: 'auto',
}}>
{loading && (
<div style={{ padding: '10px 12px', fontSize: 10, color: 'var(--nbl-text-muted)', textAlign: 'center' }}>
Loading...
</div>
)}
{!loading && filtered.length === 0 && (
<div style={{ padding: '10px 12px', fontSize: 10, color: 'var(--nbl-text-muted)', textAlign: 'center' }}>
{query ? `No results for "${query}"` : 'No options available'}
</div>
)}
{filtered.map(opt => (
<button
key={opt.value}
type="button"
onClick={() => { onChange(opt.value); setOpen(false); setQuery('') }}
style={{
display: 'block', width: '100%', textAlign: 'left',
padding: '8px 12px', cursor: 'pointer',
background: opt.value === value ? 'color-mix(in srgb, var(--nbl-agos) 10%, var(--nbl-bg))' : 'transparent',
border: 'none', color: 'var(--nbl-text)',
borderBottom: '1px solid color-mix(in srgb, var(--nbl-border) 50%, transparent)',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'color-mix(in srgb, var(--nbl-agos) 8%, var(--nbl-bg))' }}
onMouseLeave={e => {
e.currentTarget.style.background = opt.value === value
? 'color-mix(in srgb, var(--nbl-agos) 10%, var(--nbl-bg))'
: 'transparent'
}}
>
<div style={{ fontSize: 11, fontWeight: 600 }}>{opt.label}</div>
{opt.description && (
<div style={{ fontSize: 9, color: 'var(--nbl-text-muted)', marginTop: 2, lineHeight: 1.3 }}>
{opt.description}
</div>
)}
<div style={{ fontSize: 9, color: 'var(--nbl-text-ghost)', fontFamily: 'var(--font-mono)', marginTop: 1 }}>
{opt.value}
</div>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -1,11 +1,21 @@
/**
* WorkflowInspector — workflow-level settings panel (trigger, provider, template, stats).
* WorkflowInspector — workflow-level settings panel (template, trigger, provider, stats).
*
* Shown when no node is selected.
*
* Layout order:
* 1. Template (select an existing workflow as template — typeahead search)
* 2. Trigger (manual / webhook / schedule / event / chat)
* 3. Trigger-specific config (with agent typeahead, schedule modal, event typeahead)
*/
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Calendar } from 'lucide-react'
import { InfoTip } from '@/components/ui/Tooltip'
import { agentsApi, workflowsApi } from '@/api/client'
import type { WfDefinition, WfTrigger, WfProvider, WfTriggerType } from '../../types'
import { TypeaheadSelect, type TypeaheadOption } from './TypeaheadSelect'
import { ScheduleModal } from './ScheduleModal'
const FIELD_LABEL_STYLE = {
fontSize: 10,
@@ -23,6 +33,118 @@ const SECTION_TITLE_STYLE = {
type TriggerMode = WfTriggerType
/* ── Known Agos event types ───────────────────────────────────────────── */
const AGOS_EVENT_TYPES: TypeaheadOption[] = [
// Agent events
{ value: 'agent_spawned', label: 'Agent Spawned', description: 'An agent was created and started' },
{ value: 'agent_completed', label: 'Agent Completed', description: 'An agent finished its task successfully' },
{ value: 'agent_failed', label: 'Agent Failed', description: 'An agent encountered an error and stopped' },
{ value: 'agent_cancelled', label: 'Agent Cancelled', description: 'An agent was manually cancelled' },
// Task events
{ value: 'task_started', label: 'Task Started', description: 'A task began execution' },
{ value: 'task_completed', label: 'Task Completed', description: 'A task completed successfully' },
{ value: 'task_failed', label: 'Task Failed', description: 'A task failed with an error' },
{ value: 'task_timeout', label: 'Task Timeout', description: 'A task exceeded its time limit' },
// Tool events
{ value: 'tool_called', label: 'Tool Called', description: 'A tool was invoked' },
{ value: 'tool_completed', label: 'Tool Completed', description: 'A tool finished execution' },
{ value: 'tool_policy_denied', label: 'Tool Policy Denied', description: 'A tool call was blocked by policy' },
{ value: 'tool_error', label: 'Tool Error', description: 'A tool call failed' },
// Workflow events
{ value: 'workflow_started', label: 'Workflow Started', description: 'A workflow execution began' },
{ value: 'workflow.execution.completed', label: 'Workflow Completed', description: 'A workflow execution finished' },
{ value: 'workflow.execution.failed', label: 'Workflow Failed', description: 'A workflow execution failed' },
{ value: 'workflow.node.completed', label: 'Workflow Node Completed', description: 'A workflow node finished' },
// Policy events
{ value: 'policy_checked', label: 'Policy Checked', description: 'A policy was evaluated' },
{ value: 'policy_violation', label: 'Policy Violation', description: 'A policy violation was detected' },
{ value: 'approval_requested', label: 'Approval Requested', description: 'A human approval was requested' },
{ value: 'approval_resolved', label: 'Approval Resolved', description: 'A human approval was resolved' },
// Plugin events
{ value: 'plugin_installed', label: 'Plugin Installed', description: 'A plugin was installed' },
{ value: 'plugin_executed', label: 'Plugin Executed', description: 'A plugin was executed' },
// Memory events
{ value: 'memory_stored', label: 'Memory Stored', description: 'A memory was persisted' },
{ value: 'memory_retrieved', label: 'Memory Retrieved', description: 'A memory was retrieved' },
// Security events
{ value: 'auth_failure', label: 'Auth Failure', description: 'An authentication attempt failed' },
{ value: 'permission_denied', label: 'Permission Denied', description: 'A permission check failed' },
]
/* ── Hooks for lazy data fetching ─────────────────────────────────────── */
function useAgentOptions() {
const [options, setOptions] = useState<TypeaheadOption[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
agentsApi.list({ page_size: 100 })
.then(res => {
if (cancelled) return
const agents = res.items ?? []
setOptions(agents.map(a => ({
value: a.id,
label: a.name || a.id,
description: a.status ?? 'active',
})))
})
.catch(() => { /* fail silently — user can still type */ })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [])
return { options, loading }
}
function useWorkflowOptions() {
const [options, setOptions] = useState<TypeaheadOption[]>([])
const [loading, setLoading] = useState(false)
const fetchWorkflows = useCallback((search?: string) => {
setLoading(true)
workflowsApi.list({ page_size: 50, search: search || undefined })
.then(res => {
const items = res.items ?? []
setOptions(items.map(w => ({
value: w.id,
label: w.name || w.id,
description: w.description || w.status || '',
})))
})
.catch(() => { /* fail silently */ })
.finally(() => setLoading(false))
}, [])
useEffect(() => { fetchWorkflows() }, [fetchWorkflows])
return { options, loading, onSearch: fetchWorkflows }
}
/* ── Cron description helper ──────────────────────────────────────────── */
function describeCronSimple(cron?: string, tz?: string): string | null {
if (!cron) return null
const parts = cron.trim().split(/\s+/)
if (parts.length < 5) return cron
const [min, hr, dom, , dow] = parts
const tzLabel = tz || 'UTC'
const pad = (n: number) => String(n).padStart(2, '0')
if (min.startsWith('*/')) return `Every ${min.slice(2)} minutes`
if (min !== '*' && hr === '*') return `Every hour at :${pad(parseInt(min) || 0)}`
if (dow !== '*' && dow !== '?') {
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const days = dow.split(',').map(d => dayNames[parseInt(d)] ?? d).join(', ')
return `Every ${days} at ${pad(parseInt(hr) || 0)}:${pad(parseInt(min) || 0)} ${tzLabel}`
}
if (dom !== '*') return `Day ${dom} at ${pad(parseInt(hr) || 0)}:${pad(parseInt(min) || 0)} ${tzLabel}`
return `Daily at ${pad(parseInt(hr) || 0)}:${pad(parseInt(min) || 0)} ${tzLabel}`
}
/* ── Component ────────────────────────────────────────────────────────── */
interface WorkflowInspectorProps {
definition: WfDefinition
onUpdateTrigger: (patch: Partial<WfTrigger>) => void
@@ -39,6 +161,25 @@ export function WorkflowInspector({
const trigger = definition.trigger ?? { type: 'manual', enabled: true, config: {} }
const template = definition.template ?? null
const [scheduleModalOpen, setScheduleModalOpen] = useState(false)
// Lazy-loaded options
const agentData = useAgentOptions()
const workflowData = useWorkflowOptions()
// Selected event types as a set for multi-select
const selectedEvents = useMemo(() => trigger.config?.event_types ?? [], [trigger.config?.event_types])
const handleToggleEvent = useCallback((value: string) => {
const current = trigger.config?.event_types ?? []
const next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value]
onUpdateTrigger({ config: { ...trigger.config, event_types: next } })
}, [trigger, onUpdateTrigger])
const cronDescription = describeCronSimple(trigger.config?.cron, trigger.config?.timezone)
// Provider selection (agos-native vs n8n) is planned but not yet exposed to users
return (
@@ -47,7 +188,34 @@ export function WorkflowInspector({
Click a step to edit it. The canvas stays primary; config lives here.
</div>
{/* Trigger */}
{/* ─── Template (above trigger) ──────────────────────────────────── */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 6 }}>
<div style={SECTION_TITLE_STYLE}>Template</div>
<InfoTip text="Select an existing workflow to use as a template. The template's definition will be used as a starting point for this workflow." />
</div>
<TypeaheadSelect
value={template?.id ?? ''}
onChange={value => {
if (!value) {
onUpdateTemplate({ id: undefined, name: undefined })
} else {
const selected = workflowData.options.find(o => o.value === value)
onUpdateTemplate({ id: value, name: selected?.label })
}
}}
options={workflowData.options}
loading={workflowData.loading}
onSearch={workflowData.onSearch}
placeholder="Search workflows by name or ID..."
showLabelOnSelect
/>
</div>
{/* Spacer between template and trigger */}
<div style={{ borderTop: '1px solid var(--nbl-border)', margin: '4px 0' }} />
{/* ─── Trigger ───────────────────────────────────────────────────── */}
<div>
<div style={SECTION_TITLE_STYLE}>Trigger</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6, marginTop: 8 }}>
@@ -81,29 +249,7 @@ export function WorkflowInspector({
</div>
</div>
{/* Template reference — use an existing workflow as a base */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
<label style={{ ...FIELD_LABEL_STYLE, marginBottom: 0 }}>Template</label>
<InfoTip text="Reference an existing workflow as a template. The template's definition is used as a starting point." />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
className="nbl-input nbl-input--sm"
value={template?.id ?? ''}
placeholder="Template ID"
onChange={e => onUpdateTemplate({ id: e.target.value })}
/>
<input
className="nbl-input nbl-input--sm"
value={template?.name ?? ''}
placeholder="Template name"
onChange={e => onUpdateTemplate({ name: e.target.value })}
/>
</div>
</div>
{/* Webhook config */}
{/* ─── Webhook config ────────────────────────────────────────────── */}
{trigger.type === 'webhook' && (
<>
<div>
@@ -121,13 +267,14 @@ export function WorkflowInspector({
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
<label style={{ ...FIELD_LABEL_STYLE, marginBottom: 0 }}>Agent</label>
<InfoTip text="The agent that will execute this workflow when the webhook is called. Required for the webhook to function." />
<InfoTip text="The agent that will execute this workflow when the webhook is called." />
</div>
<input
className="nbl-input nbl-input--sm"
<TypeaheadSelect
value={trigger.config?.agent_id ?? ''}
placeholder="Select an agent"
onChange={e => onUpdateTrigger({ config: { ...trigger.config, agent_id: e.target.value } })}
onChange={v => onUpdateTrigger({ config: { ...trigger.config, agent_id: v } })}
options={agentData.options}
loading={agentData.loading}
placeholder="Search agents..."
/>
</div>
<div>
@@ -146,69 +293,106 @@ export function WorkflowInspector({
</>
)}
{/* Schedule config */}
{/* ─── Schedule config (with modal) ──────────────────────────────── */}
{trigger.type === 'schedule' && (
<>
{/* Schedule summary + configure button */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
<label style={{ ...FIELD_LABEL_STYLE, marginBottom: 0 }}>Cron expression</label>
<InfoTip text="Standard cron syntax: minute hour day-of-month month day-of-week. Examples: '0 9 * * 1' = every Monday at 9am, '*/15 * * * *' = every 15 minutes, '0 0 1 * *' = first day of each month at midnight." />
<label style={{ ...FIELD_LABEL_STYLE, marginBottom: 0 }}>Schedule</label>
<InfoTip text="Configure when this workflow runs automatically. Click to open the schedule editor." />
</div>
<input
<button
type="button"
onClick={() => setScheduleModalOpen(true)}
className="nbl-input nbl-input--sm"
value={trigger.config?.cron ?? ''}
placeholder="0 9 * * 1 (Mon 9am UTC)"
onChange={e => onUpdateTrigger({ config: { ...trigger.config, cron: e.target.value } })}
/>
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
<label style={{ ...FIELD_LABEL_STYLE, marginBottom: 0 }}>Timezone</label>
<InfoTip text="IANA timezone for the schedule. The cron expression is evaluated in this timezone. Examples: UTC, America/New_York, Europe/London, Asia/Kolkata." />
</div>
<input
className="nbl-input nbl-input--sm"
value={trigger.config?.timezone ?? 'UTC'}
placeholder="UTC"
onChange={e => onUpdateTrigger({ config: { ...trigger.config, timezone: e.target.value } })}
/>
style={{
width: '100%', cursor: 'pointer', textAlign: 'left',
display: 'flex', alignItems: 'center', gap: 8,
}}
>
<Calendar size={12} style={{ color: 'var(--nbl-agos)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: cronDescription ? 'var(--nbl-text)' : 'var(--nbl-text-ghost)' }}>
{cronDescription || 'Click to configure schedule...'}
</span>
</button>
{trigger.config?.cron && (
<div style={{ fontSize: 9, color: 'var(--nbl-text-ghost)', fontFamily: 'var(--font-mono)', marginTop: 4 }}>
cron: {trigger.config.cron} &middot; tz: {trigger.config.timezone || 'UTC'}
</div>
)}
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
<label style={{ ...FIELD_LABEL_STYLE, marginBottom: 0 }}>Agent</label>
<InfoTip text="The agent that will execute this workflow on each scheduled run." />
</div>
<input
className="nbl-input nbl-input--sm"
<TypeaheadSelect
value={trigger.config?.agent_id ?? ''}
placeholder="Select an agent"
onChange={e => onUpdateTrigger({ config: { ...trigger.config, agent_id: e.target.value } })}
onChange={v => onUpdateTrigger({ config: { ...trigger.config, agent_id: v } })}
options={agentData.options}
loading={agentData.loading}
placeholder="Search agents..."
/>
</div>
{scheduleModalOpen && (
<ScheduleModal
initialCron={trigger.config?.cron}
initialTimezone={trigger.config?.timezone}
onApply={(cron, timezone) => onUpdateTrigger({ config: { ...trigger.config, cron, timezone } })}
onClose={() => setScheduleModalOpen(false)}
/>
)}
</>
)}
{/* Event config */}
{/* ─── Event config (typeahead multi-select) ─────────────────────── */}
{trigger.type === 'event' && (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
<label style={{ ...FIELD_LABEL_STYLE, marginBottom: 0 }}>Agos event types</label>
<InfoTip text="Platform events that trigger this workflow. These are Agos internal events emitted by agents, tasks, and other workflows. Enter one event type per line." />
<label style={{ ...FIELD_LABEL_STYLE, marginBottom: 0 }}>Event types</label>
<InfoTip text="Select Agos platform events that trigger this workflow. Multiple events can be selected." />
</div>
<textarea
className="nbl-textarea nbl-textarea--sm"
rows={3}
style={{ resize: 'vertical' }}
value={(trigger.config?.event_types ?? []).join('\n')}
placeholder={'agent.task.completed\nworkflow.execution.failed'}
onChange={e => onUpdateTrigger({
config: { ...trigger.config, event_types: e.target.value.split('\n').map(s => s.trim()).filter(Boolean) },
})}
{/* Selected event chips */}
{selectedEvents.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
{selectedEvents.map(ev => {
const opt = AGOS_EVENT_TYPES.find(o => o.value === ev)
return (
<span
key={ev}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '3px 8px', borderRadius: 6,
background: 'color-mix(in srgb, var(--nbl-agos) 10%, var(--nbl-bg))',
border: '1px solid color-mix(in srgb, var(--nbl-agos) 22%, var(--nbl-border))',
fontSize: 10, color: 'var(--nbl-text)',
}}
>
{opt?.label ?? ev}
<button
type="button"
onClick={() => handleToggleEvent(ev)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--nbl-text-muted)', padding: 0, lineHeight: 1 }}
>
&times;
</button>
</span>
)
})}
</div>
)}
{/* Typeahead for adding events */}
<TypeaheadSelect
value=""
onChange={v => { if (v && !selectedEvents.includes(v)) handleToggleEvent(v) }}
options={AGOS_EVENT_TYPES.filter(o => !selectedEvents.includes(o.value))}
placeholder="Search event types..."
/>
</div>
)}
{/* Chat trigger config */}
{/* ─── Chat trigger config ───────────────────────────────────────── */}
{trigger.type === 'chat' && (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 4 }}>
@@ -224,7 +408,7 @@ export function WorkflowInspector({
</div>
)}
{/* Manual trigger — no additional config needed */}
{/* ─── Manual trigger ────────────────────────────────────────────── */}
{trigger.type === 'manual' && (
<div style={{ fontSize: 10, color: 'var(--nbl-text-muted)', lineHeight: 1.5, padding: '4px 0' }}>
This workflow is triggered manually via the Run button or API.

View File

@@ -41,12 +41,13 @@ interface NodePaletteProps {
export function NodePalette({
pinned,
hovered,
hovered: _hovered,
onPinnedChange,
onHoverChange,
onHoverChange: _onHoverChange,
onAddNode,
}: NodePaletteProps) {
const expanded = pinned || hovered
void _hovered
void _onHoverChange
const [search, setSearch] = useState('')
const searchRef = useRef<HTMLInputElement>(null)
@@ -59,20 +60,16 @@ export function NodePalette({
return (
<div
onMouseEnter={() => onHoverChange(true)}
onMouseLeave={() => onHoverChange(false)}
style={{
width: expanded ? PANEL_WIDTH : 124,
flex: expanded ? '1 1 0' : '0 0 auto',
minHeight: 0,
transition: 'width 220ms ease, opacity 220ms ease',
width: pinned ? PANEL_WIDTH : 124,
transition: 'width 220ms ease',
}}
>
<div style={{ ...FLOATING_PANEL_STYLE, display: 'flex', flexDirection: 'column', overflow: 'hidden', height: '100%', minHeight: 0 }}>
<div style={{ ...FLOATING_PANEL_STYLE, display: 'flex', flexDirection: 'column', overflow: 'hidden', maxHeight: pinned ? 'calc(100vh - 160px)' : 'auto' }}>
{/* Header / toggle */}
<button
type="button"
onClick={() => onPinnedChange(expanded ? !pinned : true)}
onClick={() => onPinnedChange(!pinned)}
style={{
border: 'none',
background: 'transparent',
@@ -108,112 +105,110 @@ export function NodePalette({
color: pinned ? 'var(--nbl-agos)' : 'var(--nbl-text-muted)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{expanded ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
{pinned ? <ChevronLeft size={14} /> : <ChevronRight size={14} />}
</div>
</button>
{/* Body */}
<div
style={{
flex: expanded ? '1 1 0' : '0 0 0',
minHeight: 0,
overflow: expanded ? 'auto' : 'hidden',
opacity: expanded ? 1 : 0,
transition: 'opacity 180ms ease',
borderTop: expanded ? '1px solid var(--nbl-border)' : '1px solid transparent',
}}
>
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Search bar */}
<div style={{ position: 'relative' }}>
<Search
size={13}
style={{
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
color: 'var(--nbl-text-ghost)', pointerEvents: 'none',
}}
/>
<input
ref={searchRef}
className="nbl-input"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search nodes..."
style={{ paddingLeft: 30, fontSize: 11 }}
/>
</div>
{/* Body — visible only when pinned */}
{pinned && (
<div
style={{
overflow: 'auto',
borderTop: '1px solid var(--nbl-border)',
}}
>
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Search bar */}
<div style={{ position: 'relative' }}>
<Search
size={13}
style={{
position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)',
color: 'var(--nbl-text-ghost)', pointerEvents: 'none',
}}
/>
<input
ref={searchRef}
className="nbl-input"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search nodes..."
style={{ paddingLeft: 30, fontSize: 11 }}
/>
</div>
{/* Categorized node types */}
{groups.map(group => (
<div key={group.family}>
<div style={SECTION_TITLE_STYLE}>{group.label}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{group.items.map(action => {
const Icon = action.icon
return (
<button
key={action.type}
draggable
onDragStart={e => handleDragStart(e, action.type, action.label)}
onClick={() => onAddNode(action.type, action.label)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
borderRadius: 10,
cursor: 'grab',
background: 'transparent',
border: '1px solid transparent',
color: 'var(--nbl-text)',
textAlign: 'left',
transition: 'background 120ms ease, border-color 120ms ease',
}}
onMouseEnter={e => {
e.currentTarget.style.background = `color-mix(in srgb, ${action.color} 8%, var(--nbl-bg))`
e.currentTarget.style.borderColor = `color-mix(in srgb, ${action.color} 22%, var(--nbl-border))`
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.borderColor = 'transparent'
}}
>
<div style={{
width: 26, height: 26, borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: `color-mix(in srgb, ${action.color} 14%, transparent)`,
color: action.color, flexShrink: 0,
}}>
<Icon size={13} />
</div>
<div>
<div style={{ fontSize: 11, fontWeight: 600 }}>{action.label}</div>
<div style={{ fontSize: 9, color: 'var(--nbl-text-muted)', marginTop: 1, lineHeight: 1.3 }}>
{action.description}
{/* Categorized node types */}
{groups.map(group => (
<div key={group.family}>
<div style={SECTION_TITLE_STYLE}>{group.label}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{group.items.map(action => {
const Icon = action.icon
return (
<button
key={action.type}
draggable
onDragStart={e => handleDragStart(e, action.type, action.label)}
onClick={() => onAddNode(action.type, action.label)}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
borderRadius: 10,
cursor: 'grab',
background: 'transparent',
border: '1px solid transparent',
color: 'var(--nbl-text)',
textAlign: 'left',
transition: 'background 120ms ease, border-color 120ms ease',
}}
onMouseEnter={e => {
e.currentTarget.style.background = `color-mix(in srgb, ${action.color} 8%, var(--nbl-bg))`
e.currentTarget.style.borderColor = `color-mix(in srgb, ${action.color} 22%, var(--nbl-border))`
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.borderColor = 'transparent'
}}
>
<div style={{
width: 26, height: 26, borderRadius: 8,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: `color-mix(in srgb, ${action.color} 14%, transparent)`,
color: action.color, flexShrink: 0,
}}>
<Icon size={13} />
</div>
</div>
</button>
)
})}
<div>
<div style={{ fontSize: 11, fontWeight: 600 }}>{action.label}</div>
<div style={{ fontSize: 9, color: 'var(--nbl-text-muted)', marginTop: 1, lineHeight: 1.3 }}>
{action.description}
</div>
</div>
</button>
)
})}
</div>
</div>
</div>
))}
))}
{groups.length === 0 && search && (
<div style={{ fontSize: 11, color: 'var(--nbl-text-muted)', textAlign: 'center', padding: '12px 0' }}>
No nodes match "{search}"
</div>
)}
{groups.length === 0 && search && (
<div style={{ fontSize: 11, color: 'var(--nbl-text-muted)', textAlign: 'center', padding: '12px 0' }}>
No nodes match "{search}"
</div>
)}
<div style={{
padding: '6px 8px', borderRadius: 10,
border: '1px dashed var(--nbl-border)',
fontSize: 9, color: 'var(--nbl-text-muted)', lineHeight: 1.5,
}}>
Click to add or drag onto the canvas. Specialize node type in the inspector.
<div style={{
padding: '6px 8px', borderRadius: 10,
border: '1px dashed var(--nbl-border)',
fontSize: 9, color: 'var(--nbl-text-muted)', lineHeight: 1.5,
}}>
Click to add or drag onto the canvas. Specialize node type in the inspector.
</div>
</div>
</div>
</div>
)}
</div>
</div>
)