Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1514.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Settings → Workflow graphs → Edit now draws the Add link panel with the dialog's own form: a 14px section heading, a responsive field grid, a `Pick a node` placeholder in both node selects, and a primary `Add link` button that stays disabled until both ends are chosen. Node handles are smaller and theme-accent, sit beside the connection line instead of on it, and only gain full opacity and a halo on hover, focus or while a wire is dragged; the settings page also injects the workflow stylesheet when it opens without the sidebar section ([#1514](https://github.com/ThinkFlowLab/FlyBot/issues/1514)).
41 changes: 29 additions & 12 deletions plugins/workflow/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ window.__ModuleLoader__.load({
'Add link': '添加连线',
'From node': '起点',
'To node': '终点',
'Pick a node': '选择一个节点',
Link: '连线',
Condition: '条件',
Loop: '循环',
Expand Down Expand Up @@ -717,7 +718,7 @@ window.__ModuleLoader__.load({
onMouseLeave: editing ? () => onTip?.(null) : null,
},
editing ? h('button', {
type: 'button', className: 'wf-port out', 'data-port': 'out', 'aria-label': node.name,
type: 'button', className: `wf-port out${gesture?.kind === 'wire' && gesture.from === node.id ? ' wiring' : ''}`, 'data-port': 'out', 'aria-label': node.name,
onPointerDown: (event) => { event.preventDefault?.(); event.stopPropagation?.(); setGesture({ kind: 'wire', from: node.id }) },
}) : null,
editing ? h('button', {
Expand Down Expand Up @@ -924,6 +925,11 @@ window.__ModuleLoader__.load({
return () => { gone = true }
}, [])

useEffect(() => {
ensureCss()
return undefined
}, [])

const workflows = pack?.workflows ?? []
const current = workflows.find(item => item.id === workflowId) ?? workflows[0]
const nodes = current?.nodes ?? []
Expand Down Expand Up @@ -1053,14 +1059,14 @@ window.__ModuleLoader__.load({
'aria-label': tr('From node'),
value: edgeForm.from,
onChange: (event) => setEdgeForm({ ...edgeForm, from: event.target.value }),
}, h('option', { value: '' }), nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))),
}, h('option', { value: '' }, tr('Pick a node')), nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))),
h('label', { className: 'wf-field' },
h('span', null, tr('To node')),
h('select', {
'aria-label': tr('To node'),
value: edgeForm.to,
onChange: (event) => setEdgeForm({ ...edgeForm, to: event.target.value }),
}, h('option', { value: '' }), nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))),
}, h('option', { value: '' }, tr('Pick a node')), nodes.map(node => h('option', { key: node.id, value: node.id }, node.name)))),
edgeForm.kind === 'loop' ? h('label', { className: 'wf-field' },
h('span', null, tr('Max iterations')),
h('input', {
Expand All @@ -1083,7 +1089,7 @@ window.__ModuleLoader__.load({
value: edgeForm.until.eq,
onChange: (event) => setEdgeForm({ ...edgeForm, until: { ...edgeForm.until, eq: event.target.value } }),
})) : null,
h('button', { type: 'button', onClick: addEdge }, tr('Add link')))) : null) : null)
h('button', { type: 'button', className: 'wf-btn wf-pri', disabled: !edgeForm.from || !edgeForm.to, onClick: addEdge }, tr('Add link')))) : null) : null)
}

function GraphCanvas({ workflow, editing, selectedNode, onSelectNode, onCommit, roster }) {
Expand Down Expand Up @@ -1164,7 +1170,7 @@ window.__ModuleLoader__.load({
},
},
editing ? h('button', {
type: 'button', className: 'wf-port out', 'data-port': 'out',
type: 'button', className: `wf-port out${gesture?.kind === 'wire' && gesture.from === node.id ? ' wiring' : ''}`, 'data-port': 'out',
onPointerDown: (event) => {
event.preventDefault()
event.stopPropagation()
Expand All @@ -1181,6 +1187,15 @@ window.__ModuleLoader__.load({
return right > 40 ? right : 0
}

function ensureCss() {
if (typeof document === 'undefined') return
if (document.getElementById('flybot-workflow-css')) return
const style = document.createElement('style')
style.id = 'flybot-workflow-css'
style.textContent = CSS
document.head.appendChild(style)
}

function Section({ roster, call, actions }) {
const [pack, setPack] = useState(null)
const [openId, setOpenId] = useState(null)
Expand Down Expand Up @@ -1208,12 +1223,7 @@ window.__ModuleLoader__.load({
}

useEffect(() => {
if (typeof document === 'undefined') return undefined
if (document.getElementById('flybot-workflow-css')) return undefined
const style = document.createElement('style')
style.id = 'flybot-workflow-css'
style.textContent = CSS
document.head.appendChild(style)
ensureCss()
return undefined
}, [])

Expand Down Expand Up @@ -1596,7 +1606,8 @@ window.__ModuleLoader__.load({
.wf-wires path{fill:none;stroke:var(--bt-line,rgba(20,20,20,.5));stroke-width:2}
.wf-wires .wf-end{pointer-events:auto;fill:var(--bt-card,#fff);stroke:var(--bt-accent,#4d6bfe);cursor:crosshair}
.wf-abs{position:absolute}
.wf-port{position:absolute;left:50%;bottom:-6px;width:12px;height:12px;margin-left:-6px;border-radius:50%;border:2px solid var(--bt-accent,#4d6bfe);background:var(--bt-card,#fff);padding:0;cursor:crosshair}
.wf-port{position:absolute;right:-5px;bottom:-5px;width:10px;height:10px;border-radius:50%;border:1.5px solid var(--bt-accent,#4d6bfe);background:var(--bt-card,#fff);padding:0;cursor:crosshair;opacity:.55;z-index:2;transition:opacity .15s,box-shadow .15s}
.wf-port:hover,.wf-port:focus-visible,.wf-port.wiring{opacity:1;box-shadow:0 0 0 3px rgba(77,107,254,.22)}
.wf-mini{position:absolute;top:-8px;right:-8px;width:18px;height:18px;border-radius:50%;border:1px solid var(--bt-line,rgba(20,20,20,.16));background:var(--bt-card,#fff);cursor:pointer;line-height:16px;padding:0}
.wf-node{width:168px;border:1px solid var(--bt-line,rgba(20,20,20,.08));background:var(--bt-card,#fff);border-radius:12px;padding:8px 9px;display:flex;align-items:center;gap:8px;position:relative}
@keyframes wf-node-live{0%,100%{border-color:var(--bt-accent,#4d6bfe);box-shadow:0 0 0 2px rgba(77,107,254,.18),0 0 0 0 rgba(77,107,254,.2)}50%{border-color:rgba(77,107,254,.85);box-shadow:0 0 0 3px rgba(77,107,254,.35),0 0 14px 2px rgba(77,107,254,.22)}}
Expand Down Expand Up @@ -1662,6 +1673,12 @@ window.__ModuleLoader__.load({
.wf-inspector-content{display:flex;flex-direction:column;gap:12px}
.wf-inspector-empty{display:flex;align-items:center;justify-content:center}
.wf-inspector-empty p{color:var(--bt-ink-3,#9a9a9a);text-align:center}
.wf-edge-form{border:1px solid var(--bt-line,rgba(20,20,20,.08));border-radius:12px;padding:16px;background:var(--bt-card,#fff)}
.wf-edge-form h3{font-size:14px;margin:0 0 12px}
.wf-edge-form .wf-edge-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
.wf-edge-form .wf-field{margin-top:0}
.wf-edge-form .wf-btn{align-self:end;justify-self:start}
.wf-btn:disabled{opacity:.5;cursor:not-allowed}
.wf-par-toggle{display:flex;align-items:center;gap:6px;font-size:12px}
`

Expand Down
122 changes: 122 additions & 0 deletions plugins/workflow/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,7 @@ test('Chinese labels follow the host Tasks translation', () => {
try {
assert.equal(client.tr('Edit'), '编辑')
assert.equal(client.tr('Hand a task to a Workflow'), '把任务交给 Workflow')
assert.equal(client.tr('Pick a node'), '选择一个节点')
assert.equal(client.columnOf({ status: 'working' }), 'in-progress')
assert.equal(client.shortId('task-abcdef123456'), '#123456')
} finally {
Expand Down Expand Up @@ -1150,3 +1151,124 @@ test('a stage control that never answers settles as an error', async () => {
const value = await client.settledCall(async () => new Promise(() => {}), 'workflow/pause', { workflowId: 'delivery' }, 'The run did not answer', 5)
assert.deepEqual(value, { ok: false, error: 'The run did not answer' })
})

const cssRule = (source, selector) => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = new RegExp(`${escaped}\\{([^}]*)\\}`).exec(source)
assert.ok(match, `missing CSS rule ${selector}`)
return match[1]
}
const pixel = (declaration, rule) => {
const match = new RegExp(`${declaration}:(\\d+(?:\\.\\d+)?)px`).exec(rule)
assert.ok(match, `missing ${declaration} in ${rule}`)
return Number(match[1])
}

test('the settings Add link panel uses the dialog form, a Pick a node placeholder and a disabled submit', async () => {
const calls = []
const graph = delivery()
const call = async (name, body) => {
calls.push({ name, body })
if (name === 'workflow/state') return { workflows: [graph] }
return { ok: true, workflows: [graph] }
}
const settings = mount(client.WorkflowSettings, { call, roster: { bots: [] } })
await settle()
button(settings.tree, 'Edit').props.onClick()
await settle()
const form = findAll(settings.tree, node => node.props?.className === 'wf-edge-form')[0]
assert.ok(form, 'the Add link panel carries wf-edge-form')
assert.equal(textOf(findAll(form, node => node.type === 'h3')[0]), 'Add link')
const pick = label => findAll(settings.tree, node => node.props?.['aria-label'] === label)[0]
for (const label of ['From node', 'To node']) {
const options = findAll(pick(label), node => node.type === 'option')
assert.equal(options[0].props.value, '')
assert.equal(textOf(options[0]), 'Pick a node', `${label} starts on a placeholder, not a blank option`)
}
assert.equal(button(settings.tree, 'Add link').props.className, 'wf-btn wf-pri')
assert.equal(button(settings.tree, 'Add link').props.disabled, true, 'the submit waits for both ends')
pick('From node').props.onChange({ target: { value: 'coder' } })
await settle()
assert.equal(button(settings.tree, 'Add link').props.disabled, true, 'one end alone does not enable the submit')
pick('To node').props.onChange({ target: { value: 'planner' } })
await settle()
assert.equal(button(settings.tree, 'Add link').props.disabled, false)
button(settings.tree, 'Add link').props.onClick()
const linked = calls.filter(item => item.name === 'workflow/graph').at(-1)
assert.ok(linked.body.edges.some(edge => edge.from === 'coder' && edge.to === 'planner'))
settings.unmount()
})

test('graph handles are small, off the wire and lit only on hover or while dragging a wire', async () => {
const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8')
const graph = delivery()
const call = async (name) => {
if (name === 'workflow/state') return { workflows: [graph] }
return { ok: true, workflows: [graph] }
}
const settings = mount(client.WorkflowSettings, { call, roster: { bots: [] } })
await settle()
assert.equal(findAll(settings.tree, node => node.props?.['data-port'] === 'out').length, 0, 'no handles outside Edit mode')
button(settings.tree, 'Edit').props.onClick()
await settle()
const ports = findAll(settings.tree, node => node.props?.['data-port'] === 'out')
assert.equal(ports.length, graph.nodes.length)
assert.match(ports[0].props.className, /\bwf-port out\b/)
assert.equal(ports.some(node => node.props.className.includes('wiring')), false)
ports[0].props.onPointerDown({ preventDefault() {}, stopPropagation() {} })
await settle()
const lit = findAll(settings.tree, node => node.props?.['data-port'] === 'out' && node.props.className.includes('wiring'))
assert.equal(lit.length, 1, 'an active wire gesture lights one handle')
settings.unmount()

const port = cssRule(source, '.wf-port')
assert.ok(pixel('width', port) <= 10, `the handle is ${pixel('width', port)}px wide`)
assert.ok(pixel('height', port) <= 10)
assert.ok(pixel('border', port) <= 1.5)
assert.match(port, /right:-5px/, 'the handle sits beside the connection line, not on it')
assert.match(source, /\.wf-port:hover[^{]*\{[^}]*opacity:1/)
assert.match(source, /\.wf-port:focus-visible[^{]*\{[^}]*opacity:1/)
assert.match(source, /\.wf-port\.wiring\{[^}]*opacity:1/)
const form = cssRule(source, '.wf-edge-form')
assert.match(form, /padding:16px/)
assert.match(cssRule(source, '.wf-edge-form h3'), /font-size:14px/)
assert.ok(pixel('font-size', cssRule(source, '.wf-edge-form h3')) <= 14)
assert.match(cssRule(source, '.wf-edge-form .wf-edge-fields'), /display:grid/)
assert.match(cssRule(source, '.wf-edge-form .wf-field'), /margin-top:0/)
})

test('the settings page injects the workflow stylesheet without the sidebar section', async () => {
const prev = globalThis.document
const stub = (existing) => {
const appended = []
return {
appended,
getElementById: id => (existing && id === 'flybot-workflow-css' ? existing : null),
createElement: () => ({ id: '', textContent: '' }),
head: { appendChild(element) { appended.push(element) } },
querySelector() { return null },
addEventListener() {},
removeEventListener() {},
}
}
try {
const doc = stub(null)
globalThis.document = doc
const view = mount(client.WorkflowSettings, { call: async () => ({ workflows: [], templates: [] }), roster: { bots: [] } })
await settle()
const style = doc.appended.find(element => element.id === 'flybot-workflow-css')
assert.ok(style, 'WorkflowSettings appended #flybot-workflow-css')
assert.match(style.textContent, /\.wf-port\{/)
assert.match(style.textContent, /\.wf-edge-form\{/)
view.unmount()

const again = stub({ id: 'flybot-workflow-css' })
globalThis.document = again
const second = mount(client.WorkflowSettings, { call: async () => ({ workflows: [], templates: [] }), roster: { bots: [] } })
await settle()
assert.equal(again.appended.length, 0, 'an existing stylesheet is not appended twice')
second.unmount()
} finally {
globalThis.document = prev
}
})
Loading