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
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:
@@ -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)}
|
||||
|
||||
301
src/pages/workflows/components/inspector/ScheduleModal.tsx
Normal file
301
src/pages/workflows/components/inspector/ScheduleModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
189
src/pages/workflows/components/inspector/TypeaheadSelect.tsx
Normal file
189
src/pages/workflows/components/inspector/TypeaheadSelect.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} · 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 }}
|
||||
>
|
||||
×
|
||||
</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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user