Repository navigation
Expand file tree
/
Copy pathclient.js
More file actions
2709 lines (2620 loc) · 163 KB
/
Copy pathclient.js
File metadata and controls
2709 lines (2620 loc) · 163 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Browser half of @flybot/workflow. A workflow is a row in the sidebar and, once
// opened, the main column: stage chat with normal FlyBot conversation UI, and a
// flowchart drawer. Settings → Workflow graphs is canvas-first with per-node inspector.
// The host owns the graph; this file only draws it. Every string is a React text child.
window.__ModuleLoader__.load({
id: '@flybot/workflow',
factory: (require) => {
const { createElement: h, useEffect, useRef, useState } = require('react')
let createPortal = null
try {
const dom = require('react-dom')
if (typeof dom?.createPortal === 'function') createPortal = dom.createPortal
} catch (error) {
void error
}
function portal(node) {
const root = typeof document !== 'undefined' ? document.body : null
if (typeof createPortal !== 'function' || !root) return node
return createPortal(node, root)
}
const ZH = {
Workflows: 'Workflows',
Connections: '连线',
New: '新建',
Project: '项目',
Unassigned: '未归属',
'All Projects': '全部项目',
'Filter by Project': '按项目筛选',
'No workflows in this Project': '这个项目下还没有 Workflow',
'Hand a task to a Workflow': '把任务交给 Workflow',
'A task can go to a Workflow, not only to one Bot. The Workflow then runs each Bot in order, or side by side.': '任务不再只能交给单个 Bot —— 交给 Workflow 后,会按固定流程依次(或并行)交给各个 Bot 执行。',
'Pick a task card': '从 Tasks 看板选择任务卡片',
'Pick who runs it': '选择执行者',
Cancel: '取消',
Assign: '确认分配',
'Assign to {target}': '确认分配给 {target}',
Workflow: 'Workflow',
'One Bot (as before)': '单个 Bot(原有方式)',
'Concurrent runs': '同时运行',
'{count} running · queue {depth}': '{count} 个在运行 · 队列 {depth}',
'{count} running': '{count} 个在运行',
'{name} · {count} running': '{name} · {count} 个在运行',
Reset: '重置',
Flow: '流程图',
Details: 'Details',
Runs: '运行记录',
Running: '运行中',
Done: '已完成',
Failed: '失败',
Idle: '空闲',
'Not started': '待办',
'In progress': '进行中',
'Close': '关闭',
Pause: '暂停',
Resume: '继续',
Stop: '停止',
Progress: '进度',
'New workflow': '新建 Workflow',
Name: '名称',
'Add a Bot': '添加 Bot',
Parallel: '与上一步并行',
Create: '创建',
'Local Bots': '本机 Bot',
'No workflows yet.': '还没有 Workflow。',
'Waiting for a task': '等待任务',
'A step failed': '有一步失败',
'{node} failed': '{node} 失败',
Stopped: '已停止',
'The hand-off failed': '交接失败',
'The host ended this run': '宿主中断了这次运行',
'Message the workflow': '发消息',
Trigger: '触发方式',
Manual: '手动,从任务看板分配',
Schedule: '定时',
Model: '模型',
'Each Bot\'s own model': '跟随各个 Bot 的模型',
'{count} models': '{count} 个模型',
Permissions: '权限',
'Full access, same as a normal Bot': '完全访问,与普通 Bot 相同',
'On failure': '失败策略',
'Tester retries once. Any other failed step stops the run.': 'Tester 失败后自动重试 1 次。其他节点失败则停住。',
'A failed step stops the run.': '失败的节点停住,等你处理。',
Members: '成员',
'Current run': '当前运行',
'No runs yet.': '还没有运行记录。',
'Assign the task': '确认分配',
Edit: '编辑',
'Finish editing': '完成',
'Drag a Bot here': '把 Bot 拖到图上',
'Layout lives in Settings': '布局在 Settings 画布改',
'From a template': '从模板新建',
Remove: '移除',
'System prompt': '系统提示词',
Save: '保存',
'Workflow graphs': 'Workflow 图',
'Add link': '添加连线',
'This pair already has a {kind} link — replace or delete it first.': '这一对已有一条{kind}连线,请先替换或删除它。',
'(linked)': '(已连接)',
'From node': '起点',
'To node': '终点',
'Pick a node': '选择一个节点',
'Pick an output': '选择一个输出',
Link: '连线',
Condition: '条件',
Loop: '循环',
Kind: '连线类型',
when: '当',
until: '直到',
Else: '否则',
'Max iterations': '最多次数',
'Output name': '输出名',
Equals: '等于',
Skipped: '跳过',
Queued: '排队中',
'Select a node to edit': '选择一个节点来编辑',
'Node settings': '节点设置',
Role: '角色',
Tools: '工具',
'Inputs and outputs': '输入和输出',
Delete: '删除',
Rename: '重命名',
'Delete workflow': '删除 Workflow',
'Rename workflow': '重命名 Workflow',
'Are you sure you want to delete "{name}"? This cannot be undone.': '确定删除「{name}」?此操作无法撤销。',
'No open tasks on the Tasks board.': 'Tasks 看板上没有可分配的任务。',
'Tasks this workflow holds': '此 Workflow 持有的任务',
'Re-match': '重新匹配',
'{count} more held tasks': '还有 {count} 个持有的任务',
'Could not re-match this task': '无法重新匹配该任务',
'Loading the Tasks board…': '正在读取 Tasks 看板…',
'Could not load the Tasks board.': '无法读取 Tasks 看板。',
Retry: '重试',
'Could not rename this workflow': '无法重命名该 Workflow',
'Could not delete this workflow': '无法删除该 Workflow',
'Could not save this workflow': '无法保存该 Workflow',
'Could not reset this workflow': '无法重置该 Workflow',
'Could not pause this run': '无法暂停本次运行',
'Could not resume this run': '无法继续本次运行',
'Could not stop this run': '无法停止本次运行',
'Reading workflows…': '正在读取 Workflow…',
'Changes are saved automatically': '更改会自动保存',
'Try again': '重试',
'Could not change the model': '无法更改模型',
'Next run: {time}': '下次运行:{time}',
'Step {step}': '步骤 {step}',
'Parallel branches · Merge after all pass': '并行分支 · 全部通过后汇合',
'Show more': '展开',
'Show less': '收起',
'Show {count} more cards': '展开另外 {count} 张卡片',
'Show {count} more Bots': '展开另外 {count} 个 Bot',
'Show {count} more held tasks': '展开另外持有中的 {count} 个任务',
'Showing {shown} of {count} held tasks': '共有 {count} 个持有中的任务,当前显示 {shown} 个',
Paused: '已暂停',
// Stage timeline (#1489). The key is the English line; service.js and
// narrate.js keep the Chinese text only as the fallback for rows written
// before the key existed.
'Task assigned · {task} → {workflow}': '任务已分配 · {task} → {workflow}',
'Queued · {task} ({ahead} ahead)': '排队 · {task}(前面 {ahead} 个)',
'Workflow "{name}" created · members {members}': 'Workflow「{name}」已创建 · 成员 {members}',
'⏸ Paused · {task}': '⏸ 已暂停 · {task}',
'▶ Resumed · {task}': '▶ 已继续 · {task}',
'■ Stopped · {task}': '■ 已停止 · {task}',
'✕ Failed · {task}': '✕ 失败 · {task}',
'✕ Failed · {task} · {detail}': '✕ 失败 · {task} · {detail}',
'{node} cannot start: missing input {missing}.': '{node} 不能开始:缺少输入 {missing}。',
'{node} did not print OUTPUT {outputs}; the step stands because it opened PR #{pull}.': '{node} 没有打印 OUTPUT {outputs};它已开了 PR #{pull},这一步按完成继续。',
'{node} did not print OUTPUT {outputs}; the step stands because it claimed the issue.': '{node} 没有打印 OUTPUT {outputs};它已认领 issue,这一步按完成继续。',
'{node} did not print OUTPUT {outputs}; the step stands because it claimed the issue and opened PR #{pull}.': '{node} 没有打印 OUTPUT {outputs};它已认领 issue 并开了 PR #{pull},这一步按完成继续。',
'{node} did not take this step ({code}).': '{node} 没有接到这一步({code})。',
'{node} did not take this step ({code}); retrying in {seconds}s ({tries}/{total}).': '{node} 没有接到这一步({code}),{seconds} 秒后重试({tries}/{total})。',
'{node} did not take this step ({code}); {total} retries did not land, the run failed.': '{node} 没有接到这一步({code}),重试 {total} 次仍未落地,run 已标记失败。',
'{node} step deadline reached ({minutes} min), the run was marked failed.': '{node} 这一步到达 {minutes} 分钟的截止时间,run 已标记失败。',
'Started · {task} · {nodes}': '开始 · {task} · {nodes}',
'Task {task} entered the workflow; {nodes} claimed it': '任务 {task} 已进入 Workflow,{nodes} 认领',
'{node} failed; went back to {nodes} to retry': '{node} 失败,已返回 {nodes} 重试',
'↺ Recovered from failure → {nodes}': '↺ 失败恢复 → {nodes}',
'{node} failed this step and stops here.': '{node} 这一步失败了,停在本节点。',
'Snapshot test notice.snap does not match (old copy snapshot not updated). Retried 1/2 automatically, and Coder was asked to update the snapshot.': '快照测试 notice.snap 不一致(旧文案快照未更新)。自动重试 1/2,并已通知 Coder 更新快照。',
'↻ {node} retried after a failure': '↻ {node} 失败后重试',
'↻ {node} retried automatically after a failure': '↻ {node} 失败后自动重试',
'↻ Tester retried automatically after a failure · Coder pushed the snapshot update': '↻ Tester 失败后自动重试 · Coder 已推送快照更新',
'{node} finished this step.': '{node} 完成了这一步。',
'∥ Parallel branches started: {nodes}': '∥ 并行分支开始:{nodes}',
'⇢ Parallel branches joined (join) → {nodes}': '⇢ 并行分支汇合(join)→ {nodes}',
'✓ Done · {task} · {steps} steps': '✓ 完成 · {task} · {steps} 步',
'↺ Run reset': '↺ 已重置运行',
'Broke down "{task}": Writer gets the notice under 2 lines, conclusion first; Coder replaces the copy and the collapse logic; Reviewer and Tester verify in parallel.': '已拆解「{task}」:Writer 把 notice 压到 2 行以内,结论先行;Coder 替换文案与折叠逻辑;Reviewer 与 Tester 并行验收。',
'Draft done (86 chars → 31): "The delivery team is set up; members sync automatically. Tap here for progress →"': '草稿完成(原 86 字 → 31 字):「交付组已建好,成员会自动同步。点此查看进度 →」',
'Patch submitted: replaces the copy and collapses anything over 2 lines. 3 files changed.': '补丁已提交:替换文案,超过 2 行自动折叠。改动 3 个文件。',
'Review passed. Suggestion: make the collapse threshold a constant, NOTICE_MAX_LINES.': 'Review 通过。建议把折叠阈值做成常量 NOTICE_MAX_LINES。',
'Retry passed: unit 128/128, snapshots 24/24, E2E 6/6.': '重试通过:单元 128/128,快照 24/24,E2E 6/6。',
'Merged and rolled out to 10% of traffic. Board card {id} moved to Done.': '已合并并灰度 10% 发布。看板卡片 {id} 已移到「已完成」。',
'{node} finished this step of "{task}".': '{node} 完成了「{task}」的这一步。',
}
function zhUi() {
const host = globalThis.__flybot__?.t
return typeof host === 'function' && host('Tasks') === '任务'
}
// A host call that must end: a throw, a rejection or a host that never answers settles
// as one error string, so a failed read cannot leave the page on "Reading workflows…",
// a failed save cannot look saved, and a stage control (Pause, Stop, Reset) cannot fail
// silently either. Same shape as readWorkflows()/workflowCallError() in
// core/src/client/workflows.js, kept here because this page reads the plugin's own calls.
const CALL_TIMEOUT_MS = 10000
function callError(failure, fallback) {
if (typeof failure === 'string' && failure.trim()) return failure.trim()
const message = typeof failure?.message === 'string' ? failure.message.trim() : ''
return message || fallback
}
function settledCall(call, name, body, fallback, timeoutMs = CALL_TIMEOUT_MS) {
return new Promise((resolve) => {
let settled = false
const finish = (value) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(value)
}
const timer = setTimeout(() => finish({ ok: false, error: fallback }), timeoutMs)
let pending
try {
pending = body === undefined ? call(name) : call(name, body)
} catch (failure) {
finish({ ok: false, error: callError(failure, fallback) })
return
}
Promise.resolve(pending)
.then((value) => finish(value === null || value === undefined
? { ok: false, error: fallback }
: (value.ok === false ? { ok: false, error: callError(value.error, fallback) } : value)))
.catch((failure) => finish({ ok: false, error: callError(failure, fallback) }))
})
}
function fill(text, params) {
return params ? text.replace(/\{(\w+)\}/g, (match, name) => (name in params ? String(params[name]) : match)) : text
}
function tr(key, params) {
const host = globalThis.__flybot__?.t
// Ask the host for the key alone: it fills an unknown key's params itself,
// and that fill would hide the plugin's own copy from the fallback below.
const fromHost = typeof host === 'function' ? host(key) : key
const text = fromHost !== key ? fromHost : zhUi() && Object.hasOwn(ZH, key) ? ZH[key] : key
return fill(text, params)
}
// The stage clock at the ages the core's list rows use: "just now", "5 min",
// "3 h", "2 d", then a date. Read at render time, so a Done run ages; only a
// row written before the stamp existed still paints its stored text.
function relativeTime(at) {
const ms = Number(at)
if (!Number.isFinite(ms)) return ''
const diff = Math.max(0, Date.now() - ms)
if (diff < 60_000) return tr('just now')
if (diff < 3_600_000) return tr('{n} min', { n: Math.floor(diff / 60_000) })
if (diff < 86_400_000) return tr('{n} h', { n: Math.floor(diff / 3_600_000) })
if (diff < 30 * 86_400_000) return tr('{n} d', { n: Math.floor(diff / 86_400_000) })
return new Date(ms).toLocaleDateString(zhUi() ? 'zh-CN' : 'en-US', { month: 'short', day: 'numeric' })
}
const STATUS_KEY = { queued: 'Queued', running: 'Running', done: 'Done', failed: 'Failed', skipped: 'Skipped', idle: 'Idle' }
// One graph has one state source: the last run's nodeStatus, and Idle when no run
// exists at all (#1753). Settings paints exactly this; the room Flow used to drop
// a finished run and default every seat to Queued, which read as pending work.
function nodeState(status, id) {
if (!status) return 'idle'
return status[id] ?? 'queued'
}
// Why a run failed, from the record the host keeps (#1493). A failure it did not
// name still reads Failed: the reason is added information, never a requirement.
const FAILURE_KEY = {
stopped: 'Stopped',
'dispatch-failed': 'The hand-off failed',
'host-ended': 'The host ended this run',
}
function failureText(run) {
const failure = run?.failure
if (!failure) return tr('Failed')
if (failure.code === 'step-failed') {
const head = failure.nodeName ? tr('{node} failed', { node: failure.nodeName }) : tr('A step failed')
return failure.text ? `${head} · ${failure.text}` : head
}
return tr(FAILURE_KEY[failure.code] ?? 'Failed')
}
// A row without a time (an older history entry) stays a row; it just shows none.
function runTime(at) {
const ms = Number(at)
if (!Number.isFinite(ms) || ms <= 0) return ''
const when = new Date(ms)
if (Number.isNaN(when.getTime())) return ''
const pad = (value) => String(value).padStart(2, '0')
const clock = `${pad(when.getHours())}:${pad(when.getMinutes())}`
const today = new Date()
const sameDay = when.getFullYear() === today.getFullYear() && when.getMonth() === today.getMonth() && when.getDate() === today.getDate()
return sameDay ? clock : `${pad(when.getMonth() + 1)}-${pad(when.getDate())} ${clock}`
}
const COLUMN_KEY = { 'not-started': 'Not started', 'in-progress': 'In progress', done: 'Done' }
const COLUMN_OF = { open: 'not-started', claimed: 'not-started', working: 'in-progress', blocked: 'in-progress', 'result-ready': 'in-progress', accepted: 'done', cancelled: 'done', failed: 'done' }
const COLORS = { Planner: '#8b5cf6', Writer: '#f0509a', Coder: '#1fbf6a', Reviewer: '#f5a524', Tester: '#14b8a6', Deployer: '#2f7cf6', '研究员': '#a78bfa', '数据分析师': '#ec4899' }
// One code point, not one UTF-16 unit: half a surrogate pair renders as a replacement
// character in the avatar circle (#2017, #2581).
const firstGlyph = name => Array.from(String(name ?? '').trim())[0] ?? '?'
// A cut in code points, so a label never ends inside a surrogate pair (#2581).
const cutCodePoints = (value, max) => {
const text = String(value ?? '')
return text.length <= max ? text : Array.from(text).slice(0, max).join('')
}
// Zero-width plane center line: shift wire coordinates so strokes stay inside the SVG box.
const WIRE_SPAN = 480
const NODE_WIRE_OUT = 56
const NODE_W = 168
const NODE_H = 72
// The flowchart drawer is a fixed 400px panel with 18px side padding, so its
// Flow tab fits the graph into 364px. Settings keeps its own scrolling world
// plane; only the drawer fits every node and edge to the panel width (#1513).
const DRAWER_FLOW_W = 364
const FIT_PAD = 12
// The Tools field saves a list, and the host normalizes that list on every save
// (cleanTools drops a trailing empty token). Committing per keystroke therefore
// ate the separator the next name needed, so the field keeps its text while the
// user types and commits it on blur, or after this pause (#1520).
const TOOLS_COMMIT_MS = 600
function workflowNodeIds(workflow) {
return new Set((workflow?.nodes ?? []).map(node => node.id))
}
function stageMessages(workflow, messages, roster) {
const nodeIds = workflowNodeIds(workflow)
const rosterIds = roster?.byId ? new Set(Object.keys(roster.byId)) : new Set()
return (messages ?? []).filter(message => {
if (message.kind !== 'bot') return true
if (rosterIds.has(message.botId)) return true
return !nodeIds.has(message.botId)
})
}
function useConversationInset() {
const [left, setLeft] = useState(() => conversationLeft())
useEffect(() => {
if (typeof document === 'undefined') return undefined
const sync = () => setLeft(conversationLeft())
sync()
globalThis.addEventListener?.('resize', sync)
let obs
if (typeof ResizeObserver !== 'undefined') {
obs = new ResizeObserver(sync)
const side = document.querySelector?.('.bt-side')
if (side) obs.observe(side)
}
return () => {
globalThis.removeEventListener?.('resize', sync)
obs?.disconnect?.()
}
}, [])
return left
}
function columnOf(task) {
if (task.column && COLUMN_KEY[task.column]) return task.column
return COLUMN_OF[task.status] ?? 'not-started'
}
function priorityOf(task) {
if (task.priority === 'urgent') return 'P0'
if (task.priority === 'high' || task.priority === 'P1') return 'P1'
if (task.priority === 'low' || task.priority === 'P3') return 'P3'
return 'P2'
}
function shortId(id) {
const text = String(id ?? '')
if (text.startsWith('#')) return text
if (text.startsWith('task-')) return `#${text.slice(-6)}`
return text
}
// The row clips itself with CSS (single line + ellipsis) and the full title
// rides in a tooltip, so never cut the string: a UTF-16 offset can land
// inside a surrogate pair and render an emoji/CJK Ext-B title as a replacement character.
function taskSummary(task, run) {
if (run?.taskSummary) return run.taskSummary
const id = shortId(task?.id ?? '')
const title = String(task?.title ?? '').replace(/\s+/g, ' ').trim()
if (!id && !title) return ''
if (!title) return id
if (!id) return title
// Remove issue number prefix from title if it duplicates the id
let cleanTitle = title
const issueMatch = title.match(/^issue\s+#?\d+\s*[:\-–—]\s*/i)
if (issueMatch && id) cleanTitle = title.slice(issueMatch[0].length)
return `${id} · ${cleanTitle}`
}
function sidebarPreview(item) {
const busy = (item.activeCount ?? 0) > 0 && item.run?.live === true
if (busy) return item.preview || tr('Waiting for a task')
if (item.trigger === 'schedule' && item.scheduleLabel) return tr('Next run: {time}', { time: item.scheduleLabel })
return tr('Waiting for a task')
}
function glyph() {
return h('svg', { width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', 'aria-hidden': true },
h('rect', { x: 9, y: 2, width: 6, height: 5, rx: 1.5 }),
h('rect', { x: 2, y: 17, width: 6, height: 5, rx: 1.5 }),
h('rect', { x: 16, y: 17, width: 6, height: 5, rx: 1.5 }),
h('path', { d: 'M12 7v4M5 17v-3h14v3' }))
}
function Mark({ node, roster, size = 28 }) {
const bot = node?.botId ? roster?.byId?.[node.botId] : null
const Avatar = globalThis.__flybot__?.BotAvatar
if (bot && typeof Avatar === 'function') return h(Avatar, { bot, size, badge: false, live: false })
const color = COLORS[node?.name] ?? '#4d6bfe'
return h('span', { className: 'wf-av', style: { background: color, width: size, height: size, fontSize: Math.max(11, size * 0.42) } }, firstGlyph(node?.name))
}
const BOT_TINTS = ['#4d6bfe', '#8b5cf6', '#14b8a6', '#f5a524', '#ec4899', '#1fbf6a', '#2f7cf6']
function botTint(bot) {
let hash = 0
for (const ch of String(bot?.id ?? bot?.name ?? '')) hash = (hash * 31 + ch.charCodeAt(0)) | 0
return BOT_TINTS[Math.abs(hash) % BOT_TINTS.length]
}
function botPaletteSubtitle(bot, peers) {
const role = String(bot?.role ?? '').replace(/\s+/g, ' ').trim()
const dupes = (peers ?? []).filter(item => item.name === bot.name).length > 1
if (role) return Array.from(role).length > 28 ? `${cutCodePoints(role, 27)}…` : role
if (dupes && bot?.id) return `…${String(bot.id).slice(-6)}`
return ''
}
function PaletteBotFace({ bot, size = 26 }) {
const Avatar = globalThis.__flybot__?.BotAvatar
if (typeof Avatar === 'function') return h(Avatar, { bot, size, badge: false, live: false })
const color = botTint(bot)
return h('span', {
className: 'wf-palette-av',
style: { background: color, width: size, height: size, fontSize: Math.max(11, size * 0.42) },
}, firstGlyph(bot?.name))
}
function PaletteBotChip({ bot, peers, onPointerDown, dragging }) {
const sub = botPaletteSubtitle(bot, peers)
return h('button', {
type: 'button',
className: `wf-palette-bot${dragging ? ' dragging' : ''}`,
'data-bot': bot.id,
'aria-label': sub ? `${bot.name} · ${sub}` : bot.name,
onPointerDown,
},
h('span', { className: 'wf-palette-grip', 'aria-hidden': true }),
h(PaletteBotFace, { bot, size: 26 }),
h('span', { className: 'wf-palette-copy' },
h('span', { className: 'wf-palette-name' }, bot.name),
sub ? h('span', { className: 'wf-palette-sub' }, sub) : null))
}
function nodeOf(workflow, id) {
return workflow?.nodes?.find(node => node.id === id)
}
function MiniFlowchart({ workflow, compact = false }) {
const nodes = workflow?.nodes ?? []
const edges = workflow?.edges ?? []
if (nodes.length === 0) return null
const layers = workflow?.layers ?? [[nodes[0]?.id]]
const width = compact ? 28 : 160
const nodeW = compact ? 10 : 32
const nodeH = compact ? 10 : 32
const spacing = compact ? 14 : 40
const layerSpacing = compact ? 14 : 48
const height = compact ? 28 : layers.length * layerSpacing + nodeH
const viewW = compact ? 28 : width
const viewH = compact ? 28 : height
const markerId = compact ? 'wf-mini-arrow-c' : 'wf-mini-arrow'
return h('svg', {
width: compact ? '100%' : width,
height: compact ? '100%' : height,
viewBox: `0 0 ${viewW} ${viewH}`,
preserveAspectRatio: 'xMidYMid meet',
className: compact ? 'wf-mini-flow wf-mini-flow-compact' : 'wf-mini-flow',
'aria-hidden': compact ? true : undefined,
'aria-label': compact ? undefined : workflow.name,
},
h('defs', null,
h('marker', { id: markerId, viewBox: '0 0 10 10', refX: '8', refY: '5', markerWidth: compact ? '3' : '4', markerHeight: compact ? '3' : '4', orient: 'auto' },
h('path', { d: 'M0 0 10 5 0 10z', fill: 'var(--bt-ink-3, #9a9a9a)' }))),
edges.map(edge => {
const fromIdx = layers.findIndex(layer => layer.includes(edge.from))
const toIdx = layers.findIndex(layer => layer.includes(edge.to))
if (fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx) return null
const fromLayer = layers[fromIdx]
const toLayer = layers[toIdx]
const fromPos = fromLayer.indexOf(edge.from)
const toPos = toLayer.indexOf(edge.to)
const midX = viewW / 2
const x1 = midX + (fromPos - (fromLayer.length - 1) / 2) * spacing
const y1 = fromIdx * layerSpacing + nodeH
const x2 = midX + (toPos - (toLayer.length - 1) / 2) * spacing
const y2 = toIdx * layerSpacing
return h('path', {
key: `${edge.from}>${edge.to}`,
d: `M ${x1} ${y1} L ${x2} ${y2}`,
stroke: 'var(--bt-ink-3, #9a9a9a)',
strokeWidth: compact ? 1.2 : 1,
fill: 'none',
markerEnd: `url(#${markerId})`,
})
}),
layers.map((layer, layerIdx) => layer.map((nodeId, pos) => {
const node = nodeOf(workflow, nodeId)
if (!node) return null
const x = viewW / 2 + (pos - (layer.length - 1) / 2) * spacing - nodeW / 2
const y = layerIdx * layerSpacing
const color = COLORS[node.name] ?? '#4d6bfe'
return h('g', { key: nodeId },
h('rect', {
x, y, width: nodeW, height: nodeH, rx: compact ? 2 : 6,
fill: color,
opacity: 0.15,
stroke: color,
strokeWidth: 1,
}),
compact ? null : h('text', {
x: x + nodeW / 2,
y: y + nodeH / 2 + 1,
textAnchor: 'middle',
dominantBaseline: 'middle',
fill: color,
fontSize: 12,
fontWeight: 600,
}, firstGlyph(node.name)))
})))
}
function RunStrip({ workflow, run, focused, locked, onFocus, onPause, onResume, onStop, onTab, onOpenDrawer }) {
const running = (run.running ?? []).map(id => nodeOf(workflow, id)).filter(Boolean)
const chipClass = run.status === 'done' ? 'done' : (run.status === 'failed' ? 'failed' : (run.paused ? 'queued' : (running[0] ? 'running' : 'queued')))
const chipLabel = run.status === 'done' ? tr('Done') : (run.status === 'failed' ? tr('Failed') : (run.paused ? tr('Paused') : (running[0] ? tr('Running') : tr('Queued'))))
const meta = run.status === 'failed'
? failureText(run)
: run.paused
? `${tr('Paused')} · ${running.map(node => node.name).join(' ‖ ') || '—'} · ${run.doneCount}/${run.nodeCount}`
: (running[0]
? `${tr('Running')} · ${running.map(node => node.name).join(' ‖ ')} · ${run.doneCount}/${run.nodeCount}`
: (run.status === 'done' ? tr('Done') : tr('Waiting for a task')))
const focus = () => onFocus?.(run.id)
const stopBubble = (event) => { event?.stopPropagation?.() }
return h('div', {
className: `wf-strip ${focused ? 'wf-strip-focus' : ''}`,
'data-run-id': run.id,
role: 'button',
tabIndex: 0,
onClick: focus,
onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); focus() } },
},
h('div', { className: 'wf-strip-head' },
h('div', { className: 'wf-strip-id' },
h('div', { className: 'wf-tk', title: run.task?.title ?? '' }, taskSummary(run.task, run)),
h('div', { className: 'wf-meta' }, meta)),
h('span', { className: `wf-chip ${chipClass}` }, chipLabel)),
h('div', { className: 'wf-strip-progress', 'aria-label': tr('Progress'), title: tr('Progress') },
h('div', { className: 'wf-segs' }, (run.marks ?? []).map((mark, index) => h('span', { key: index, className: `wf-seg ${mark}` })))),
h('div', { className: 'wf-strip-actions' },
locked ? null : (run.status === 'running' ? (run.paused
? h('button', { type: 'button', className: 'wf-btn wf-sm wf-pri', onClick: (event) => { stopBubble(event); onResume?.(run.id) } }, tr('Resume'))
: h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: (event) => { stopBubble(event); onPause?.(run.id) } }, tr('Pause'))) : null),
locked ? null : (run.status === 'running' ? h('button', { type: 'button', className: 'wf-btn wf-sm wf-danger-outline', onClick: (event) => { stopBubble(event); onStop?.(run.id) } }, tr('Stop')) : null),
h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: (event) => { stopBubble(event); focus(); onOpenDrawer?.(); onTab('runs') } }, tr('Progress')),
h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: (event) => { stopBubble(event); focus(); onOpenDrawer?.(); onTab('flow') } }, tr('Flow'))))
}
// Stage: view/control only — progress and flowchart; no message composer (#1459).
function Stage({ workflow, roster, locked, readOnlyReason, stageLeft, drawer, tab, focusedRunId, onFocusRun, onCloseDrawer, onToggleDrawer, onOpenDrawer, onTab, onAssign, onTip, onReset, editing, onToggleEdit, onCommit, onPause, onResume, onStop, onRematch, onHeldMore }) {
const chatRef = useRef(null)
const chatFoot = useRef({ height: 0, shown: 0 })
const liveRuns = liveStageRuns(workflow)
const held = workflow.heldTasks ?? []
// #2256: the id list is a page. Folded, the reader sees the page the state read
// carries; unfolded, every id the host sent for the wider read.
const [heldWide, setHeldWide] = useState(false)
// Another lane is another page: switching folds whatever the last one had unfolded.
useEffect(() => { setHeldWide(false) }, [workflow.id])
const heldShown = heldWide ? held : held.slice(0, HELD_PAGE)
// #2260: the wider read stops at HELD_LIMIT, so a count taken from heldTaskCount alone
// promised ids the next click could not reach. The folded control counts what it reaches.
const heldTotal = workflow.heldTaskCount ?? held.length
const heldRest = Math.min(heldTotal - heldShown.length, HELD_LIMIT - heldShown.length)
const viewRun = resolveFocusedRun(workflow, focusedRunId)
const showSummary = (workflow.queueDepth ?? 0) > 0 || liveRuns.length > 1
const visible = stageMessages(workflow, workflow.messages, roster)
useEffect(() => {
const el = chatRef.current
if (!el) { chatFoot.current = { height: 0, shown: 0 }; return }
const prev = chatFoot.current
chatFoot.current = { height: el.scrollHeight, shown: visible.length }
// A stage line that is filtered out changes no pixel, so it must not move the chat.
if (visible.length <= prev.shown) return
// Follow a new line only from the bottom; a reader who scrolled up keeps their place.
if (prev.height > 0 && el.scrollTop + (el.clientHeight ?? 0) < prev.height - 24) return
el.scrollTop = el.scrollHeight
}, [workflow.messages.length])
return h('div', { className: 'wf-stage', style: { left: stageLeft } },
h('div', { className: 'wf-main' },
h('div', { className: 'wf-top' },
h('button', { type: 'button', className: 'wf-pill', onClick: onToggleDrawer },
h('span', { className: 'wf-pill-thumb' }, h(MiniFlowchart, { workflow, compact: true })),
h('span', { className: 'wf-pill-name' }, workflow.name),
h('span', { className: 'wf-chev', 'aria-hidden': true }, '▾')),
h('div', { className: 'wf-actions' },
locked ? null : h('button', { type: 'button', className: 'wf-btn wf-pri', onClick: onAssign }, tr('Hand a task to a Workflow')))),
locked ? h('div', { className: 'wf-muted wf-read-only', role: 'status' }, readOnlyReason) : null,
showSummary ? h('div', { className: 'wf-strip wf-strip-summary' },
h('span', { className: 'wf-chip queued' }, tr('{count} running · queue {depth}', { count: workflow.activeCount ?? liveRuns.length, depth: workflow.queueDepth ?? 0 }))) : null,
held.length > 0 ? h('div', { className: 'wf-held' },
h('div', { className: 'wf-held-head' }, tr('Tasks this workflow holds')),
heldShown.map(taskId => h('div', { className: 'wf-held-row', key: taskId },
h('span', { className: 'wf-held-id', title: taskId }, taskId),
locked ? null : h('button', { type: 'button', className: 'wf-btn wf-sm', onClick: () => onRematch?.(taskId) }, tr('Re-match')))),
// #2249 gave this page the one unfold control; #2256 puts the held ids behind
// it, so the rest of the list is reachable instead of being named and dropped.
// #2260: past the 200-id page cap the unfolded list states the cap it cannot pass.
heldWide && heldTotal > held.length
? h('div', { className: 'wf-held-note' }, tr('Showing {shown} of {count} held tasks', { shown: held.length, count: heldTotal }))
: null,
heldWide || heldTotal > heldShown.length
? h('button', {
type: 'button', className: 'wf-more wf-held-more', 'data-more': 'held', 'aria-expanded': heldWide,
onClick: () => { const next = !heldWide; setHeldWide(next); onHeldMore?.(next) },
}, heldWide ? tr('Show less') : tr('Show {count} more held tasks', { count: heldRest }))
: null) : null,
liveRuns.length > 0 ? h('div', { className: 'wf-strips' },
liveRuns.map(run => h(RunStrip, {
key: run.id, workflow, run, focused: viewRun?.id === run.id, locked,
onFocus: onFocusRun, onPause, onResume, onStop, onTab, onOpenDrawer,
}))) : null,
visible.length > 0 ? h('div', { className: 'wf-chat', ref: chatRef },
h('div', { className: 'wf-chat-in' },
visible.map(message => h(Bubble, { key: message.id, message, workflow, roster })))) : null),
drawer ? h('aside', { className: 'wf-drawer' },
h('div', { className: 'wf-dr-head' },
h('button', { type: 'button', className: 'wf-icon', 'aria-label': tr('Close'), onClick: onCloseDrawer }, '×')),
h(DrawerBody, { workflow, roster, tab, locked, onTab, onReset, onTip, editing, onToggleEdit, onCommit, viewRun })) : null)
}
function Bubble({ message, workflow, roster }) {
if (message.kind === 'time') return h('div', { className: 'wf-ts' }, relativeTime(message.at) || message.text)
if (message.kind === 'system') {
const text = message.key ? tr(message.key, message.params) : message.text
return h('div', { className: `wf-sys ${message.tone ?? ''}`, title: text }, text)
}
if (message.kind === 'user') return h('div', { className: 'wf-user', title: message.text }, message.text)
const node = nodeOf(workflow, message.botId) ?? { id: message.botId, name: message.botId }
const text = message.key ? tr(message.key, message.params) : message.text
return h('div', { className: 'wf-msg' },
h(Mark, { node, roster, size: 28 }),
h('div', null,
h('div', { className: 'wf-who' }, node.name, message.step ? h('span', { className: 'wf-step' }, tr('Step {step}', { step: tr(message.step) })) : null),
h('div', { className: 'wf-bub', title: text }, text,
message.attachments?.length ? h('div', { className: 'wf-att' }, message.attachments.map(name => h('span', { key: name }, name))) : null)))
}
function DrawerBody({ workflow, roster, tab, locked, onTab, onReset, onTip, editing, onToggleEdit, onCommit, viewRun }) {
const run = viewRun?.live === true ? viewRun : null
// The Flow tab paints the run the stage already has — the live run if there is
// one, else the last one — so an idle workflow shows its last result, the same
// graph.run.nodeStatus Settings draws, instead of an empty map (#1753).
const nodeStatus = run?.nodeStatus ?? workflow.run?.nodeStatus ?? null
return h('div', { className: 'wf-dr-body' },
h('div', { className: 'wf-dr-title' },
h('div', { className: 'wf-ico wf-ico-lg' }, glyph()),
h('h2', null, workflow.name),
h('p', null, workflow.description)),
h('div', { className: 'wf-tabs', role: 'tablist' },
['flow', 'details', 'runs'].map(key => h('button', {
key, type: 'button', role: 'tab', 'aria-selected': tab === key, className: tab === key ? 'on' : '', onClick: () => onTab(key),
}, tr(key === 'flow' ? 'Flow' : key === 'details' ? 'Details' : 'Runs')))),
tab === 'flow' ? h(Flow, { workflow, run, nodeStatus, roster, onTip, editing: locked ? false : editing, onCommit }) : null,
tab === 'details' ? h(Details, { workflow, roster }) : null,
tab === 'runs' ? h(Runs, { workflow }) : null,
locked ? null : h('div', { className: 'wf-ctl' },
h('button', { type: 'button', className: 'wf-btn', onClick: onReset }, `↺ ${tr('Reset')}`),
h('button', { type: 'button', className: 'wf-btn', onClick: onToggleEdit }, editing ? tr('Finish editing') : tr('Edit'))))
}
function nextNodeId(nodes) {
let n = nodes.length + 1
const ids = new Set(nodes.map(node => node.id))
while (ids.has(`n${n}`)) n += 1
return `n${n}`
}
function hitNode(event) {
const direct = event.target?.dataset?.node ? event.target : null
const nested = event.target?.closest?.('[data-node]')
return (nested ?? direct)?.dataset?.node || ''
}
// The host refuses a graph that lists one pair twice, and that refusal saves nothing, so a
// gesture that repeats a pair it already sees would turn the whole edit into an error the
// picture never shows. Adding an edge the graph already has is therefore a no-op here.
function withEdge(list, from, to) {
if (list.some(edge => edge.from === from && edge.to === to)) return list
return list.concat([{ from, to }])
}
// The host keys a link by its pair alone, so a second edge over the same two Bots refuses the
// whole save whatever its kind (#2173). The Settings form has to read the kind that is there
// before it submits, and name it to the user.
const EDGE_KIND_LABEL = { forward: 'Link', condition: 'Condition', loop: 'Loop' }
const blankEdgeForm = () => ({ from: '', to: '', kind: 'forward', max: 5, until: { output: '', eq: '' } })
function edgeKindOf(edge) {
return edge.loop === true ? 'loop' : edge.when ? 'condition' : 'forward'
}
// The Settings plane is zero-width, so world x sits on a ±WIRE_SPAN centre
// line; the fitted drawer plane starts at 0, so it passes span 0 (#1513).
function wirePath(from, to, span = WIRE_SPAN) {
const shift = typeof span === 'number' ? span : WIRE_SPAN
const x1 = (from.x ?? 0) + shift
const y1 = (from.y ?? 0) + NODE_WIRE_OUT
const x2 = (to.x ?? 0) + shift
const y2 = to.y ?? 0
if (Math.abs(x1 - x2) < 0.5) {
const mid = (y1 + y2) / 2
return `M ${x1} ${y1} C ${x1} ${mid}, ${x2} ${mid}, ${x2} ${y2}`
}
const midY = Math.round((y1 + y2) / 2)
return `M ${x1} ${y1} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${y2}`
}
// The drawn card's own size: a fitted plane keeps the 168x72 card, and the
// laid-out one shrinks it to the panel (#1693). Reading the box here is what
// keeps the frame around the cards it encloses instead of the old card size.
const FRAME_PAD = 10
// The label rides the frame's top border, so the band above the first card is
// taller than the side and bottom pads.
const FRAME_LABEL = 20
function parallelFrameStyle(members) {
const pad = FRAME_PAD
const boxes = members.map(node => {
const x = node.x ?? 0
const y = node.y ?? 0
const w = node.w ?? NODE_W
const h = node.h ?? NODE_H
return { left: x - w / 2, right: x + w / 2, top: y, bottom: y + h }
})
const left = Math.min(...boxes.map(box => box.left))
const right = Math.max(...boxes.map(box => box.right))
const top = Math.min(...boxes.map(box => box.top))
const bottom = Math.max(...boxes.map(box => box.bottom))
return {
left: left - pad,
top: top - FRAME_LABEL,
width: right - left + pad * 2,
// The box has to enclose the cards it names: the label band above them and a
// pad below the last row, not just one pad for both (#1738).
height: bottom - top + FRAME_LABEL + pad,
}
}
// Fit a graph to a panel width: only the node centres scale, never the node
// boxes, so labels stay readable. Positions come back in panel coordinates
// (x from the left edge, y from the top), the graph is centred horizontally,
// and `scale` is never above 1 so a wide Settings canvas never enlarges it.
// It maps world → fitted; `offsetX` / `offsetY` invert a pointer position.
function fitPlane(nodes, panelWidth = DRAWER_FLOW_W) {
const width = Math.max(Number(panelWidth) || DRAWER_FLOW_W, NODE_W + FIT_PAD * 2)
const list = (nodes ?? []).filter(node => node && typeof node === 'object')
const xs = list.map(node => Number(node.x) || 0)
const ys = list.map(node => Number(node.y) || 0)
const minX = xs.length ? Math.min(...xs) : 0
const maxX = xs.length ? Math.max(...xs) : 0
const minY = ys.length ? Math.min(...ys) : 0
const maxY = ys.length ? Math.max(...ys) : 0
const span = maxX - minX
const room = Math.max(0, width - FIT_PAD * 2 - NODE_W)
const scale = span > 0 ? Math.min(1, room / span) : 1
const offsetX = width / 2 - ((minX + maxX) / 2) * scale
const offsetY = FIT_PAD - minY
return {
width,
scale,
offsetX,
offsetY,
height: Math.max(420, maxY + NODE_H + FIT_PAD + offsetY),
nodes: list.map(node => ({ ...node, x: (node.x ?? 0) * scale + offsetX, y: (node.y ?? 0) + offsetY })),
}
}
// The drawer's read-only flowchart is laid out from the steps, not from the
// stored editor coordinates: hand-dragged x/y drawn into a narrow panel put
// nodes on top of the parallel frame and left wires hanging in empty space.
// Rows follow `layers`, a parallel row sits inside its dashed frame, node
// boxes shrink to the panel width, and each wire is routed orthogonally from
// the drawn boxes — `heights` are the measured node heights, so a card that
// wraps to a taller box moves its anchors with it.
const FLOW_GAP_X = 12
const FLOW_ROW_GAP = 36
const FLOW_PAR_PAD = 10
const FLOW_PAR_LABEL = 28
const FLOW_NODE_MIN_W = 96
const FLOW_WRAP_GAP = 12
function flowRows(workflow) {
const nodes = (workflow?.nodes ?? []).filter(node => node && typeof node.id === 'string')
const ids = new Set(nodes.map(node => node.id))
const seen = new Set()
const rows = []
for (const layer of workflow?.layers ?? []) {
const row = (Array.isArray(layer) ? layer : []).filter(id => ids.has(id) && !seen.has(id))
row.forEach(id => seen.add(id))
if (row.length) rows.push(row)
}
for (const node of nodes) if (!seen.has(node.id)) rows.push([node.id])
return rows
}
function flowLayout(workflow, panelWidth = DRAWER_FLOW_W, heights = {}) {
const width = Math.max(Number(panelWidth) || DRAWER_FLOW_W, FLOW_NODE_MIN_W + FIT_PAD * 2)
const rows = flowRows(workflow)
const boxes = new Map()
const frames = []
const bands = []
let y = FIT_PAD
rows.forEach((row, index) => {
const par = row.length > 1
const inner = width - FIT_PAD * 2 - (par ? FLOW_PAR_PAD * 2 : 0)
// A layer wider than the plane wraps onto more lines inside the same frame
// instead of running past the panel edge, where the clipped branches cannot
// be reached at all (#1658). The column count comes from the width, so a
// 3-branch layer in a 400px drawer keeps one line.
const columns = Math.max(1, Math.min(row.length, Math.floor((inner + FLOW_GAP_X) / (FLOW_NODE_MIN_W + FLOW_GAP_X))))
const perLine = Math.ceil(row.length / Math.ceil(row.length / columns))
const lines = []
for (let at = 0; at < row.length; at += perLine) lines.push(row.slice(at, at + perLine))
const top = y + (par ? FLOW_PAR_LABEL : 0)
let cursor = top
let leftEdge = width
let rightEdge = 0
const linesOfBand = []
lines.forEach((line, lineIndex) => {
const w = Math.max(FLOW_NODE_MIN_W, Math.min(NODE_W, Math.floor((inner - FLOW_GAP_X * (line.length - 1)) / line.length)))
const total = w * line.length + FLOW_GAP_X * (line.length - 1)
const start = Math.round(width / 2 - total / 2)
let lineH = 0
line.forEach((id, column) => {
const hgt = Math.max(24, Number(heights?.[id]) || NODE_H)
lineH = Math.max(lineH, hgt)
const left = start + column * (w + FLOW_GAP_X)
boxes.set(id, { id, row: index, line: lineIndex, left, top: cursor, w, h: hgt, x: left + w / 2 })
})
linesOfBand.push({ top: cursor, bottom: cursor + lineH })
leftEdge = Math.min(leftEdge, start)
rightEdge = Math.max(rightEdge, start + total)
cursor += lineH + (lineIndex < lines.length - 1 ? FLOW_WRAP_GAP : 0)
})
const bottom = cursor + (par ? FLOW_PAR_PAD : 0)
const span = rightEdge - leftEdge
// The label rides the frame's top border; with two branches one line, it fits
// between the two drops so no wire runs through it.
const labelMax = lines.length === 1 && row.length === 2 ? span + FLOW_GAP_X - 16 : span
if (par) frames.push({ key: row.join('+'), left: leftEdge - FLOW_PAR_PAD, top: y, width: span + FLOW_PAR_PAD * 2, height: bottom - y, labelMax })
bands.push({ top: y, bottom, lines: linesOfBand, left: leftEdge, right: rightEdge })
y = bottom + FLOW_ROW_GAP
})
const height = rows.length ? y - FLOW_ROW_GAP + FIT_PAD : 0
return { width, height, rows, boxes, frames, bands, wires: flowWires(workflow?.edges ?? [], boxes, bands, width) }
}
// The drawer's Edit plane. `fitPlane` scales only the node centres (#1513), so the
// cards keep their 168px width: three or more branches of one parallel step land
// on top of each other in a 364px drawer, the later card takes the click, and the
// branch's out-port cannot start a wire (#1677). A two-branch step is squeezed the
// same way: the drawer's fit leaves the pair 4px apart, so each card's out-port and
// remove button sit under its neighbour and the frame runs against the panel edge
// (#1738). A row that is that tight is drawn at `flowLayout`'s own column width, the
// width the Read-only plane gives it, so it keeps the row gap and stays a fitted
// plane whose drags still commit world coordinates; a row that cannot keep the gap
// even then (three or more branches) falls back to the laid-out rows, whose
// coordinates are the panel's own, which makes a drag a plain pointer delta.
const FIT_PAR_GAP = FLOW_GAP_X
function editPlane(workflow, panelWidth = DRAWER_FLOW_W, heights = {}) {
const width = Math.max(Number(panelWidth) || DRAWER_FLOW_W, NODE_W + FIT_PAD * 2)
const fit = fitPlane(workflow?.nodes, width)
const at = new Map(fit.nodes.map(node => [node.id, node]))
const rows = (workflow?.layers ?? [])
.map(layer => (layer ?? []).map(id => at.get(id)).filter(Boolean))
.filter(row => row.length > 1)
const tight = rows.some(row => row.some((a, i) => row[i + 1] && Math.abs(row[i + 1].x - a.x) < NODE_W + FIT_PAR_GAP))
const rowLayout = tight ? flowLayout(workflow, width, heights) : null
// The width this card is drawn at: the Read-only plane's column width while that
// is narrower than the 168px card, the card's own width otherwise.
const cardWidth = id => {
const box = rowLayout?.boxes.get(id)
return box && box.w < NODE_W ? box.w : NODE_W
}
const crowded = rows.some(row => row.some((a, i) => row.some((b, j) => j > i &&
Math.abs(a.y - b.y) < NODE_H &&
Math.abs(a.x - b.x) < (cardWidth(a.id) + cardWidth(b.id)) / 2 + FIT_PAR_GAP)))
if (!crowded) {
const nodes = fit.nodes.map(node => ({ ...node, w: cardWidth(node.id) }))
return { width, scale: fit.scale, offsetX: fit.offsetX, offsetY: fit.offsetY, height: fit.height, wrapped: false, nodes }
}
// The laid-out rows are `flowLayout`'s own boxes, and its wires are the ones
// routed around those same boxes — one copy of the row geometry (#1693).
const layout = rowLayout ?? flowLayout(workflow, width, heights)
const stored = new Map((workflow?.nodes ?? []).map(node => [node.id, node]))
const drawn = [...layout.boxes.values()].map(box => ({
...stored.get(box.id), id: box.id, x: box.x, y: box.top, w: box.w, h: box.h,
}))
return { width: layout.width, scale: 1, offsetX: 0, offsetY: 0, height: Math.max(420, layout.height), wrapped: true, nodes: drawn, wires: layout.wires, frames: layout.frames }
}
// One bus per connected group of edges between two adjacent rows: every
// source drops to the bus, every target rises from it, so a fork leaves one
// point and a join meets in one point. Skip and loop edges run in the right
// gutter. Every point is a box anchor or lies on the segment between two.
// A layer wider than the panel wraps onto more lines inside one band (#1658),
// and one band for all its lines means a straight run across the band would
// cut through the cards of the other line (#1659): a card that is not on the
// band's last or first line leaves through the wrap seam and runs down the
// column gap at the band's left edge, which no card reaches.
function flowWires(edges, boxes, bands, width) {
const wires = []
const gaps = new Map()
const skips = []
const pathOf = marks => marks.map((point, i) => `${i === 0 ? 'M' : 'L'} ${point[0]} ${point[1]}`).join(' ')
const lastLine = band => (band.lines?.length ?? 1) - 1
const seamY = (band, line) => Math.round(band.lines[line].bottom + FLOW_WRAP_GAP / 2)
const channelX = band => Math.round(band.left - FLOW_WRAP_GAP / 2)
for (const edge of edges) {
const from = boxes.get(edge?.from)
const to = boxes.get(edge?.to)
if (!from || !to || from === to) continue
if (!edge.loop && to.row === from.row + 1) {
const list = gaps.get(from.row) ?? []
list.push({ edge, from, to })
gaps.set(from.row, list)
} else skips.push({ edge, from, to })
}
for (const [row, list] of gaps) {
// Union the edges of this gap into groups that share a source or target.
const parent = new Map()
const find = key => { while (parent.get(key) !== key) key = parent.get(key); return key }
const join = (a, b) => { parent.set(find(a), find(b)) }
for (const { from, to } of list) {
if (!parent.has(`s${from.id}`)) parent.set(`s${from.id}`, `s${from.id}`)
if (!parent.has(`t${to.id}`)) parent.set(`t${to.id}`, `t${to.id}`)
join(`s${from.id}`, `t${to.id}`)
}
const groups = new Map()
for (const item of list) {
const key = find(`s${item.from.id}`)
const group = groups.get(key) ?? { sources: new Map(), targets: new Map(), edges: [] }
group.sources.set(item.from.id, item.from)
group.targets.set(item.to.id, item.to)
group.edges.push(item.edge)
groups.set(key, group)
}
const fromBand = bands[row]
const toBand = bands[row + 1]
const fromLast = lastLine(fromBand)
const downX = channelX(fromBand)
const upX = channelX(toBand)
const gapTop = fromBand.bottom
const gapBottom = toBand.top
const ordered = [...groups.values()]
ordered.forEach((group, k) => {
const busY = Math.round(gapTop + (gapBottom - gapTop) * (k + 1) / (ordered.length + 1))
const sources = [...group.sources.values()]
const targets = [...group.targets.values()]
const key = group.edges.map(edge => `${edge.from}>${edge.to}`).join(',')
const sourceMarks = box => {
const bottom = box.top + box.h
const line = box.line ?? 0
if (line >= fromLast) return [[box.x, bottom], [box.x, busY]]
const seam = seamY(fromBand, line)
return [[box.x, bottom], [box.x, seam], [downX, seam], [downX, busY]]
}
const targetMarks = box => {
const line = box.line ?? 0
if (line <= 0) return [[box.x, busY], [box.x, box.top]]
const seam = seamY(toBand, line - 1)
return [[box.x, busY], [upX, busY], [upX, seam], [box.x, seam], [box.x, box.top]]
}
if (sources.length === 1 && targets.length === 1 && Math.abs(sources[0].x - targets[0].x) < 0.5) {
const source = sources[0]
const target = targets[0]
const x = source.x
if ((source.line ?? 0) >= fromLast && (target.line ?? 0) <= 0) {
wires.push({ key, kind: 'step', from: [source.id], to: [target.id], d: `M ${x} ${source.top + source.h} L ${x} ${target.top}`, arrow: true })
return
}
const marks = sourceMarks(source)
const rise = targetMarks(target)
if (marks.at(-1)[0] !== rise[0][0]) marks.push([rise[0][0], busY])
marks.push(...rise.slice(1))
wires.push({ key, kind: 'step', from: [source.id], to: [target.id], d: pathOf(marks), arrow: true })
return
}
const xs = sources.concat(targets).map(box => box.x)
let minX = Math.min(...xs)
const maxX = Math.max(...xs)
if (sources.some(box => (box.line ?? 0) < fromLast)) minX = Math.min(minX, downX)
const targetIds = targets.map(box => box.id)
const parts = sources.map(box => pathOf(sourceMarks(box)))