dsh-workflow-map 0.0.0-stage → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -0
- package/LICENSE +21 -0
- package/README.en.md +447 -0
- package/README.md +423 -2
- package/client.js +9673 -0
- package/cordis.patch.yml +11 -0
- package/docs/OPEN-SOURCE-GUIDE.md +256 -0
- package/docs/screenshot-detail.png +0 -0
- package/docs/screenshot.png +0 -0
- package/icon.svg +6 -0
- package/index.js +12 -0
- package/lib/index.js +974 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +65 -4
- package/src/client/Branches.js +241 -0
- package/src/client/FlowColumn.js +572 -0
- package/src/client/FlowEffects.js +677 -0
- package/src/client/HotInject.js +628 -0
- package/src/client/MainFlow.js +359 -0
- package/src/client/NodeCard.js +545 -0
- package/src/client/NodeDetail.js +569 -0
- package/src/client/ProgressUnder.js +216 -0
- package/src/client/RightPage.js +285 -0
- package/src/client/Settings.js +212 -0
- package/src/client/StepColumn.js +479 -0
- package/src/client/StepDetail.js +250 -0
- package/src/client/export.js +280 -0
- package/src/client/panel.js +1911 -0
- package/src/client/pulse.js +126 -0
- package/src/client/store.js +1090 -0
- package/src/client/theme.js +1164 -0
- package/src/fold.mjs +2633 -0
- package/src/orchestrator.mjs +426 -0
- package/src/route.js +621 -0
|
@@ -0,0 +1,1911 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* panel.js — the React component, its hooks and the pure renderer.
|
|
3
|
+
*
|
|
4
|
+
* Structure:
|
|
5
|
+
* wfMakeSay dictionary lookup with {placeholder} interpolation + fallback
|
|
6
|
+
* wfUseSessionId props.useSessions → the active session id
|
|
7
|
+
* wfUsePrefs localStorage-backed {width, height, collapsed, selectedStep}
|
|
8
|
+
* wfUseSnap the 800 ms / 3 s conditional poll loop
|
|
9
|
+
* wfUseStyle <style id="dsh-workflow-map-css">, removed on unmount
|
|
10
|
+
* wfUseAltW Alt+W toggles collapsed
|
|
11
|
+
* wfUseSessionReset drops a remembered step selection when the session changes
|
|
12
|
+
* WorkflowMapPanel the overlay occupant
|
|
13
|
+
* wfRenderPanel PURE: (model, prefs, say, actions, ui) → element tree | null
|
|
14
|
+
* apply registration
|
|
15
|
+
*
|
|
16
|
+
* `wfRenderPanel` is deliberately hook-free so tools/selftest-panel.mjs can drive it
|
|
17
|
+
* with a fake `createElement` and assert the exact tree the frame will mount.
|
|
18
|
+
*
|
|
19
|
+
* Inlined into client.js by tools/build-client.mjs (single-file browser half).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/* ------------------------------------------------------------------------- */
|
|
23
|
+
/* locale */
|
|
24
|
+
/* ------------------------------------------------------------------------- */
|
|
25
|
+
|
|
26
|
+
// Flat dotted keys (the dsh-context convention). zh/en must carry IDENTICAL key
|
|
27
|
+
// sets; a half-translated dictionary would ship a raw key to the user.
|
|
28
|
+
var DICT_ZH = {
|
|
29
|
+
"panel.aria": "工作流地图:{task}",
|
|
30
|
+
"panel.title": "工作流地图",
|
|
31
|
+
"panel.task.fallback": "当前会话",
|
|
32
|
+
"panel.progress": "{done}/{total} 步 · {percent}%",
|
|
33
|
+
"panel.planning": "规划中…",
|
|
34
|
+
"panel.planning.note": "计划尚未提交,提交后会显示分步。",
|
|
35
|
+
"panel.collapse": "收起",
|
|
36
|
+
"panel.expand": "展开",
|
|
37
|
+
"panel.settings": "设置",
|
|
38
|
+
"panel.steps": "总任务分步",
|
|
39
|
+
"panel.flow": "分步流程",
|
|
40
|
+
"panel.flow.empty": "这一步还没有动作",
|
|
41
|
+
"panel.nodes": "{count} 个节点",
|
|
42
|
+
"panel.step.nodes": "{count} 节点",
|
|
43
|
+
"panel.files": "{count} 文件",
|
|
44
|
+
"panel.node.error": "失败",
|
|
45
|
+
"panel.truncated": "已省略 {count} 个节点",
|
|
46
|
+
"panel.flow.step": "第 {n} 步 · {title}",
|
|
47
|
+
"panel.flow.group": "{count} 个连续 {tool}",
|
|
48
|
+
"panel.flow.range": "{first}–{last}",
|
|
49
|
+
"panel.flow.more": "更早 {count} 个被折叠",
|
|
50
|
+
"panel.flow.showAll": "展开全部",
|
|
51
|
+
"panel.flow.expandGroup": "展开分组",
|
|
52
|
+
"panel.flow.collapseGroup": "收起分组",
|
|
53
|
+
// the round branch icons. The ARIA label says WHAT each circle is (read / edit / the
|
|
54
|
+
// tool being called), because a bare glyph is not a name; the visible label under it is the
|
|
55
|
+
// short form of the same fact.
|
|
56
|
+
"panel.flow.icon.read": "读取",
|
|
57
|
+
"panel.flow.icon.edit": "修改",
|
|
58
|
+
"panel.flow.icon.tool": "工具",
|
|
59
|
+
"panel.flow.branch": "{kind} {count} 处",
|
|
60
|
+
"panel.flow.branch.aria": "{count} 条分支(读取 / 修改 / 调用的工具)",
|
|
61
|
+
"panel.flow.branch.open": "展开{kind}详情",
|
|
62
|
+
"panel.flow.branch.close": "收起{kind}详情",
|
|
63
|
+
"panel.branch": "支线",
|
|
64
|
+
"panel.branch.subagent": "子代理",
|
|
65
|
+
"panel.branch.workflow": "工作流",
|
|
66
|
+
"panel.branch.job": "后台任务",
|
|
67
|
+
"panel.branch.nodes": "{count} 个活动",
|
|
68
|
+
"panel.branch.more": "另有 {count} 条支线",
|
|
69
|
+
"panel.branch.dropped": "较早 {count} 条支线已省略",
|
|
70
|
+
"panel.branch.truncated": "支线内已省略 {count} 个活动",
|
|
71
|
+
"panel.branch.expand": "展开支线",
|
|
72
|
+
"panel.branch.collapse": "收起支线",
|
|
73
|
+
"panel.node.verb.read": "读",
|
|
74
|
+
"panel.node.verb.write": "写",
|
|
75
|
+
"panel.node.verb.edit": "改",
|
|
76
|
+
"panel.node.verb.glob": "列",
|
|
77
|
+
"panel.node.verb.grep": "搜",
|
|
78
|
+
"panel.node.verb.undo": "撤",
|
|
79
|
+
"panel.node.verb.command": "命令",
|
|
80
|
+
"panel.node.verb.skill": "技能",
|
|
81
|
+
"panel.node.verb.subagent": "子代理",
|
|
82
|
+
"panel.node.pending": "无结果记录",
|
|
83
|
+
"panel.status.done": "已完成",
|
|
84
|
+
"panel.status.running": "进行中",
|
|
85
|
+
"panel.status.failed": "失败",
|
|
86
|
+
"panel.status.skipped": "已跳过",
|
|
87
|
+
"panel.status.pending": "待执行",
|
|
88
|
+
"panel.kind.tool": "工具",
|
|
89
|
+
"panel.kind.message": "消息",
|
|
90
|
+
"panel.kind.report": "报告",
|
|
91
|
+
"panel.kind.subagent": "子代理",
|
|
92
|
+
"panel.kind.question": "提问",
|
|
93
|
+
"panel.kind.approval": "审批",
|
|
94
|
+
"panel.kind.other": "其他",
|
|
95
|
+
"panel.percent.estimate": "~{percent}% 估算",
|
|
96
|
+
"panel.percent.self": "{percent}% 自报",
|
|
97
|
+
// Measured basis (todo-list completion ratio), kept strictly distinct from the self-reported value / "estimated".
|
|
98
|
+
"panel.percent.real": "{percent}% 实测",
|
|
99
|
+
// the rail's second line. {duration} is already formatted ("3 minutes"), and the
|
|
100
|
+
// wording says where the number came from — an estimate, not a measurement.
|
|
101
|
+
"panel.percent.ago": "{duration}前估计",
|
|
102
|
+
"panel.percent.unknown": "时间未知",
|
|
103
|
+
"panel.live": "正在",
|
|
104
|
+
"panel.step.confirm": "需确认",
|
|
105
|
+
"panel.step.progress": "{title} 进度",
|
|
106
|
+
// — node detail + step detail. Keep these suffixes LOWERCASE: build-client
|
|
107
|
+
// counts `panel.*` keys to prove zh/en carry the same set, and the pattern is [a-z.]+.
|
|
108
|
+
"panel.detail.aria": "节点详情:{tool}",
|
|
109
|
+
"panel.detail.category.file": "文件",
|
|
110
|
+
"panel.detail.category.command": "命令",
|
|
111
|
+
"panel.detail.category.skill": "技能",
|
|
112
|
+
"panel.detail.category.subagent": "子代理",
|
|
113
|
+
"panel.detail.category.cad": "CAD",
|
|
114
|
+
"panel.detail.category.other": "其他",
|
|
115
|
+
"panel.detail.action.read": "读取",
|
|
116
|
+
"panel.detail.action.edit": "修改",
|
|
117
|
+
"panel.detail.action.write": "写入",
|
|
118
|
+
"panel.detail.action.query": "查询",
|
|
119
|
+
"panel.detail.action.other": "其他",
|
|
120
|
+
"panel.detail.args": "参数",
|
|
121
|
+
"panel.detail.showargs": "查看完整参数",
|
|
122
|
+
"panel.detail.showcommand": "查看完整命令",
|
|
123
|
+
"panel.detail.copy": "复制",
|
|
124
|
+
"panel.detail.copyargs": "复制参数",
|
|
125
|
+
"panel.detail.copypath": "复制路径",
|
|
126
|
+
"panel.detail.copied": "已复制",
|
|
127
|
+
"panel.detail.copyfailed": "复制失败",
|
|
128
|
+
"panel.detail.files": "文件",
|
|
129
|
+
"panel.detail.nofiles": "未涉及文件",
|
|
130
|
+
"panel.detail.result": "结果",
|
|
131
|
+
"panel.detail.error": "错误",
|
|
132
|
+
"panel.detail.noerror": "无错误文本",
|
|
133
|
+
"panel.detail.noresult": "无结果记录",
|
|
134
|
+
"panel.detail.noargs": "无参数记录",
|
|
135
|
+
"panel.detail.started": "开始",
|
|
136
|
+
"panel.detail.ended": "结束",
|
|
137
|
+
"panel.detail.duration": "耗时",
|
|
138
|
+
"panel.detail.argscap": "参数按宿主上限截断为 200 字",
|
|
139
|
+
"panel.detail.errorcap": "错误文本为宿主记录的首行(上限 200 字)",
|
|
140
|
+
"panel.detail.unknowntool": "未知工具",
|
|
141
|
+
"panel.step.head": "第 {n} 步 · {tools} 工具 · {files} 文件",
|
|
142
|
+
"panel.step.detailtitle": "第 {n} 步详情",
|
|
143
|
+
"panel.step.expand": "展开这一步详情",
|
|
144
|
+
"panel.step.collapse": "收起这一步详情",
|
|
145
|
+
"panel.step.detailopen": "展开分步详情",
|
|
146
|
+
"panel.step.detailclose": "收起分步详情",
|
|
147
|
+
"panel.step.detailaria": "第 {n} 步详情",
|
|
148
|
+
"panel.step.report": "报告",
|
|
149
|
+
"panel.step.noreport": "这一步还没交报告",
|
|
150
|
+
"panel.step.nosummary": "报告没有摘要",
|
|
151
|
+
"panel.step.artifacts": "产物",
|
|
152
|
+
"panel.step.noartifacts": "未列产物",
|
|
153
|
+
"panel.step.exit": "退出码",
|
|
154
|
+
"panel.step.tools": "{count} 工具",
|
|
155
|
+
"panel.step.files": "{count} 文件",
|
|
156
|
+
"panel.step.nodescount": "{count} 节点",
|
|
157
|
+
"panel.step.truncatedcount": "另有 {count} 个节点宿主未发送",
|
|
158
|
+
"panel.step.unknowntime": "无时间记录",
|
|
159
|
+
"panel.settings.endpoint": "数据源",
|
|
160
|
+
"panel.settings.counts": "{steps} 步 / {nodes} 节点",
|
|
161
|
+
"panel.settings.rev": "版本 {rev}",
|
|
162
|
+
"panel.settings.reset": "恢复默认",
|
|
163
|
+
"panel.settings.reset.hint": "恢复配色、动效、详情深度与节点上限",
|
|
164
|
+
"panel.settings.palette": "配色",
|
|
165
|
+
"panel.settings.palette.rainbow": "彩虹",
|
|
166
|
+
"panel.settings.palette.brand": "品牌",
|
|
167
|
+
"panel.settings.palette.soft": "柔和",
|
|
168
|
+
"panel.settings.motion": "动效",
|
|
169
|
+
"panel.settings.motion.full": "运行中",
|
|
170
|
+
"panel.settings.motion.always": "始终",
|
|
171
|
+
"panel.settings.motion.static": "关闭",
|
|
172
|
+
"panel.settings.detail": "详情",
|
|
173
|
+
"panel.settings.detail.summary": "摘要",
|
|
174
|
+
"panel.settings.detail.title": "只看标题",
|
|
175
|
+
"panel.settings.limit": "节点上限",
|
|
176
|
+
"panel.settings.limit.all": "全部",
|
|
177
|
+
"panel.settings.export": "工作流快照",
|
|
178
|
+
"panel.settings.export.markdown": "复制 Markdown",
|
|
179
|
+
"panel.settings.export.json": "复制 JSON",
|
|
180
|
+
"panel.settings.export.downloadmarkdown": "下载 .md",
|
|
181
|
+
"panel.settings.export.downloadjson": "下载 .json",
|
|
182
|
+
"panel.stale": "离线(显示上次数据)",
|
|
183
|
+
// the inject column. `sent` is the ONLY success word — a failure prints
|
|
184
|
+
// the HOST's own reason, so no failure string of ours is ever shown instead of it.
|
|
185
|
+
// the single page and its right-hand page.
|
|
186
|
+
"panel.page.main": "主流程",
|
|
187
|
+
"panel.page.step": "第 {n} 步详情",
|
|
188
|
+
"panel.page.types": "这一步用到的工具文件",
|
|
189
|
+
"panel.page.noTypes": "这一步没有文件或工具调用记录",
|
|
190
|
+
"panel.page.typeStep": "第 {n} 步 · {label}",
|
|
191
|
+
"panel.page.typeSession": "全会话 · {label}",
|
|
192
|
+
"panel.page.typeSub": "范围:{step},共 {count} 处调用",
|
|
193
|
+
"panel.page.wholeSession": "全会话",
|
|
194
|
+
"panel.page.typeEmpty": "这一类还没有可列出的调用",
|
|
195
|
+
"panel.page.truncated": "另有 {count} 处调用未随视图发送",
|
|
196
|
+
"panel.page.count": "{count} 处",
|
|
197
|
+
"panel.page.kind.file": "文件类型",
|
|
198
|
+
"panel.page.kind.tool": "调用工具",
|
|
199
|
+
"panel.page.sat.label": "{label}({kind}){count} 处,点击查看这一类调用",
|
|
200
|
+
"panel.page.moreTypes": "另有 {count} 类未画在两侧",
|
|
201
|
+
"panel.page.close": "关闭这一页",
|
|
202
|
+
"panel.page.noReport": "这一步的详情还没有随视图发送",
|
|
203
|
+
"panel.page.inject": "热注入",
|
|
204
|
+
"panel.inject.banner": "⚡ 热注入",
|
|
205
|
+
// the badge on the header button. {count} is the number of rows
|
|
206
|
+
// the SESSION has not taken yet — never a number this side invents.
|
|
207
|
+
"panel.inject.pending.badge": "{count} 条待会话拾取",
|
|
208
|
+
"panel.inject.open": "热注入提示词",
|
|
209
|
+
"panel.inject.title": "热注入",
|
|
210
|
+
"panel.inject.hint": "发送后会在当前轮次的下一步进入会话,不打断正在做的事。",
|
|
211
|
+
"panel.inject.placeholder": "补充一句提示词…",
|
|
212
|
+
"panel.inject.send": "发送",
|
|
213
|
+
"panel.inject.sending": "发送中…",
|
|
214
|
+
"panel.inject.cancel": "取消",
|
|
215
|
+
"panel.inject.sent": "已注入",
|
|
216
|
+
"panel.inject.unknown": "注入失败(宿主未给出原因)",
|
|
217
|
+
"panel.inject.counter": "{used}/{max}",
|
|
218
|
+
// the history rows. `picked` and `pending` are the two status words; they are NOT
|
|
219
|
+
// synonyms — the first is measured evidence, the second is "still waiting".
|
|
220
|
+
"panel.inject.history": "热注入历史",
|
|
221
|
+
"panel.inject.history.empty": "还没有热注入记录",
|
|
222
|
+
"panel.inject.row.picked": "会话已接下",
|
|
223
|
+
"panel.inject.row.pending": "待会话拾取",
|
|
224
|
+
// Revoke-related strings. The host now exposes a real inbox revoke API, so the wording states the fact:
|
|
225
|
+
// what the button does = `POST /dsh-workflow/unsteer` -> host `inbox.remove`; only after that does the log's
|
|
226
|
+
// `agent/inbox/spliced { outcome: 'canceled' }` turn the row into "revoked".
|
|
227
|
+
"panel.inject.row.canceled": "已撤销",
|
|
228
|
+
"panel.inject.revoke": "撤回",
|
|
229
|
+
"panel.inject.revoke.hint": "这条还在收件箱里、当前轮次尚未取用,所以可以撤回。撤回后它不会进入会话;一旦被轮次接下,就只能等它执行。",
|
|
230
|
+
"panel.inject.revoking": "撤回中…",
|
|
231
|
+
"panel.inject.revoke.failed": "撤回失败(宿主未给出原因)",
|
|
232
|
+
"capsule.aria": "打开工作流地图面板",
|
|
233
|
+
"capsule.title": "工作流地图",
|
|
234
|
+
"capsule.progress": "{done}/{total} · {percent}%",
|
|
235
|
+
"capsule.hint": "点击展开",
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
var DICT_EN = {
|
|
239
|
+
"panel.aria": "Workflow map: {task}",
|
|
240
|
+
"panel.title": "Workflow map",
|
|
241
|
+
"panel.task.fallback": "Current session",
|
|
242
|
+
"panel.progress": "{done}/{total} steps · {percent}%",
|
|
243
|
+
"panel.planning": "Planning…",
|
|
244
|
+
"panel.planning.note": "The plan has not been submitted yet; steps appear after it is.",
|
|
245
|
+
"panel.collapse": "Collapse",
|
|
246
|
+
"panel.expand": "Expand",
|
|
247
|
+
"panel.settings": "Settings",
|
|
248
|
+
"panel.steps": "Task steps",
|
|
249
|
+
"panel.flow": "Step flow",
|
|
250
|
+
"panel.flow.empty": "No actions in this step yet",
|
|
251
|
+
"panel.nodes": "{count} nodes",
|
|
252
|
+
"panel.step.nodes": "{count} nodes",
|
|
253
|
+
"panel.files": "{count} files",
|
|
254
|
+
"panel.node.error": "failed",
|
|
255
|
+
"panel.truncated": "{count} nodes omitted",
|
|
256
|
+
"panel.flow.step": "Step {n} · {title}",
|
|
257
|
+
"panel.flow.group": "{count} consecutive {tool}",
|
|
258
|
+
"panel.flow.range": "{first}–{last}",
|
|
259
|
+
"panel.flow.more": "{count} earlier nodes folded",
|
|
260
|
+
"panel.flow.showAll": "Show all",
|
|
261
|
+
"panel.flow.expandGroup": "Expand group",
|
|
262
|
+
"panel.flow.collapseGroup": "Collapse group",
|
|
263
|
+
// the round branch icons.
|
|
264
|
+
"panel.flow.icon.read": "Read",
|
|
265
|
+
"panel.flow.icon.edit": "Edit",
|
|
266
|
+
"panel.flow.icon.tool": "Tool",
|
|
267
|
+
"panel.flow.branch": "{kind} ×{count}",
|
|
268
|
+
"panel.flow.branch.aria": "{count} branches (read / edit / tool)",
|
|
269
|
+
"panel.flow.branch.open": "Expand {kind} detail",
|
|
270
|
+
"panel.flow.branch.close": "Collapse {kind} detail",
|
|
271
|
+
"panel.branch": "Branches",
|
|
272
|
+
"panel.branch.subagent": "Subagent",
|
|
273
|
+
"panel.branch.workflow": "Workflow",
|
|
274
|
+
"panel.branch.job": "Background job",
|
|
275
|
+
"panel.branch.nodes": "{count} activities",
|
|
276
|
+
"panel.branch.more": "{count} more branches",
|
|
277
|
+
"panel.branch.dropped": "{count} earlier branches omitted",
|
|
278
|
+
"panel.branch.truncated": "{count} activities omitted in this branch",
|
|
279
|
+
"panel.branch.expand": "Expand branch",
|
|
280
|
+
"panel.branch.collapse": "Collapse branch",
|
|
281
|
+
"panel.node.verb.read": "Read",
|
|
282
|
+
"panel.node.verb.write": "Write",
|
|
283
|
+
"panel.node.verb.edit": "Edit",
|
|
284
|
+
"panel.node.verb.glob": "List",
|
|
285
|
+
"panel.node.verb.grep": "Search",
|
|
286
|
+
"panel.node.verb.undo": "Undo",
|
|
287
|
+
"panel.node.verb.command": "Command",
|
|
288
|
+
"panel.node.verb.skill": "Skill",
|
|
289
|
+
"panel.node.verb.subagent": "Subagent",
|
|
290
|
+
"panel.node.pending": "No result recorded",
|
|
291
|
+
"panel.status.done": "Done",
|
|
292
|
+
"panel.status.running": "Running",
|
|
293
|
+
"panel.status.failed": "Failed",
|
|
294
|
+
"panel.status.skipped": "Skipped",
|
|
295
|
+
"panel.status.pending": "Pending",
|
|
296
|
+
"panel.kind.tool": "Tool",
|
|
297
|
+
"panel.kind.message": "Message",
|
|
298
|
+
"panel.kind.report": "Report",
|
|
299
|
+
"panel.kind.subagent": "Subagent",
|
|
300
|
+
"panel.kind.question": "Question",
|
|
301
|
+
"panel.kind.approval": "Approval",
|
|
302
|
+
"panel.kind.other": "Other",
|
|
303
|
+
"panel.percent.estimate": "~{percent}% est.",
|
|
304
|
+
"panel.percent.self": "{percent}% self",
|
|
305
|
+
// measured (todo-table completion ratio), distinct from self/estimate.
|
|
306
|
+
"panel.percent.real": "{percent}% measured",
|
|
307
|
+
"panel.percent.ago": "{duration} ago",
|
|
308
|
+
"panel.percent.unknown": "time unknown",
|
|
309
|
+
"panel.live": "Working",
|
|
310
|
+
// same keys as the zh dictionary — the shape self-test asserts the two
|
|
311
|
+
// key sets are identical.
|
|
312
|
+
// the single page and its right-hand page (same keys as the zh dictionary).
|
|
313
|
+
"panel.page.main": "Main flow",
|
|
314
|
+
"panel.page.step": "Step {n} details",
|
|
315
|
+
"panel.page.types": "Tool files this step used",
|
|
316
|
+
"panel.page.noTypes": "No file or tool call recorded for this step",
|
|
317
|
+
"panel.page.typeStep": "Step {n} · {label}",
|
|
318
|
+
"panel.page.typeSession": "Whole session · {label}",
|
|
319
|
+
"panel.page.typeSub": "Scope: {step} · {count} calls",
|
|
320
|
+
"panel.page.wholeSession": "whole session",
|
|
321
|
+
"panel.page.typeEmpty": "No call of this type to list yet",
|
|
322
|
+
"panel.page.truncated": "{count} more calls were not sent with the view",
|
|
323
|
+
"panel.page.count": "{count} calls",
|
|
324
|
+
"panel.page.kind.file": "file type",
|
|
325
|
+
"panel.page.kind.tool": "tool",
|
|
326
|
+
"panel.page.sat.label": "{label} ({kind}), {count} calls — click to see this type's calls",
|
|
327
|
+
"panel.page.moreTypes": "{count} more types are not drawn on the sides",
|
|
328
|
+
"panel.page.close": "Close this page",
|
|
329
|
+
"panel.page.noReport": "This step's details have not been sent with the view yet",
|
|
330
|
+
"panel.page.inject": "Hot inject",
|
|
331
|
+
"panel.inject.banner": "⚡ Hot inject",
|
|
332
|
+
"panel.inject.pending.badge": "{count} waiting for the turn",
|
|
333
|
+
"panel.inject.open": "Hot-inject a prompt",
|
|
334
|
+
"panel.inject.title": "Hot inject",
|
|
335
|
+
"panel.inject.hint": "Sent text enters the session at the NEXT STEP of the current turn; it does not interrupt the work in progress.",
|
|
336
|
+
"panel.inject.placeholder": "Add a prompt…",
|
|
337
|
+
"panel.inject.send": "Send",
|
|
338
|
+
"panel.inject.sending": "Sending…",
|
|
339
|
+
"panel.inject.cancel": "Cancel",
|
|
340
|
+
"panel.inject.sent": "Injected",
|
|
341
|
+
"panel.inject.unknown": "Injection failed (the host gave no reason)",
|
|
342
|
+
"panel.inject.counter": "{used}/{max}",
|
|
343
|
+
"panel.inject.history": "Hot-inject history",
|
|
344
|
+
"panel.inject.history.empty": "No hot-inject yet",
|
|
345
|
+
"panel.inject.row.picked": "taken by the session",
|
|
346
|
+
"panel.inject.row.pending": "waiting for the turn",
|
|
347
|
+
"panel.inject.row.canceled": "revoked",
|
|
348
|
+
"panel.inject.revoke": "Revoke",
|
|
349
|
+
"panel.inject.revoke.hint": "Still queued and not claimed by the running turn, so it can be taken back. A revoked message never enters the session; once the turn has claimed it, it can only run.",
|
|
350
|
+
"panel.inject.revoking": "Revoking…",
|
|
351
|
+
"panel.inject.revoke.failed": "Revoke failed (the host gave no reason)",
|
|
352
|
+
"panel.step.confirm": "Needs confirm",
|
|
353
|
+
"panel.step.progress": "Progress of {title}",
|
|
354
|
+
"panel.detail.aria": "Node detail: {tool}",
|
|
355
|
+
"panel.detail.category.file": "File",
|
|
356
|
+
"panel.detail.category.command": "Command",
|
|
357
|
+
"panel.detail.category.skill": "Skill",
|
|
358
|
+
"panel.detail.category.subagent": "Subagent",
|
|
359
|
+
"panel.detail.category.cad": "CAD",
|
|
360
|
+
"panel.detail.category.other": "Other",
|
|
361
|
+
"panel.detail.action.read": "Read",
|
|
362
|
+
"panel.detail.action.edit": "Edited",
|
|
363
|
+
"panel.detail.action.write": "Written",
|
|
364
|
+
"panel.detail.action.query": "Searched",
|
|
365
|
+
"panel.detail.action.other": "Other",
|
|
366
|
+
"panel.detail.args": "Arguments",
|
|
367
|
+
"panel.detail.showargs": "Show full arguments",
|
|
368
|
+
"panel.detail.showcommand": "Show full command",
|
|
369
|
+
"panel.detail.copy": "Copy",
|
|
370
|
+
"panel.detail.copyargs": "Copy arguments",
|
|
371
|
+
"panel.detail.copypath": "Copy path",
|
|
372
|
+
"panel.detail.copied": "Copied",
|
|
373
|
+
"panel.detail.copyfailed": "Copy failed",
|
|
374
|
+
"panel.detail.files": "Files",
|
|
375
|
+
"panel.detail.nofiles": "No files touched",
|
|
376
|
+
"panel.detail.result": "Result",
|
|
377
|
+
"panel.detail.error": "Error",
|
|
378
|
+
"panel.detail.noerror": "No error text",
|
|
379
|
+
"panel.detail.noresult": "No result recorded",
|
|
380
|
+
"panel.detail.noargs": "No arguments recorded",
|
|
381
|
+
"panel.detail.started": "Started",
|
|
382
|
+
"panel.detail.ended": "Ended",
|
|
383
|
+
"panel.detail.duration": "Duration",
|
|
384
|
+
"panel.detail.argscap": "Arguments are capped at the host's 200 characters",
|
|
385
|
+
"panel.detail.errorcap": "The host stores the error's first line (200 characters max)",
|
|
386
|
+
"panel.detail.unknowntool": "Unknown tool",
|
|
387
|
+
"panel.step.head": "Step {n} · {tools} tools · {files} files",
|
|
388
|
+
"panel.step.detailtitle": "Step {n} details",
|
|
389
|
+
"panel.step.expand": "Show this step's details",
|
|
390
|
+
"panel.step.collapse": "Hide this step's details",
|
|
391
|
+
"panel.step.detailopen": "Show step details",
|
|
392
|
+
"panel.step.detailclose": "Hide step details",
|
|
393
|
+
"panel.step.detailaria": "Step {n} details",
|
|
394
|
+
"panel.step.report": "Report",
|
|
395
|
+
"panel.step.noreport": "No report for this step yet",
|
|
396
|
+
"panel.step.nosummary": "The report has no summary",
|
|
397
|
+
"panel.step.artifacts": "Artifacts",
|
|
398
|
+
"panel.step.noartifacts": "No artifacts listed",
|
|
399
|
+
"panel.step.exit": "Exit code",
|
|
400
|
+
"panel.step.tools": "{count} tools",
|
|
401
|
+
"panel.step.files": "{count} files",
|
|
402
|
+
"panel.step.nodescount": "{count} nodes",
|
|
403
|
+
"panel.step.truncatedcount": "{count} nodes not sent by the host",
|
|
404
|
+
"panel.step.unknowntime": "No time recorded",
|
|
405
|
+
"panel.settings.endpoint": "Endpoint",
|
|
406
|
+
"panel.settings.counts": "{steps} steps / {nodes} nodes",
|
|
407
|
+
"panel.settings.rev": "rev {rev}",
|
|
408
|
+
"panel.settings.reset": "Restore defaults",
|
|
409
|
+
"panel.settings.reset.hint": "Restore palette, motion, detail depth and node limit",
|
|
410
|
+
"panel.settings.palette": "Palette",
|
|
411
|
+
"panel.settings.palette.rainbow": "Rainbow",
|
|
412
|
+
"panel.settings.palette.brand": "Brand",
|
|
413
|
+
"panel.settings.palette.soft": "Soft",
|
|
414
|
+
"panel.settings.motion": "Motion",
|
|
415
|
+
"panel.settings.motion.full": "While working",
|
|
416
|
+
"panel.settings.motion.always": "Always",
|
|
417
|
+
"panel.settings.motion.static": "Off",
|
|
418
|
+
"panel.settings.detail": "Detail",
|
|
419
|
+
"panel.settings.detail.summary": "Summary",
|
|
420
|
+
"panel.settings.detail.title": "Title only",
|
|
421
|
+
"panel.settings.limit": "Node limit",
|
|
422
|
+
"panel.settings.limit.all": "All",
|
|
423
|
+
"panel.settings.export": "Workflow snapshot",
|
|
424
|
+
"panel.settings.export.markdown": "Copy Markdown",
|
|
425
|
+
"panel.settings.export.json": "Copy JSON",
|
|
426
|
+
"panel.settings.export.downloadmarkdown": "Download .md",
|
|
427
|
+
"panel.settings.export.downloadjson": "Download .json",
|
|
428
|
+
"panel.stale": "Offline (last data shown)",
|
|
429
|
+
"capsule.aria": "Open the workflow map panel",
|
|
430
|
+
"capsule.title": "Workflow map",
|
|
431
|
+
"capsule.progress": "{done}/{total} · {percent}%",
|
|
432
|
+
"capsule.hint": "Click to expand",
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
var WF_DICTS = { zh: DICT_ZH, en: DICT_EN };
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* `props.t` is the framework translator for this slot's locale namespace. It is
|
|
439
|
+
* used when present; when it is absent (a bare mount, a future seat rename, or a
|
|
440
|
+
* Node smoke test) the same dictionaries above answer instead of a raw key.
|
|
441
|
+
*/
|
|
442
|
+
function wfMakeSay(t) {
|
|
443
|
+
return function say(key, params) {
|
|
444
|
+
var text = null;
|
|
445
|
+
if (typeof t === "function") {
|
|
446
|
+
try {
|
|
447
|
+
text = t(key);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
text = null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (typeof text !== "string" || text === "" || text === key) {
|
|
453
|
+
text = WF_DICTS.zh[key] || WF_DICTS.en[key] || key;
|
|
454
|
+
}
|
|
455
|
+
if (params && typeof params === "object") {
|
|
456
|
+
text = text.replace(/\{(\w+)\}/g, function (match, name) {
|
|
457
|
+
return params[name] === undefined || params[name] === null ? match : String(params[name]);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return text;
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function wfProgressText(say, progress) {
|
|
465
|
+
return say("panel.progress", {
|
|
466
|
+
done: progress.done,
|
|
467
|
+
total: progress.total,
|
|
468
|
+
percent: progress.percent,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* ------------------------------------------------------------------------- */
|
|
473
|
+
/* hooks */
|
|
474
|
+
/* ------------------------------------------------------------------------- */
|
|
475
|
+
|
|
476
|
+
/** The active session id, or null. `useSessions` is a standard prop of BOTH seats this
|
|
477
|
+
* plugin occupies (`main` and `sidebar.panellist`), so the panel keeps reading it the
|
|
478
|
+
* same way it did on the overlay. */
|
|
479
|
+
function wfUseSessionId(props) {
|
|
480
|
+
var useSessions = props && props.useSessions;
|
|
481
|
+
if (typeof useSessions !== "function") return null;
|
|
482
|
+
var snapshot = useSessions(function (value) {
|
|
483
|
+
return value;
|
|
484
|
+
});
|
|
485
|
+
return wfSessionIdFromSnapshot(snapshot);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** localStorage-backed prefs; every update is persisted by an effect. */
|
|
489
|
+
function wfUsePrefs() {
|
|
490
|
+
var pair = React.useState(function () {
|
|
491
|
+
return wfLoadPrefs();
|
|
492
|
+
});
|
|
493
|
+
var prefs = pair[0];
|
|
494
|
+
var setPrefs = pair[1];
|
|
495
|
+
|
|
496
|
+
function update(patch) {
|
|
497
|
+
setPrefs(function (prev) {
|
|
498
|
+
return wfApplyPrefsPatch(prev, patch);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
React.useEffect(
|
|
503
|
+
function () {
|
|
504
|
+
wfSavePrefs(prefs);
|
|
505
|
+
},
|
|
506
|
+
[prefs.width, prefs.height, prefs.collapsed, prefs.selectedStep,
|
|
507
|
+
prefs.palette, prefs.motion, prefs.detail, prefs.nodeLimit]
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
return { prefs: prefs, update: update };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Poll `/dsh-workflow/state` with If-None-Match. Reading is the only thing this
|
|
515
|
+
* hook does: no writes, no host calls, no projections.
|
|
516
|
+
*
|
|
517
|
+
* The snapshot is dropped to `loading` the moment the session id changes, so a
|
|
518
|
+
* switch can never show the previous session's steps ( §4 acceptance).
|
|
519
|
+
*/
|
|
520
|
+
function wfUseSnap(sessionId) {
|
|
521
|
+
var pair = React.useState(function () {
|
|
522
|
+
return { status: sessionId ? "loading" : "idle", view: null, rev: null, error: "" };
|
|
523
|
+
});
|
|
524
|
+
var snap = pair[0];
|
|
525
|
+
var setSnap = pair[1];
|
|
526
|
+
|
|
527
|
+
React.useEffect(
|
|
528
|
+
function () {
|
|
529
|
+
if (!sessionId) {
|
|
530
|
+
setSnap({ status: "idle", view: null, rev: null, error: "" });
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
var stopped = false;
|
|
535
|
+
var timer = 0;
|
|
536
|
+
var controller = null;
|
|
537
|
+
var etag = "";
|
|
538
|
+
|
|
539
|
+
setSnap({ status: "loading", view: null, rev: null, error: "" });
|
|
540
|
+
|
|
541
|
+
function schedule(delay) {
|
|
542
|
+
if (stopped) return;
|
|
543
|
+
timer = window.setTimeout(tick, delay);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function delayForPage() {
|
|
547
|
+
return document.visibilityState === "visible" ? WF_POLL_VISIBLE : WF_POLL_HIDDEN;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function tick() {
|
|
551
|
+
if (stopped) return;
|
|
552
|
+
var delay = delayForPage();
|
|
553
|
+
if (controller && typeof controller.abort === "function") {
|
|
554
|
+
try {
|
|
555
|
+
controller.abort();
|
|
556
|
+
} catch (error) {
|
|
557
|
+
/* an already-settled request has nothing to abort */
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
561
|
+
var signal = controller ? controller.signal : undefined;
|
|
562
|
+
wfPollOnce(window.fetch, wfPollUrl(sessionId), etag, signal).then(function (result) {
|
|
563
|
+
if (stopped) return;
|
|
564
|
+
// -1 = this request was aborted by a newer one (visibility change): the
|
|
565
|
+
// newer tick already owns the loop, so stay silent and do not re-arm.
|
|
566
|
+
if (result.status === -1) return;
|
|
567
|
+
if (result.status === 304) {
|
|
568
|
+
setSnap(function (prev) {
|
|
569
|
+
return prev.view ? { status: "live", view: prev.view, rev: prev.rev, error: "" } : prev;
|
|
570
|
+
});
|
|
571
|
+
} else if (result.status === 200) {
|
|
572
|
+
etag = result.etag || etag;
|
|
573
|
+
setSnap({
|
|
574
|
+
status: "live",
|
|
575
|
+
view: result.payload.view || null,
|
|
576
|
+
rev: wfFinite(result.payload.rev),
|
|
577
|
+
error: "",
|
|
578
|
+
});
|
|
579
|
+
} else {
|
|
580
|
+
setSnap(function (prev) {
|
|
581
|
+
return {
|
|
582
|
+
status: prev.view ? "stale" : "error",
|
|
583
|
+
view: prev.view,
|
|
584
|
+
rev: prev.rev,
|
|
585
|
+
error: result.error,
|
|
586
|
+
};
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
schedule(delay);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function onVisibilityChange() {
|
|
594
|
+
if (document.visibilityState !== "visible") return;
|
|
595
|
+
window.clearTimeout(timer);
|
|
596
|
+
tick();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
tick();
|
|
600
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
601
|
+
return function () {
|
|
602
|
+
stopped = true;
|
|
603
|
+
window.clearTimeout(timer);
|
|
604
|
+
if (controller && typeof controller.abort === "function") {
|
|
605
|
+
try {
|
|
606
|
+
controller.abort();
|
|
607
|
+
} catch (error) {
|
|
608
|
+
/* ignore */
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
612
|
+
};
|
|
613
|
+
},
|
|
614
|
+
[sessionId]
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
return snap;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** The stylesheet lives exactly as long as the mount. */
|
|
621
|
+
function wfUseStyle() {
|
|
622
|
+
React.useEffect(function () {
|
|
623
|
+
var element = document.getElementById(WF_STYLE_ID);
|
|
624
|
+
if (!element) {
|
|
625
|
+
element = document.createElement("style");
|
|
626
|
+
element.id = WF_STYLE_ID;
|
|
627
|
+
document.head.appendChild(element);
|
|
628
|
+
}
|
|
629
|
+
element.textContent = WF_CSS;
|
|
630
|
+
return function () {
|
|
631
|
+
var current = document.getElementById(WF_STYLE_ID);
|
|
632
|
+
if (current && current.parentNode) current.parentNode.removeChild(current);
|
|
633
|
+
};
|
|
634
|
+
}, []);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Alt+W toggles the collapsed state; the listener is removed on unmount. */
|
|
638
|
+
function wfUseAltW(updatePrefs) {
|
|
639
|
+
React.useEffect(function () {
|
|
640
|
+
function onKeyDown(event) {
|
|
641
|
+
if (!event || !event.altKey || event.ctrlKey || event.metaKey) return;
|
|
642
|
+
var key = event.key || "";
|
|
643
|
+
if (!(key === "w" || key === "W" || key === "ω" || event.code === "KeyW")) return;
|
|
644
|
+
if (typeof event.preventDefault === "function") event.preventDefault();
|
|
645
|
+
updatePrefs(function (prev) {
|
|
646
|
+
return { collapsed: !prev.collapsed };
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
window.addEventListener("keydown", onKeyDown);
|
|
650
|
+
return function () {
|
|
651
|
+
window.removeEventListener("keydown", onKeyDown);
|
|
652
|
+
};
|
|
653
|
+
}, []);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* A remembered step index belongs to one session; a switch drops it.
|
|
658
|
+
*
|
|
659
|
+
* The previous id lives in module scope instead of React.useRef on purpose: the
|
|
660
|
+
* overlay occupant is a page singleton, and this plugin only relies on the React
|
|
661
|
+
* hooks the harness documents (useState / useEffect). One less hook is one less
|
|
662
|
+
* assumption about the client runtime.
|
|
663
|
+
*/
|
|
664
|
+
var wfSelectionSession;
|
|
665
|
+
function wfUseSessionReset(sessionId, updatePrefs) {
|
|
666
|
+
React.useEffect(
|
|
667
|
+
function () {
|
|
668
|
+
var previous = wfSelectionSession;
|
|
669
|
+
wfSelectionSession = sessionId;
|
|
670
|
+
if (previous !== undefined && previous !== sessionId) {
|
|
671
|
+
updatePrefs({ selectedStep: null });
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
[sessionId]
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/* ------------------------------------------------------------------------- */
|
|
679
|
+
/* the two writes: the inject send and its undo ( + ) */
|
|
680
|
+
/* ------------------------------------------------------------------------- */
|
|
681
|
+
|
|
682
|
+
var WF_INJECT_PATH = "/dsh-workflow/steer";
|
|
683
|
+
var WF_UNSTEER_PATH = "/dsh-workflow/unsteer";
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* One hot-inject send. Kept out of the component body so the four-state machine is readable
|
|
687
|
+
* in one place, and so a self-test can read its shape without mounting React.
|
|
688
|
+
*
|
|
689
|
+
* The ACKNOWLEDGEMENT clears itself after WF_INJECT_ACK_MS; the timer is only armed when an
|
|
690
|
+
* acknowledgement was actually produced, so an unmounted panel cannot leave a stray timeout
|
|
691
|
+
* that writes into a dead setter (React ignores it, but the discipline is the point).
|
|
692
|
+
*/
|
|
693
|
+
function wfSendInject(text, sessionId, setInject) {
|
|
694
|
+
if (typeof setInject !== "function") return;
|
|
695
|
+
var trimmed = wfInjectText(text);
|
|
696
|
+
if (trimmed === "" || typeof sessionId !== "string" || sessionId === "") return;
|
|
697
|
+
|
|
698
|
+
setInject(function (prev) {
|
|
699
|
+
return { open: prev ? prev.open : true, state: "sending", text: trimmed, reason: "" };
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Cancellable before send: the controller is stored on window for the panel's "cancel send" button.
|
|
703
|
+
// Only takes effect while the request is still in flight; once it returns 200 the host has it in the inbox and the plugin cannot pull it back.
|
|
704
|
+
var controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
705
|
+
window.__dshWorkflowMapAbort = controller;
|
|
706
|
+
|
|
707
|
+
wfPostSteer(window.fetch, WF_INJECT_PATH, sessionId, trimmed, controller ? controller.signal : undefined).then(function (result) {
|
|
708
|
+
// The acknowledgement's wording comes from the panel's OWN dictionary: `say` here is the
|
|
709
|
+
// component's, and wfInjectOutcome only needs it for the one fallback string.
|
|
710
|
+
var outcome = wfInjectOutcome(result && result.payload, wfMakeSay());
|
|
711
|
+
var next = {
|
|
712
|
+
open: true,
|
|
713
|
+
state: outcome.kind === "sent" ? "sent" : "failed",
|
|
714
|
+
text: trimmed,
|
|
715
|
+
// A failure carries the HOST's own reason verbatim (the honesty rule).
|
|
716
|
+
reason: outcome.kind === "sent" ? "" : outcome.text,
|
|
717
|
+
};
|
|
718
|
+
setInject(next);
|
|
719
|
+
window.setTimeout(function () {
|
|
720
|
+
setInject(function (prev) {
|
|
721
|
+
if (!prev) return prev;
|
|
722
|
+
var cleared = wfInjectAckDone(prev.state);
|
|
723
|
+
if (cleared === prev.state) return prev;
|
|
724
|
+
return { open: prev.open, state: cleared, text: prev.text, reason: "" };
|
|
725
|
+
});
|
|
726
|
+
}, WF_INJECT_ACK_MS);
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* One revocation: `POST /dsh-workflow/unsteer { sessionId, messageId }` for a history row the
|
|
732
|
+
* running turn has not claimed yet.
|
|
733
|
+
*
|
|
734
|
+
* WHY IT IS NOT OPTIMISTIC. The host's 200 means the message LEFT the inbox — the fold will say
|
|
735
|
+
* "revoked" on the next poll, from the host's own `agent/inbox/spliced { outcome: 'canceled' }`.
|
|
736
|
+
* So this function never rewrites the history row: it only records that THIS row is being revoked,
|
|
737
|
+
* which greys its button (no second post) and carries the host's reason back when it refuses.
|
|
738
|
+
*
|
|
739
|
+
* WHY THE ID IS CHECKED TWICE. A missing `messageId` (an older row without an id, which
|
|
740
|
+
* store.js drops anyway) and a missing `sessionId` are refused HERE rather than posted, and a
|
|
741
|
+
* second click on a row already being revoked returns the previous state untouched.
|
|
742
|
+
*/
|
|
743
|
+
function wfRevokeInject(messageId, sessionId, setRevoke) {
|
|
744
|
+
if (typeof setRevoke !== "function") return;
|
|
745
|
+
if (typeof messageId !== "string" || messageId === "") return;
|
|
746
|
+
if (typeof sessionId !== "string" || sessionId === "") return;
|
|
747
|
+
|
|
748
|
+
var double = false;
|
|
749
|
+
setRevoke(function (prev) {
|
|
750
|
+
var state = prev && typeof prev === "object" ? prev : { id: "", state: "idle", reason: "" };
|
|
751
|
+
if (state.id === messageId && (state.state === "revoking" || state.state === "revoked")) {
|
|
752
|
+
double = true;
|
|
753
|
+
return state;
|
|
754
|
+
}
|
|
755
|
+
return { id: messageId, state: "revoking", reason: "" };
|
|
756
|
+
});
|
|
757
|
+
if (double === true) return;
|
|
758
|
+
|
|
759
|
+
wfPostUnsteer(window.fetch, WF_UNSTEER_PATH, sessionId, messageId).then(function (result) {
|
|
760
|
+
var outcome = wfRevokeOutcome(result && result.payload, wfMakeSay());
|
|
761
|
+
setRevoke({
|
|
762
|
+
id: messageId,
|
|
763
|
+
state: outcome.kind === "revoked" ? "revoked" : "failed",
|
|
764
|
+
// A failure carries the HOST's own reason verbatim (the same honesty rule as the send).
|
|
765
|
+
reason: outcome.kind === "revoked" ? "" : outcome.text,
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/* ------------------------------------------------------------------------- */
|
|
771
|
+
/* detail wiring */
|
|
772
|
+
/* ------------------------------------------------------------------------- */
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Escape collapses the details, not the panel. Re-bound when the selected step changes
|
|
776
|
+
* because the handler needs to know WHICH step to fold — a mount-only closure would
|
|
777
|
+
* close the step that was selected when the panel opened.
|
|
778
|
+
*/
|
|
779
|
+
function wfUseEscapeDetail(selectedStep, actions) {
|
|
780
|
+
React.useEffect(
|
|
781
|
+
function () {
|
|
782
|
+
if (typeof document === "undefined") return undefined;
|
|
783
|
+
var bound = wfBindEscape(document, actions);
|
|
784
|
+
return function () {
|
|
785
|
+
bound.destroy();
|
|
786
|
+
};
|
|
787
|
+
},
|
|
788
|
+
[selectedStep]
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Re-measure the flowing curve when a <details> or the step detail changes height. */
|
|
793
|
+
function wfUseDetailNudge() {
|
|
794
|
+
React.useEffect(function () {
|
|
795
|
+
if (typeof document === "undefined") return undefined;
|
|
796
|
+
var bound = wfBindDetailNudge(document, typeof window !== "undefined" ? window : null);
|
|
797
|
+
return function () {
|
|
798
|
+
bound.destroy();
|
|
799
|
+
};
|
|
800
|
+
}, []);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/* ------------------------------------------------------------------------- */
|
|
804
|
+
/* the single page's behaviour */
|
|
805
|
+
/* ------------------------------------------------------------------------- */
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Resolve the prefs' (rightKind, rightKey) into the page spec the renderer needs, against the
|
|
809
|
+
* LIVE model. Pure and exported for the self-test: a stored key that no longer resolves must
|
|
810
|
+
* produce `null` (the page closes) rather than somebody else's record.
|
|
811
|
+
*
|
|
812
|
+
* The return value is the page's WHOLE content decision, so the renderer never re-derives it:
|
|
813
|
+
* {kind, stepIndex, unit, stepDetail, typeKey, label, scopeStep, bucket, nodes, openType}
|
|
814
|
+
*/
|
|
815
|
+
function wfRightPageSpec(model, prefs) {
|
|
816
|
+
if (!model || !prefs || prefs.rightOpen !== true) return null;
|
|
817
|
+
var kind = typeof prefs.rightKind === "string" ? prefs.rightKind : "step";
|
|
818
|
+
if (WF_RIGHT_KINDS.indexOf(kind) < 0) return null;
|
|
819
|
+
|
|
820
|
+
if (kind === "inject") {
|
|
821
|
+
return { kind: "inject", stepIndex: null, openType: null, unit: null, stepDetail: null };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
var key = typeof prefs.rightKey === "string" ? prefs.rightKey : "";
|
|
825
|
+
var units = model.page && Array.isArray(model.page.units) ? model.page.units : [];
|
|
826
|
+
|
|
827
|
+
if (kind === "type") {
|
|
828
|
+
var at = key.indexOf("|");
|
|
829
|
+
if (at < 0) return null;
|
|
830
|
+
var scopeRaw = key.slice(0, at);
|
|
831
|
+
var typeKey = key.slice(at + 1);
|
|
832
|
+
if (typeKey === "") return null;
|
|
833
|
+
var scopeStep = scopeRaw === "session" ? null : wfFinite(Number(scopeRaw));
|
|
834
|
+
if (scopeRaw !== "session" && (scopeStep === null || !isFinite(scopeStep))) return null;
|
|
835
|
+
// The bucket is looked up in the units the model actually has: a type from another session's
|
|
836
|
+
// stored key simply does not resolve.
|
|
837
|
+
var seen = null;
|
|
838
|
+
var owner = null;
|
|
839
|
+
if (scopeStep === null) {
|
|
840
|
+
// Whole session: merge every unit's bucket of this key. The count is the sum of the units',
|
|
841
|
+
// which is exactly what each circle already claims, so the page cannot contradict the chain.
|
|
842
|
+
var merged = { key: typeKey, kind: "file", label: "", side: "left", count: 0, running: false, error: false, nodes: [] };
|
|
843
|
+
for (var u = 0; u < units.length; u += 1) {
|
|
844
|
+
var types = units[u] && Array.isArray(units[u].types) ? units[u].types : [];
|
|
845
|
+
for (var t = 0; t < types.length; t += 1) {
|
|
846
|
+
if (types[t].key !== typeKey) continue;
|
|
847
|
+
merged.kind = types[t].kind;
|
|
848
|
+
merged.label = types[t].label;
|
|
849
|
+
merged.side = types[t].side;
|
|
850
|
+
merged.count += types[t].count;
|
|
851
|
+
merged.running = merged.running || types[t].running === true;
|
|
852
|
+
merged.error = merged.error || types[t].error === true;
|
|
853
|
+
for (var id = 0; id < types[t].nodes.length; id += 1) merged.nodes.push(types[t].nodes[id]);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (merged.label === "") return null;
|
|
857
|
+
seen = merged;
|
|
858
|
+
} else {
|
|
859
|
+
if (scopeStep < 0 || scopeStep >= units.length) return null;
|
|
860
|
+
var unitTypes = units[scopeStep] && Array.isArray(units[scopeStep].types) ? units[scopeStep].types : [];
|
|
861
|
+
for (var ut = 0; ut < unitTypes.length; ut += 1) {
|
|
862
|
+
if (unitTypes[ut].key === typeKey) {
|
|
863
|
+
seen = unitTypes[ut];
|
|
864
|
+
break;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (!seen) return null;
|
|
868
|
+
owner = units[scopeStep];
|
|
869
|
+
}
|
|
870
|
+
// The calls themselves: every node of that type, resolved through the SELECTED step's flow only
|
|
871
|
+
// when the scope IS the selected step (the wire only ships that step's nodes). A session-wide
|
|
872
|
+
// type therefore lists ids it cannot dereference — the page says so rather than inventing rows.
|
|
873
|
+
var calls = [];
|
|
874
|
+
var flowNodes = model.flow && Array.isArray(model.flow.nodes) ? model.flow.nodes : [];
|
|
875
|
+
if (scopeStep !== null && model.selected === scopeStep) {
|
|
876
|
+
var byId = Object.create(null);
|
|
877
|
+
for (var f = 0; f < flowNodes.length; f += 1) byId[flowNodes[f].id] = flowNodes[f];
|
|
878
|
+
for (var s = 0; s < seen.nodes.length; s += 1) {
|
|
879
|
+
if (byId[seen.nodes[s]]) calls.push(byId[seen.nodes[s]]);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return {
|
|
883
|
+
kind: "type",
|
|
884
|
+
stepIndex: model.selected,
|
|
885
|
+
scopeStep: scopeStep,
|
|
886
|
+
openType: typeKey,
|
|
887
|
+
label: seen.label,
|
|
888
|
+
bucket: seen,
|
|
889
|
+
nodes: calls,
|
|
890
|
+
unit: owner,
|
|
891
|
+
stepDetail: null,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// kind === "step": the key is `step:<index>`; a keyless page follows the selection.
|
|
896
|
+
var wanted = null;
|
|
897
|
+
if (key.indexOf("step:") === 0) {
|
|
898
|
+
var parsed = wfFinite(Number(key.slice(5)));
|
|
899
|
+
if (parsed !== null && isFinite(parsed)) wanted = Math.round(parsed);
|
|
900
|
+
}
|
|
901
|
+
if (wanted === null) wanted = model.selected;
|
|
902
|
+
if (wanted === null || wanted < 0 || wanted >= units.length) return null;
|
|
903
|
+
var row = units[wanted];
|
|
904
|
+
return {
|
|
905
|
+
kind: "step",
|
|
906
|
+
stepIndex: wanted,
|
|
907
|
+
openType: null,
|
|
908
|
+
unit: row,
|
|
909
|
+
// Only the selected step's detail is on the wire; a page about another step says so.
|
|
910
|
+
stepDetail: model.selected === wanted && model.detail ? model.detail : null,
|
|
911
|
+
scopeStep: wanted,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Bring the RUNNING unit to the middle of the chain — once per step, and only if the user has not
|
|
917
|
+
* scrolled since.
|
|
918
|
+
*
|
|
919
|
+
* Two rules make this honest rather than helpful-looking:
|
|
920
|
+
* 1. it runs once per running step (`centeredStep`), so a poll cannot keep re-centring a reader;
|
|
921
|
+
* 2. any manual scroll in the page defeats it — `userScrolled` is set by the FIRST wheel / touch /
|
|
922
|
+
* keydown on the scroller, and after that this hook does nothing for that step.
|
|
923
|
+
*/
|
|
924
|
+
/**
|
|
925
|
+
* The centring DECISION, as a pure function: run the auto-centring for `runningIndex` only
|
|
926
|
+
* · when there IS a running step,
|
|
927
|
+
* · when it has not already been centred once (`centeredStep`), and
|
|
928
|
+
* · when the user has not scrolled the chain by hand (`scrolled`).
|
|
929
|
+
*
|
|
930
|
+
* The third clause is the user's "unless the user scrolls by hand". Note the trap this function exists to avoid:
|
|
931
|
+
* passing a sentinel like -1 as `centeredStep` would mean "never centred" and RE-CENTRE after every
|
|
932
|
+
* manual scroll — the exact opposite of the request.
|
|
933
|
+
*/
|
|
934
|
+
function wfShouldCenter(runningIndex, centeredStep, scrolled) {
|
|
935
|
+
if (scrolled === true) return false;
|
|
936
|
+
if (runningIndex === null || runningIndex === undefined) return false;
|
|
937
|
+
return centeredStep !== runningIndex;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function wfUsePageCenter(model, centeredStep, scrolled, setCenteredStep) {
|
|
941
|
+
var runningIndex = model && model.page && wfFinite(model.page.runningIndex) !== null ? model.page.runningIndex : null;
|
|
942
|
+
React.useEffect(
|
|
943
|
+
function () {
|
|
944
|
+
if (!wfShouldCenter(runningIndex, centeredStep, scrolled)) return undefined;
|
|
945
|
+
if (typeof document === "undefined") return undefined;
|
|
946
|
+
var result = wfCenterRunning(document, runningIndex);
|
|
947
|
+
if (result.applied === true) setCenteredStep(runningIndex);
|
|
948
|
+
return undefined;
|
|
949
|
+
},
|
|
950
|
+
[runningIndex, centeredStep, scrolled]
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* The centring itself, split out of the hook so the browser check can run the SAME code the
|
|
956
|
+
* product runs (a measurement page mounts the renderer's tree, it does not mount React).
|
|
957
|
+
*
|
|
958
|
+
* Returns `{applied, reason?, offset?, error?}` — never throws, never scrolls on a partial DOM.
|
|
959
|
+
* `error` is the signed distance from the unit's middle to the viewport's middle AFTER the write,
|
|
960
|
+
* which is what the acceptance measures.
|
|
961
|
+
*/
|
|
962
|
+
function wfCenterRunning(doc, runningIndex) {
|
|
963
|
+
if (!doc || typeof doc.querySelector !== "function") return { applied: false, reason: "no-doc" };
|
|
964
|
+
var index = wfFinite(runningIndex);
|
|
965
|
+
if (index === null) return { applied: false, reason: "no-running-step" };
|
|
966
|
+
var scroller = doc.querySelector('[data-dsh-workflow-map="page-scroll"]');
|
|
967
|
+
if (!scroller || typeof scroller.scrollTop !== "number") return { applied: false, reason: "no-scroller" };
|
|
968
|
+
var unit =
|
|
969
|
+
typeof scroller.querySelector === "function"
|
|
970
|
+
? scroller.querySelector('.wf-unit[data-step-index="' + Math.round(index) + '"]')
|
|
971
|
+
: null;
|
|
972
|
+
if (!unit || typeof unit.getBoundingClientRect !== "function") return { applied: false, reason: "no-unit" };
|
|
973
|
+
var frame = scroller.getBoundingClientRect();
|
|
974
|
+
var rect = unit.getBoundingClientRect();
|
|
975
|
+
var offset = wfCenterOffset(
|
|
976
|
+
rect.top - frame.top + scroller.scrollTop,
|
|
977
|
+
rect.height,
|
|
978
|
+
frame.height,
|
|
979
|
+
scroller.scrollHeight
|
|
980
|
+
);
|
|
981
|
+
if (offset === null) return { applied: false, reason: "bad-geometry" };
|
|
982
|
+
scroller.scrollTop = offset;
|
|
983
|
+
// Re-read after the write: the honest error is what the user now sees, not what the arithmetic
|
|
984
|
+
// predicted (a smooth-scroll or a sub-pixel layout shift would otherwise be papered over).
|
|
985
|
+
var after = unit.getBoundingClientRect();
|
|
986
|
+
return {
|
|
987
|
+
applied: true,
|
|
988
|
+
offset: Math.round(offset * 100) / 100,
|
|
989
|
+
error: Math.round((after.top + after.height / 2 - (frame.top + frame.height / 2)) * 100) / 100,
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* The FIRST manual scroll on the chain defeats the auto-centring for the rest of the session.
|
|
995
|
+
* Listening on the scroller only — not on the window — so scrolling the right page never counts.
|
|
996
|
+
*/
|
|
997
|
+
function wfUseManualScrollGuard(mark) {
|
|
998
|
+
React.useEffect(function () {
|
|
999
|
+
if (typeof document === "undefined") return undefined;
|
|
1000
|
+
var scroller = document.querySelector('[data-dsh-workflow-map="page-scroll"]');
|
|
1001
|
+
if (!scroller || typeof scroller.addEventListener !== "function") return undefined;
|
|
1002
|
+
function onUserScroll() {
|
|
1003
|
+
if (typeof mark === "function") mark();
|
|
1004
|
+
scroller.removeEventListener("wheel", onUserScroll);
|
|
1005
|
+
scroller.removeEventListener("touchstart", onUserScroll);
|
|
1006
|
+
scroller.removeEventListener("keydown", onUserScroll);
|
|
1007
|
+
}
|
|
1008
|
+
scroller.addEventListener("wheel", onUserScroll, { passive: true });
|
|
1009
|
+
scroller.addEventListener("touchstart", onUserScroll, { passive: true });
|
|
1010
|
+
scroller.addEventListener("keydown", onUserScroll);
|
|
1011
|
+
return function () {
|
|
1012
|
+
scroller.removeEventListener("wheel", onUserScroll);
|
|
1013
|
+
scroller.removeEventListener("touchstart", onUserScroll);
|
|
1014
|
+
scroller.removeEventListener("keydown", onUserScroll);
|
|
1015
|
+
};
|
|
1016
|
+
}, []);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/* ------------------------------------------------------------------------- */
|
|
1020
|
+
/* the component */
|
|
1021
|
+
/* ------------------------------------------------------------------------- */
|
|
1022
|
+
|
|
1023
|
+
function WorkflowMapPanel(props) {
|
|
1024
|
+
var sessionId = wfUseSessionId(props);
|
|
1025
|
+
var prefsHandle = wfUsePrefs();
|
|
1026
|
+
var prefs = prefsHandle.prefs;
|
|
1027
|
+
var updatePrefs = prefsHandle.update;
|
|
1028
|
+
var snap = wfUseSnap(sessionId);
|
|
1029
|
+
var settingsPair = React.useState(false);
|
|
1030
|
+
var settingsOpen = settingsPair[0];
|
|
1031
|
+
var setSettingsOpen = settingsPair[1];
|
|
1032
|
+
// the composer's four-state machine and what was typed. It deliberately
|
|
1033
|
+
// lives in React state, NOT in localStorage: an in-flight send and a draft prompt are
|
|
1034
|
+
// per-session, per-moment facts, and reviving a stale "injected" would be a lie.
|
|
1035
|
+
var injectPair = React.useState({ state: "idle", text: "", reason: "" });
|
|
1036
|
+
var inject = injectPair[0];
|
|
1037
|
+
var setInject = injectPair[1];
|
|
1038
|
+
// which history row is being revoked right now, and (on failure) the host's
|
|
1039
|
+
// own reason for that row. Deliberately NOT in the inject record above: the send's four-state
|
|
1040
|
+
// machine clears itself on a timer, while a revocation stays on screen until the FOLDED row
|
|
1041
|
+
// stops being `pending` — two different lifetimes, two different pieces of state.
|
|
1042
|
+
var revokePair = React.useState({ id: "", state: "idle", reason: "" });
|
|
1043
|
+
var injectRevoke = revokePair[0];
|
|
1044
|
+
var setInjectRevoke = revokePair[1];
|
|
1045
|
+
// which call the type page has expanded, and whether the chain has already brought the
|
|
1046
|
+
// running unit to the middle (once per step — the user's own scrolling must never be undone).
|
|
1047
|
+
var callPair = React.useState(null);
|
|
1048
|
+
var openCall = callPair[0];
|
|
1049
|
+
var setOpenCall = callPair[1];
|
|
1050
|
+
var centeredPair = React.useState(null);
|
|
1051
|
+
var centeredStep = centeredPair[0];
|
|
1052
|
+
var setCenteredStep = centeredPair[1];
|
|
1053
|
+
wfUseStyle();
|
|
1054
|
+
wfUseAltW(updatePrefs);
|
|
1055
|
+
wfUseSessionReset(sessionId, updatePrefs);
|
|
1056
|
+
|
|
1057
|
+
var say = wfMakeSay(props && props.t);
|
|
1058
|
+
// `Date.now` is passed in (the store stays clock-free) because needs one honest
|
|
1059
|
+
// judgement the event log alone cannot make: is this session still working right now?
|
|
1060
|
+
var model = wfBuildModel(snap.view, prefs, Date.now());
|
|
1061
|
+
var systemMotion = wfUseMotion();
|
|
1062
|
+
var motionPref = wfSettingsOf(prefs).motion;
|
|
1063
|
+
var motion = systemMotion === "static" || motionPref === "static" ? "static" : "css";
|
|
1064
|
+
// The curve is measured from the DOM, so it must be re-measured whenever the two endpoint
|
|
1065
|
+
// cards could have moved — the effect's dependency is a cheap string, not the whole model.
|
|
1066
|
+
// the old two-column curve (step ↔ node) is gone with the columns: there is no second
|
|
1067
|
+
// column to connect to. The living layer's CSS (ring, breathing dot, dashed branch) is reused;
|
|
1068
|
+
// only its measurement is no longer needed.
|
|
1069
|
+
// Resolve the per-step flow state BEFORE rendering: a state left over from another
|
|
1070
|
+
// step reads as the defaults (collapsed groups · no show-all · no highlight).
|
|
1071
|
+
// the right page's content is DERIVED from the prefs (kind + key) and the model, so a
|
|
1072
|
+
// stale key can never show another step's record: every branch below re-resolves against the
|
|
1073
|
+
// live view and falls back to the unit's own numbers.
|
|
1074
|
+
var page = wfRightPageSpec(model, prefs);
|
|
1075
|
+
var scrolledPair = React.useState(0);
|
|
1076
|
+
var scrolled = scrolledPair[0];
|
|
1077
|
+
var setScrolled = scrolledPair[1];
|
|
1078
|
+
wfUseManualScrollGuard(function () {
|
|
1079
|
+
setScrolled(1);
|
|
1080
|
+
});
|
|
1081
|
+
// centring: once per running step, defeated permanently by the FIRST manual scroll.
|
|
1082
|
+
wfUsePageCenter(model, centeredStep, scrolled === 1, setCenteredStep);
|
|
1083
|
+
// object URLs created by exports are revoked when this panel unmounts.
|
|
1084
|
+
wfUseSnapshotUrls();
|
|
1085
|
+
|
|
1086
|
+
var actions = {
|
|
1087
|
+
onCollapse: function () {
|
|
1088
|
+
updatePrefs({ collapsed: true });
|
|
1089
|
+
},
|
|
1090
|
+
onExpand: function () {
|
|
1091
|
+
updatePrefs({ collapsed: false });
|
|
1092
|
+
},
|
|
1093
|
+
// Hot injection is a PAGE, not a split: opening it replaces whatever the right page was
|
|
1094
|
+
// showing; closing it closes the page (the composer's draft stays in React state).
|
|
1095
|
+
onToggleInject: function () {
|
|
1096
|
+
updatePrefs(function (prev) {
|
|
1097
|
+
var open = prev.rightOpen === true && prev.rightKind === "inject";
|
|
1098
|
+
return open ? { rightOpen: false } : { rightOpen: true, rightKind: "inject" };
|
|
1099
|
+
});
|
|
1100
|
+
},
|
|
1101
|
+
onInjectText: function (value) {
|
|
1102
|
+
setInject({ state: "idle", text: String(value), reason: "" });
|
|
1103
|
+
},
|
|
1104
|
+
onInjectCancel: function () {
|
|
1105
|
+
setInject({ state: "idle", text: "", reason: "" });
|
|
1106
|
+
updatePrefs({ rightOpen: false });
|
|
1107
|
+
},
|
|
1108
|
+
onInjectSend: function (text) {
|
|
1109
|
+
wfSendInject(text, sessionId, setInject);
|
|
1110
|
+
},
|
|
1111
|
+
// the history row's "Revoke": one POST, then wait for the fold to say
|
|
1112
|
+
// "revoked". The row's id comes from the model (the host returned it on the steer 200 and the
|
|
1113
|
+
// fold carried it into the rows), never from a DOM lookup.
|
|
1114
|
+
onInjectRevoke: function (messageId) {
|
|
1115
|
+
wfRevokeInject(messageId, sessionId, setInjectRevoke);
|
|
1116
|
+
},
|
|
1117
|
+
// "cancel send": abort only in-flight requests. A 200 cannot be pulled back (the host exposes no inbox revoke for it).
|
|
1118
|
+
onInjectAbort: function () {
|
|
1119
|
+
var controller = typeof window !== "undefined" ? window.__dshWorkflowMapAbort : null;
|
|
1120
|
+
if (controller && typeof controller.abort === "function") controller.abort();
|
|
1121
|
+
window.__dshWorkflowMapAbort = null;
|
|
1122
|
+
setInject({ state: "idle", text: "", reason: "" });
|
|
1123
|
+
},
|
|
1124
|
+
onToggleSettings: function () {
|
|
1125
|
+
setSettingsOpen(!settingsOpen);
|
|
1126
|
+
},
|
|
1127
|
+
// Clicking a main-flow unit: it becomes the SELECTED step (store.js builds its detail) and the
|
|
1128
|
+
// right page opens on it. It deliberately does not expand anything in place.
|
|
1129
|
+
// right page opens on it. It deliberately does not expand anything in place.
|
|
1130
|
+
onOpenStep: function (index) {
|
|
1131
|
+
setOpenCall(null);
|
|
1132
|
+
updatePrefs({
|
|
1133
|
+
selectedStep: index,
|
|
1134
|
+
rightOpen: true,
|
|
1135
|
+
rightKind: "step",
|
|
1136
|
+
rightKey: "step:" + index,
|
|
1137
|
+
});
|
|
1138
|
+
},
|
|
1139
|
+
// A round satellite (or a type row inside the step page): the type page, scoped to that step
|
|
1140
|
+
// when it came from a unit's gutter, or to the whole session when it came from the header.
|
|
1141
|
+
onOpenType: function (index, typeKey) {
|
|
1142
|
+
setOpenCall(null);
|
|
1143
|
+
var scoped = wfFinite(index) === null ? "session" : String(Math.round(index));
|
|
1144
|
+
var patch = {
|
|
1145
|
+
rightOpen: true,
|
|
1146
|
+
rightKind: "type",
|
|
1147
|
+
rightKey: scoped + "|" + String(typeKey),
|
|
1148
|
+
};
|
|
1149
|
+
if (scoped !== "session") patch.selectedStep = Math.round(index);
|
|
1150
|
+
updatePrefs(patch);
|
|
1151
|
+
},
|
|
1152
|
+
onSelectCall: function (nodeId) {
|
|
1153
|
+
setOpenCall(function (prev) {
|
|
1154
|
+
return prev === nodeId ? null : nodeId;
|
|
1155
|
+
});
|
|
1156
|
+
},
|
|
1157
|
+
onClosePage: function () {
|
|
1158
|
+
setOpenCall(null);
|
|
1159
|
+
updatePrefs({ rightOpen: false });
|
|
1160
|
+
},
|
|
1161
|
+
onSelectStep: function (index) {
|
|
1162
|
+
updatePrefs({ selectedStep: index });
|
|
1163
|
+
},
|
|
1164
|
+
onReset: function () {
|
|
1165
|
+
// the DOCUMENTED defaults, not whatever localStorage last held.
|
|
1166
|
+
updatePrefs(wfDefaultPrefs());
|
|
1167
|
+
},
|
|
1168
|
+
onSetting: function (setting, value) {
|
|
1169
|
+
var patch = {};
|
|
1170
|
+
patch[setting] = value;
|
|
1171
|
+
updatePrefs(patch);
|
|
1172
|
+
},
|
|
1173
|
+
onExportCopy: function (kind, element) {
|
|
1174
|
+
if (!model) return;
|
|
1175
|
+
wfCopyWithFeedback(
|
|
1176
|
+
wfSnapshotText(kind, model, snap.view, sessionId, Date.now(), say),
|
|
1177
|
+
element,
|
|
1178
|
+
say
|
|
1179
|
+
);
|
|
1180
|
+
},
|
|
1181
|
+
onExportDownload: function (kind) {
|
|
1182
|
+
if (!model) return;
|
|
1183
|
+
wfSnapshotDownload(
|
|
1184
|
+
kind,
|
|
1185
|
+
wfSnapshotText(kind, model, snap.view, sessionId, Date.now(), say),
|
|
1186
|
+
sessionId
|
|
1187
|
+
);
|
|
1188
|
+
},
|
|
1189
|
+
// Escape (wfBindEscape): close the right page, never the panel. The step detail is no longer
|
|
1190
|
+
// inline, so this is the whole gesture now.
|
|
1191
|
+
onEscapeDetail: function () {
|
|
1192
|
+
setOpenCall(null);
|
|
1193
|
+
updatePrefs({ rightOpen: false });
|
|
1194
|
+
},
|
|
1195
|
+
// A path / command copy with the 1.5 s acknowledgement (NodeDetail.js).
|
|
1196
|
+
onCopy: function (text, element) {
|
|
1197
|
+
wfCopyWithFeedback(text, element, say);
|
|
1198
|
+
},
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
wfUseEscapeDetail(model ? model.selected : null, actions);
|
|
1202
|
+
|
|
1203
|
+
return wfRenderPanel(model, prefs, say, actions, {
|
|
1204
|
+
settingsOpen: settingsOpen,
|
|
1205
|
+
status: snap.status,
|
|
1206
|
+
error: snap.error,
|
|
1207
|
+
rev: snap.rev,
|
|
1208
|
+
endpoint: sessionId ? wfPollUrl(sessionId) : "",
|
|
1209
|
+
nodeTotal: snap.view && snap.view.counts ? wfFinite(snap.view.counts.nodes) : null,
|
|
1210
|
+
// what the right page shows, already resolved against the model.
|
|
1211
|
+
right: page,
|
|
1212
|
+
pageStep: page ? page.stepIndex : null,
|
|
1213
|
+
pageType: page ? page.openType : null,
|
|
1214
|
+
openCall: openCall,
|
|
1215
|
+
asOf: model ? model.asOf : null,
|
|
1216
|
+
// what the living layer reads off the root element.
|
|
1217
|
+
motion: motion,
|
|
1218
|
+
// "Always" keeps the living layer moving even when the session is idle;
|
|
1219
|
+
// "While running" (default) preserves the idle freeze; "Off" is data-motion=static.
|
|
1220
|
+
idle: motionPref === "always" ? false : !model || model.running !== true,
|
|
1221
|
+
// the composer's own state, read straight from React.
|
|
1222
|
+
injectState: inject.state,
|
|
1223
|
+
injectText: inject.text,
|
|
1224
|
+
injectReason: inject.reason,
|
|
1225
|
+
// which row is being revoked, and the host's reason when it refused.
|
|
1226
|
+
injectRevoke: injectRevoke,
|
|
1227
|
+
sessionId: sessionId || "",
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/* ------------------------------------------------------------------------- */
|
|
1232
|
+
/* the pure renderer */
|
|
1233
|
+
/* ------------------------------------------------------------------------- */
|
|
1234
|
+
|
|
1235
|
+
function wfFire(fn, argument) {
|
|
1236
|
+
if (typeof fn === "function") fn(argument);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function wfRenderPanel(model, prefs, say, actions, ui) {
|
|
1240
|
+
if (!model) return null;
|
|
1241
|
+
// the header badge's only number: rows the session has not taken yet.
|
|
1242
|
+
var pendingCount = wfPendingInjectCount(model.injects);
|
|
1243
|
+
var extras = ui || {};
|
|
1244
|
+
var fire = actions || {};
|
|
1245
|
+
// the two attributes the living layer needs. `motion` comes from the system's
|
|
1246
|
+
// prefers-reduced-motion preference (pulse.js), `idle` from the model's running flag.
|
|
1247
|
+
// Both live on .wf-root so the capsule is covered as well as the panel.
|
|
1248
|
+
var motion = extras.motion === "static" ? "static" : "css";
|
|
1249
|
+
var idle = extras.idle === true;
|
|
1250
|
+
// the layout owns the geometry. There is no dragged width, no pinned height and
|
|
1251
|
+
// no viewport clamp any more — the centre column decides both, and the card fills it.
|
|
1252
|
+
var collapsed = prefs.collapsed === true;
|
|
1253
|
+
var planning = model.kind === "planning";
|
|
1254
|
+
// one sanitised settings record drives the root data attributes and the
|
|
1255
|
+
// settings card, so a click can never leave the DOM and the card disagreeing.
|
|
1256
|
+
// Read the live prefs, not model.settings: a click must change the root attributes
|
|
1257
|
+
// on the same render even before the next poll produces an equivalent model.
|
|
1258
|
+
var settings = wfSettingsOf(prefs);
|
|
1259
|
+
if (collapsed) {
|
|
1260
|
+
var capsuleText = planning
|
|
1261
|
+
? say("panel.planning")
|
|
1262
|
+
: say("capsule.progress", {
|
|
1263
|
+
done: model.progress.done,
|
|
1264
|
+
total: model.progress.total,
|
|
1265
|
+
percent: model.progress.percent,
|
|
1266
|
+
});
|
|
1267
|
+
return h(
|
|
1268
|
+
"div",
|
|
1269
|
+
{
|
|
1270
|
+
className: "wf-root",
|
|
1271
|
+
key: "wf-root",
|
|
1272
|
+
"data-dsh-workflow-map": "root",
|
|
1273
|
+
"data-idle": idle ? "1" : "0",
|
|
1274
|
+
"data-motion": motion,
|
|
1275
|
+
"data-motion-pref": settings.motion,
|
|
1276
|
+
"data-palette": settings.palette,
|
|
1277
|
+
"data-detail": settings.detail,
|
|
1278
|
+
"data-node-limit": String(settings.nodeLimit),
|
|
1279
|
+
},
|
|
1280
|
+
// collapsed = a folded row inside the main area, not a floating capsule.
|
|
1281
|
+
// The row keeps the live count so the panel can be left folded and still be read.
|
|
1282
|
+
h(
|
|
1283
|
+
"div",
|
|
1284
|
+
{ className: "wf-capsule-bar", key: "capsule-bar" },
|
|
1285
|
+
h(
|
|
1286
|
+
"button",
|
|
1287
|
+
{
|
|
1288
|
+
type: "button",
|
|
1289
|
+
key: "capsule",
|
|
1290
|
+
className: "wf-capsule",
|
|
1291
|
+
"data-dsh-workflow-map": "capsule",
|
|
1292
|
+
"aria-expanded": "false",
|
|
1293
|
+
"aria-label": say("capsule.aria"),
|
|
1294
|
+
title: say("capsule.title") + " — " + capsuleText,
|
|
1295
|
+
onClick: function () {
|
|
1296
|
+
wfFire(fire.onExpand);
|
|
1297
|
+
},
|
|
1298
|
+
},
|
|
1299
|
+
h("span", { className: "wf-dot", key: "dot", "aria-hidden": "true" }),
|
|
1300
|
+
h("span", { className: "wf-capsule-text", key: "text" }, capsuleText)
|
|
1301
|
+
),
|
|
1302
|
+
h("span", { className: "wf-capsule-hint", key: "hint" }, say("capsule.hint"))
|
|
1303
|
+
)
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
var header = h(
|
|
1308
|
+
"header",
|
|
1309
|
+
{ className: "wf-head", key: "head" },
|
|
1310
|
+
h(
|
|
1311
|
+
"div",
|
|
1312
|
+
{ className: "wf-head-text", key: "text" },
|
|
1313
|
+
h(
|
|
1314
|
+
"div",
|
|
1315
|
+
{ className: "wf-task", key: "task", title: model.task || "" },
|
|
1316
|
+
model.task || say("panel.task.fallback")
|
|
1317
|
+
),
|
|
1318
|
+
// No progress line while planning: the body's single "planning..." line is the
|
|
1319
|
+
// whole message ( contract §3), so the number cannot appear twice.
|
|
1320
|
+
planning
|
|
1321
|
+
? null
|
|
1322
|
+
: h(
|
|
1323
|
+
"div",
|
|
1324
|
+
{ className: "wf-meta", key: "meta" },
|
|
1325
|
+
h("span", { className: "wf-pct", key: "pct" }, wfProgressText(say, model.progress)),
|
|
1326
|
+
h("span", { className: "wf-sep", key: "sep" }, "·"),
|
|
1327
|
+
h(
|
|
1328
|
+
"span",
|
|
1329
|
+
{ className: "wf-raw", key: "nodes" },
|
|
1330
|
+
say("panel.nodes", { count: model.flow.total })
|
|
1331
|
+
),
|
|
1332
|
+
extras.status === "stale"
|
|
1333
|
+
? h(
|
|
1334
|
+
"span",
|
|
1335
|
+
{ className: "wf-status-bad", key: "stale", title: extras.error || "" },
|
|
1336
|
+
say("panel.stale")
|
|
1337
|
+
)
|
|
1338
|
+
: null
|
|
1339
|
+
)
|
|
1340
|
+
),
|
|
1341
|
+
h(
|
|
1342
|
+
"div",
|
|
1343
|
+
{ className: "wf-btns", key: "btns" },
|
|
1344
|
+
h(
|
|
1345
|
+
"button",
|
|
1346
|
+
{
|
|
1347
|
+
type: "button",
|
|
1348
|
+
key: "collapse",
|
|
1349
|
+
className: "wf-btn",
|
|
1350
|
+
title: say("panel.collapse"),
|
|
1351
|
+
"aria-label": say("panel.collapse"),
|
|
1352
|
+
onClick: function () {
|
|
1353
|
+
wfFire(fire.onCollapse);
|
|
1354
|
+
},
|
|
1355
|
+
},
|
|
1356
|
+
"‒"
|
|
1357
|
+
),
|
|
1358
|
+
// the hot-inject entry is now a NAMED, prominent button (the user asked for it to be
|
|
1359
|
+
// prominent and in Chinese), not a ⇄ glyph: the glyph said "split the column", which no longer exists
|
|
1360
|
+
// what happens — the composer takes over the right-hand page.
|
|
1361
|
+
h(
|
|
1362
|
+
"button",
|
|
1363
|
+
{
|
|
1364
|
+
type: "button",
|
|
1365
|
+
key: "inject",
|
|
1366
|
+
className: "wf-btn wf-btn-hot",
|
|
1367
|
+
"data-dsh-workflow-map": "inject-toggle",
|
|
1368
|
+
// the badge is driven by the model's own rows: only a row
|
|
1369
|
+
// whose status is "pending" is still waiting for the turn. No pending row ⇒ neither the
|
|
1370
|
+
// attribute nor the element exists (the self-test asserts both directions).
|
|
1371
|
+
"data-pending-count": pendingCount > 0 ? String(pendingCount) : undefined,
|
|
1372
|
+
"data-active": extras.right && extras.right.kind === "inject" ? "true" : undefined,
|
|
1373
|
+
title: pendingCount > 0 ? say("panel.inject.pending.badge", { count: pendingCount }) : say("panel.inject.open"),
|
|
1374
|
+
"aria-label": say("panel.inject.open"),
|
|
1375
|
+
"aria-pressed": extras.right && extras.right.kind === "inject" ? "true" : "false",
|
|
1376
|
+
onClick: function () {
|
|
1377
|
+
wfFire(fire.onToggleInject);
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
say("panel.inject.banner"),
|
|
1381
|
+
pendingCount > 0
|
|
1382
|
+
? h(
|
|
1383
|
+
"span",
|
|
1384
|
+
{ className: "wf-inject-badge", key: "pending", "aria-hidden": "true" },
|
|
1385
|
+
String(pendingCount)
|
|
1386
|
+
)
|
|
1387
|
+
: null
|
|
1388
|
+
),
|
|
1389
|
+
h(
|
|
1390
|
+
"button",
|
|
1391
|
+
{
|
|
1392
|
+
type: "button",
|
|
1393
|
+
key: "settings",
|
|
1394
|
+
className: "wf-btn",
|
|
1395
|
+
"data-active": extras.settingsOpen === true ? "true" : undefined,
|
|
1396
|
+
title: say("panel.settings"),
|
|
1397
|
+
"aria-label": say("panel.settings"),
|
|
1398
|
+
onClick: function () {
|
|
1399
|
+
wfFire(fire.onToggleSettings);
|
|
1400
|
+
},
|
|
1401
|
+
},
|
|
1402
|
+
"⚙"
|
|
1403
|
+
),
|
|
1404
|
+
)
|
|
1405
|
+
);
|
|
1406
|
+
|
|
1407
|
+
var settingsCard =
|
|
1408
|
+
extras.settingsOpen === true
|
|
1409
|
+
? wfRenderSettings(h, prefs, say, fire, {
|
|
1410
|
+
endpoint: extras.endpoint,
|
|
1411
|
+
steps: model.progress.total,
|
|
1412
|
+
nodes: wfFinite(extras.nodeTotal) === null ? model.flow.total : extras.nodeTotal,
|
|
1413
|
+
rev: extras.rev,
|
|
1414
|
+
})
|
|
1415
|
+
: null;
|
|
1416
|
+
|
|
1417
|
+
var body;
|
|
1418
|
+
if (planning) {
|
|
1419
|
+
body = h(
|
|
1420
|
+
"div",
|
|
1421
|
+
{ className: "wf-body", key: "body", "data-kind": "planning" },
|
|
1422
|
+
h(
|
|
1423
|
+
"div",
|
|
1424
|
+
{ className: "wf-empty", key: "empty" },
|
|
1425
|
+
h("div", { key: "line" }, say("panel.planning")),
|
|
1426
|
+
h("div", { key: "note" }, say("panel.planning.note"))
|
|
1427
|
+
)
|
|
1428
|
+
);
|
|
1429
|
+
} else {
|
|
1430
|
+
body = h(
|
|
1431
|
+
"div",
|
|
1432
|
+
{
|
|
1433
|
+
className: "wf-body",
|
|
1434
|
+
key: "body",
|
|
1435
|
+
"data-kind": "ready",
|
|
1436
|
+
// ONE page. The right-hand page is a flex sibling, not an overlay and not a third
|
|
1437
|
+
// column: `data-right` says whether it is mounted, and its flex basis does the rest.
|
|
1438
|
+
"data-right": extras.right ? "true" : "false",
|
|
1439
|
+
"data-dsh-workflow-map": "body",
|
|
1440
|
+
},
|
|
1441
|
+
// The serial main flow: one unit per step, satellites on both sides, straight connectors.
|
|
1442
|
+
wfRenderMainFlow(h, model, say, fire, {
|
|
1443
|
+
openStep: extras.pageStep,
|
|
1444
|
+
openType: extras.pageType,
|
|
1445
|
+
asOf: wfFinite(extras.asOf) === null ? model.asOf : extras.asOf,
|
|
1446
|
+
// "Title only" narrows the unit's rail to one line, exactly as it did in the old column.
|
|
1447
|
+
compact: settings.detail === "title",
|
|
1448
|
+
}),
|
|
1449
|
+
// The right page: a step record, one file type's calls, or the inject composer.
|
|
1450
|
+
extras.right
|
|
1451
|
+
? wfRenderRightPage(
|
|
1452
|
+
h,
|
|
1453
|
+
model,
|
|
1454
|
+
say,
|
|
1455
|
+
fire,
|
|
1456
|
+
Object.assign({}, extras.right, {
|
|
1457
|
+
openCall: extras.openCall,
|
|
1458
|
+
activeNodeId: model.flow && typeof model.flow === "object" ? model.flow.activeNodeId : null,
|
|
1459
|
+
injectState: extras.injectState,
|
|
1460
|
+
injectText: extras.injectText,
|
|
1461
|
+
injectReason: extras.injectReason,
|
|
1462
|
+
// the history rows, straight from the model (the host folds them from the
|
|
1463
|
+
// session's own log, so the browser adds nothing and invents nothing).
|
|
1464
|
+
injectHistory: model.injects,
|
|
1465
|
+
// the one revocation on screen, flattened for the composer.
|
|
1466
|
+
injectRevokeId: extras.injectRevoke && typeof extras.injectRevoke === "object" ? extras.injectRevoke.id : "",
|
|
1467
|
+
injectRevokeState: extras.injectRevoke && typeof extras.injectRevoke === "object" ? extras.injectRevoke.state : "idle",
|
|
1468
|
+
injectRevokeReason: extras.injectRevoke && typeof extras.injectRevoke === "object" ? extras.injectRevoke.reason : "",
|
|
1469
|
+
sessionId: extras.sessionId,
|
|
1470
|
+
})
|
|
1471
|
+
)
|
|
1472
|
+
: null
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
return h(
|
|
1477
|
+
"div",
|
|
1478
|
+
{
|
|
1479
|
+
className: "wf-root",
|
|
1480
|
+
key: "wf-root",
|
|
1481
|
+
"data-dsh-workflow-map": "root",
|
|
1482
|
+
"data-idle": idle ? "1" : "0",
|
|
1483
|
+
"data-motion": motion,
|
|
1484
|
+
"data-motion-pref": settings.motion,
|
|
1485
|
+
"data-palette": settings.palette,
|
|
1486
|
+
"data-detail": settings.detail,
|
|
1487
|
+
"data-node-limit": String(settings.nodeLimit),
|
|
1488
|
+
},
|
|
1489
|
+
h(
|
|
1490
|
+
"section",
|
|
1491
|
+
{
|
|
1492
|
+
className: "wf-card",
|
|
1493
|
+
key: "card",
|
|
1494
|
+
// a main-column panel is a region, not a dialog — it is not modal,
|
|
1495
|
+
// it does not trap focus, and it has no close affordance of its own.
|
|
1496
|
+
role: "region",
|
|
1497
|
+
"aria-label": say("panel.aria", { task: model.task || say("panel.task.fallback") }),
|
|
1498
|
+
"data-dsh-workflow-map": "panel",
|
|
1499
|
+
"data-kind": model.kind,
|
|
1500
|
+
"data-status": extras.status || "idle",
|
|
1501
|
+
},
|
|
1502
|
+
header,
|
|
1503
|
+
settingsCard,
|
|
1504
|
+
body
|
|
1505
|
+
)
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/* ------------------------------------------------------------------------- */
|
|
1510
|
+
/* the sidebar icon */
|
|
1511
|
+
/* ------------------------------------------------------------------------- */
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* The left-rail glyph . Same picture as icon.svg — two stacked panels on a
|
|
1515
|
+
* spine — drawn inline with createElement because the sidebar hands this occupant only
|
|
1516
|
+
* `{size, active}`: no asset URL is resolvable from inside the ModuleLoader factory.
|
|
1517
|
+
* `active` recolours through currentColor, which the shipped rows also rely on.
|
|
1518
|
+
*/
|
|
1519
|
+
function WorkflowMapIcon(props) {
|
|
1520
|
+
var size = wfFinite(props && props.size) === null ? 18 : Math.round(props.size);
|
|
1521
|
+
var active = props && props.active === true;
|
|
1522
|
+
var stroke = "currentColor";
|
|
1523
|
+
return h(
|
|
1524
|
+
"svg",
|
|
1525
|
+
{
|
|
1526
|
+
key: "wf-icon",
|
|
1527
|
+
width: size,
|
|
1528
|
+
height: size,
|
|
1529
|
+
viewBox: "0 0 24 24",
|
|
1530
|
+
fill: "none",
|
|
1531
|
+
"aria-hidden": "true",
|
|
1532
|
+
focusable: "false",
|
|
1533
|
+
style: { display: "block", opacity: active ? 1 : 0.72 },
|
|
1534
|
+
},
|
|
1535
|
+
h("rect", {
|
|
1536
|
+
key: "a",
|
|
1537
|
+
x: 3,
|
|
1538
|
+
y: 3.5,
|
|
1539
|
+
width: 8,
|
|
1540
|
+
height: 17,
|
|
1541
|
+
rx: 2,
|
|
1542
|
+
stroke: stroke,
|
|
1543
|
+
strokeWidth: 1.6,
|
|
1544
|
+
}),
|
|
1545
|
+
h("rect", {
|
|
1546
|
+
key: "b",
|
|
1547
|
+
x: 13.5,
|
|
1548
|
+
y: 3.5,
|
|
1549
|
+
width: 7.5,
|
|
1550
|
+
height: 7,
|
|
1551
|
+
rx: 2,
|
|
1552
|
+
stroke: stroke,
|
|
1553
|
+
strokeWidth: 1.6,
|
|
1554
|
+
}),
|
|
1555
|
+
h("rect", {
|
|
1556
|
+
key: "c",
|
|
1557
|
+
x: 13.5,
|
|
1558
|
+
y: 13.5,
|
|
1559
|
+
width: 7.5,
|
|
1560
|
+
height: 7,
|
|
1561
|
+
rx: 2,
|
|
1562
|
+
stroke: stroke,
|
|
1563
|
+
strokeWidth: 1.6,
|
|
1564
|
+
opacity: 0.55,
|
|
1565
|
+
}),
|
|
1566
|
+
h("line", {
|
|
1567
|
+
key: "d",
|
|
1568
|
+
x1: 7,
|
|
1569
|
+
y1: 7.5,
|
|
1570
|
+
x2: 7,
|
|
1571
|
+
y2: 16.5,
|
|
1572
|
+
stroke: stroke,
|
|
1573
|
+
strokeWidth: 1.6,
|
|
1574
|
+
strokeLinecap: "round",
|
|
1575
|
+
})
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/* ------------------------------------------------------------------------- */
|
|
1580
|
+
/* the conversation-view seat wrapper */
|
|
1581
|
+
/* ------------------------------------------------------------------------- */
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* The conversation-view seat's DOM marker. The SAME panel is mounted twice — the sidebar page
|
|
1585
|
+
* (`main`, keyed by PANEL_ID) and the session tab (`conversation.view`, same id) — and only the tab
|
|
1586
|
+
* one must have the host's composer removed. The two seats are indistinguishable from the panel's
|
|
1587
|
+
* own props, so the tab seat carries this marker and the stylesheet keys its rule on it.
|
|
1588
|
+
*/
|
|
1589
|
+
var WF_VIEW_SEAT_MARKER = "conversation-view";
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Wrap a rendered node in the seat marker. `display:contents` keeps the wrapper BOX-LESS: it adds
|
|
1593
|
+
* no element to the layout, so the seat's geometry is exactly the one the panel already had — the
|
|
1594
|
+
* div exists only to be the `:has` hook for the rule that hides the composer.
|
|
1595
|
+
*/
|
|
1596
|
+
function wfViewSeatRoot(h, node) {
|
|
1597
|
+
return h(
|
|
1598
|
+
"div",
|
|
1599
|
+
{
|
|
1600
|
+
"data-dsh-workflow-map": WF_VIEW_SEAT_MARKER,
|
|
1601
|
+
style: { display: "contents" },
|
|
1602
|
+
},
|
|
1603
|
+
node
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* The `conversation.view` occupant: the same panel, plus the marker.
|
|
1609
|
+
*
|
|
1610
|
+
* WHY A WRAPPER, NOT THE PANEL ITSELF: the composer is NOT ours. The host renders it as a SIBLING
|
|
1611
|
+
* of the selected view, inside `[data-conversation-content]`
|
|
1612
|
+
* (`div[data-conversation-content] > div[data-conversation-scroll] > [selected view, div[data-composer-seat]]`,
|
|
1613
|
+
* measured in this host), so nothing inside the panel's own tree can unmount it. A stylesheet rule
|
|
1614
|
+
* scoped by `:has` on the seat marker is the only mechanism that removes it without a host change,
|
|
1615
|
+
* and it restores itself the moment the user switches back — the host unmounts this view.
|
|
1616
|
+
*/
|
|
1617
|
+
function WorkflowMapViewSeat(props) {
|
|
1618
|
+
return wfViewSeatRoot(h, WorkflowMapPanel(props));
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
/* ------------------------------------------------------------------------- */
|
|
1622
|
+
/* registration */
|
|
1623
|
+
/* ------------------------------------------------------------------------- */
|
|
1624
|
+
|
|
1625
|
+
/**
|
|
1626
|
+
* two seats, ONE id.
|
|
1627
|
+
*
|
|
1628
|
+
* sidebar.panellist (list) the rail glyph; its `id` is what the row selects
|
|
1629
|
+
* main (keyed) the centre-column page for that same key
|
|
1630
|
+
*
|
|
1631
|
+
* They must agree: `PanelRow` renders `renderSlot("sidebar.panellist", …, {only:id})`
|
|
1632
|
+
* and `selectPanel(id)` then dispatches `main` by that key — selecting an id with no
|
|
1633
|
+
* `main` occupant throws. Registering both is the whole contract.
|
|
1634
|
+
*/
|
|
1635
|
+
|
|
1636
|
+
function apply(ctx) {
|
|
1637
|
+
ctx.effect(function () {
|
|
1638
|
+
return ctx.locale.register(NS, { zh: DICT_ZH, en: DICT_EN });
|
|
1639
|
+
});
|
|
1640
|
+
ctx.slots.inject("sidebar.panellist", function () {
|
|
1641
|
+
return ctx.slots.register(
|
|
1642
|
+
{
|
|
1643
|
+
name: "sidebar.panellist",
|
|
1644
|
+
id: PANEL_ID,
|
|
1645
|
+
order: PANEL_ORDER,
|
|
1646
|
+
label: function () {
|
|
1647
|
+
return DICT_ZH["panel.title"];
|
|
1648
|
+
},
|
|
1649
|
+
locale: NS,
|
|
1650
|
+
},
|
|
1651
|
+
WorkflowMapIcon
|
|
1652
|
+
);
|
|
1653
|
+
});
|
|
1654
|
+
ctx.slots.inject("main", function () {
|
|
1655
|
+
return ctx.slots.register(
|
|
1656
|
+
{
|
|
1657
|
+
name: "main",
|
|
1658
|
+
key: PANEL_ID,
|
|
1659
|
+
locale: NS,
|
|
1660
|
+
},
|
|
1661
|
+
WorkflowMapPanel
|
|
1662
|
+
);
|
|
1663
|
+
});
|
|
1664
|
+
// The shortcut in the session tab bar. `conversation.view` is the slot for the tab row above the conversation area
|
|
1665
|
+
// (a list ordered by `order`): chat(0) / trajectory(10) / context(20) / fleet(30), so order 40 puts it
|
|
1666
|
+
// after "fleet". Clicking it renders the same `WorkflowMapPanel`, wrapped in a seat marker
|
|
1667
|
+
// `wfViewSeatRoot`: the stylesheet uses it to hide the host composer seat - the very same
|
|
1668
|
+
// panel, the same polling, the same hot injection. Not a second implementation.
|
|
1669
|
+
ctx.slots.inject("conversation.view", function () {
|
|
1670
|
+
return ctx.slots.register(
|
|
1671
|
+
{
|
|
1672
|
+
name: "conversation.view",
|
|
1673
|
+
id: PANEL_ID,
|
|
1674
|
+
order: PANEL_ORDER * 4,
|
|
1675
|
+
locale: NS,
|
|
1676
|
+
label: function () {
|
|
1677
|
+
return DICT_ZH["panel.title"];
|
|
1678
|
+
},
|
|
1679
|
+
},
|
|
1680
|
+
WorkflowMapViewSeat
|
|
1681
|
+
);
|
|
1682
|
+
});
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* Test seam: tools/selftest-panel.mjs loads the built bundle with a fake `require`
|
|
1687
|
+
* and asserts the pure parts directly. Nothing in the app reads this.
|
|
1688
|
+
*/
|
|
1689
|
+
var __test = {
|
|
1690
|
+
buildModel: wfBuildModel,
|
|
1691
|
+
// the single page, its satellites, the right page and the type buckets they are built
|
|
1692
|
+
// from. Exported so the self-tests drive the EXACT trees the frame mounts.
|
|
1693
|
+
renderMainFlow: wfRenderMainFlow,
|
|
1694
|
+
renderUnit: wfRenderUnit,
|
|
1695
|
+
renderSatellite: wfRenderSatellite,
|
|
1696
|
+
centerOffset: wfCenterOffset,
|
|
1697
|
+
centerRunning: wfCenterRunning,
|
|
1698
|
+
shouldCenter: wfShouldCenter,
|
|
1699
|
+
satelliteLabel: wfSatelliteLabel,
|
|
1700
|
+
renderRightPage: wfRenderRightPage,
|
|
1701
|
+
renderTypeBody: wfRenderTypeBody,
|
|
1702
|
+
renderCallRow: wfRenderCallRow,
|
|
1703
|
+
renderStepTypeSummary: wfRenderStepTypeSummary,
|
|
1704
|
+
rightPageSpec: wfRightPageSpec,
|
|
1705
|
+
typeBuckets: wfTypeBuckets,
|
|
1706
|
+
typeSides: wfTypeSides,
|
|
1707
|
+
typeOfPath: wfTypeOfPath,
|
|
1708
|
+
typeBadge: wfTypeBadge,
|
|
1709
|
+
typeSide: wfTypeSide,
|
|
1710
|
+
rightKinds: WF_RIGHT_KINDS,
|
|
1711
|
+
renderStepColumn: wfRenderStepColumn,
|
|
1712
|
+
renderProgressDock: wfRenderProgressDock,
|
|
1713
|
+
progressLabel: wfProgressLabel,
|
|
1714
|
+
progressFillWidth: wfProgressFillWidth,
|
|
1715
|
+
// the second line's arithmetic and its duration formatter.
|
|
1716
|
+
progressTiming: wfProgressTiming,
|
|
1717
|
+
progressDurationText: wfProgressDurationText,
|
|
1718
|
+
deriveStatus: wfDeriveStepStatus,
|
|
1719
|
+
applyDerivedStatuses: wfApplyDerivedStatuses,
|
|
1720
|
+
renderPanel: wfRenderPanel,
|
|
1721
|
+
// the conversation-view seat's marker wrapper (the rule that hides the HOST composer is
|
|
1722
|
+
// keyed on it, so both halves of that contract are driven here).
|
|
1723
|
+
viewSeatRoot: wfViewSeatRoot,
|
|
1724
|
+
viewSeatMarker: WF_VIEW_SEAT_MARKER,
|
|
1725
|
+
// the inject column — the state machine, the request and the renderer.
|
|
1726
|
+
injectState: wfInjectState,
|
|
1727
|
+
injectCanSend: wfInjectCanSend,
|
|
1728
|
+
injectText: wfInjectText,
|
|
1729
|
+
injectOutcome: wfInjectOutcome,
|
|
1730
|
+
injectAckDone: wfInjectAckDone,
|
|
1731
|
+
injectCounter: wfInjectCounter,
|
|
1732
|
+
postSteer: wfPostSteer,
|
|
1733
|
+
// the revoke half of the same column — its state machine, its request and the
|
|
1734
|
+
// host's-answer → outcome rule.
|
|
1735
|
+
revokeState: wfRevokeState,
|
|
1736
|
+
revokeOutcome: wfRevokeOutcome,
|
|
1737
|
+
postUnsteer: wfPostUnsteer,
|
|
1738
|
+
// the history shaping and the "needs confirm" derivation.
|
|
1739
|
+
injectRows: wfInjectRows,
|
|
1740
|
+
confirmRows: wfConfirmRows,
|
|
1741
|
+
confirmTitle: wfConfirmTitle,
|
|
1742
|
+
renderHotInject: wfRenderHotInject,
|
|
1743
|
+
// the persistent dock, its renderer and its two pure inputs.
|
|
1744
|
+
renderInjectDock: wfRenderInjectDock,
|
|
1745
|
+
pendingInjectCount: wfPendingInjectCount,
|
|
1746
|
+
injectStatusKey: wfInjectStatusKey,
|
|
1747
|
+
canonicalStatus: wfCanonicalStatus,
|
|
1748
|
+
normalizeStepTitle: wfNormalizeStepTitle,
|
|
1749
|
+
renderFlowColumn: wfRenderFlowColumn,
|
|
1750
|
+
renderNodeCard: wfRenderNodeCard,
|
|
1751
|
+
flowGroups: wfFlowGroups,
|
|
1752
|
+
flowItems: wfFlowItems,
|
|
1753
|
+
flowWindow: wfFlowWindow,
|
|
1754
|
+
flowNodeCount: wfFlowNodeCount,
|
|
1755
|
+
flowUiFor: wfFlowUiFor,
|
|
1756
|
+
flowToggleGroup: wfFlowToggleGroup,
|
|
1757
|
+
flowExpandAll: wfFlowExpandAll,
|
|
1758
|
+
flowSelectNode: wfFlowSelectNode,
|
|
1759
|
+
flowClearSelection: wfFlowClearSelection,
|
|
1760
|
+
flowToggleBranch: wfFlowToggleBranch,
|
|
1761
|
+
// the chain's branch derivation, its two halves (stroke path + the round icon), the
|
|
1762
|
+
// opened category view and the per-step toggle that drives it.
|
|
1763
|
+
flowToggleChain: wfFlowToggleChain,
|
|
1764
|
+
chainBranches: wfChainBranches,
|
|
1765
|
+
chainActionKind: wfChainActionKind,
|
|
1766
|
+
chainStrokePath: wfChainStrokePath,
|
|
1767
|
+
chainDetailNode: wfChainDetailNode,
|
|
1768
|
+
renderChainBranch: wfRenderChainBranch,
|
|
1769
|
+
renderChainDetail: wfRenderChainDetail,
|
|
1770
|
+
chainCurve: wfChainCurve,
|
|
1771
|
+
chainGeometry: wfChainGeometry,
|
|
1772
|
+
chainApply: wfChainApply,
|
|
1773
|
+
chainCards: wfChainCards,
|
|
1774
|
+
renderChain: wfRenderChain,
|
|
1775
|
+
renderBranches: wfRenderBranches,
|
|
1776
|
+
branchHue: wfBranchHue,
|
|
1777
|
+
branchStatus: wfBranchStatus,
|
|
1778
|
+
branchBadgeKey: wfBranchBadgeKey,
|
|
1779
|
+
branchRowStatus: wfBranchRowStatus,
|
|
1780
|
+
branchIsCollapsed: wfBranchIsCollapsed,
|
|
1781
|
+
nodeCategory: wfNodeCategory,
|
|
1782
|
+
nodeTitleParts: wfNodeTitleParts,
|
|
1783
|
+
nodeStatus: wfNodeStatus,
|
|
1784
|
+
nodeMetaBits: wfNodeMetaBits,
|
|
1785
|
+
// node detail, step detail, the clipboard and the two document bindings.
|
|
1786
|
+
renderNodeDetail: wfRenderNodeDetail,
|
|
1787
|
+
renderStepDetail: wfRenderStepDetail,
|
|
1788
|
+
renderStepHead: wfRenderStepHead,
|
|
1789
|
+
stepDetailOpen: wfStepDetailOpen,
|
|
1790
|
+
stepDetailToggle: wfStepDetailToggle,
|
|
1791
|
+
stepDetailOpenFor: wfStepDetailOpenFor,
|
|
1792
|
+
// the ONE disclosure triangle (a real <button>) and its glyph.
|
|
1793
|
+
tri: wfTri,
|
|
1794
|
+
triGlyph: wfTriGlyph,
|
|
1795
|
+
detailCategory: wfDetailCategory,
|
|
1796
|
+
detailAction: wfDetailAction,
|
|
1797
|
+
detailPath: wfDetailPath,
|
|
1798
|
+
detailFileGroups: wfDetailFileGroups,
|
|
1799
|
+
copyText: wfCopyText,
|
|
1800
|
+
copyFallback: wfCopyFallback,
|
|
1801
|
+
copyWithFeedback: wfCopyWithFeedback,
|
|
1802
|
+
setCopyState: wfSetCopyState,
|
|
1803
|
+
escapeIntent: wfEscapeIntent,
|
|
1804
|
+
bindEscape: wfBindEscape,
|
|
1805
|
+
bindDetailNudge: wfBindDetailNudge,
|
|
1806
|
+
stepReport: wfStepReport,
|
|
1807
|
+
buildStepDetail: wfBuildStepDetail,
|
|
1808
|
+
formatDuration: wfFormatDuration,
|
|
1809
|
+
formatClock: wfFormatClock,
|
|
1810
|
+
makeSay: wfMakeSay,
|
|
1811
|
+
sessionIdFromSnapshot: wfSessionIdFromSnapshot,
|
|
1812
|
+
pollUrl: wfPollUrl,
|
|
1813
|
+
pollOnce: wfPollOnce,
|
|
1814
|
+
parsePrefs: wfParsePrefs,
|
|
1815
|
+
sanitizePrefs: wfSanitizePrefs,
|
|
1816
|
+
applyPrefsPatch: wfApplyPrefsPatch,
|
|
1817
|
+
loadPrefs: wfLoadPrefs,
|
|
1818
|
+
savePrefs: wfSavePrefs,
|
|
1819
|
+
defaultPrefs: wfDefaultPrefs,
|
|
1820
|
+
// settings, snapshot construction and the two export deliveries.
|
|
1821
|
+
renderSettings: wfRenderSettings,
|
|
1822
|
+
defaultSettings: wfDefaultSettings,
|
|
1823
|
+
sanitizeSettings: wfSanitizeSettings,
|
|
1824
|
+
settingsOf: wfSettingsOf,
|
|
1825
|
+
settingOptionLabel: wfSettingOptionLabel,
|
|
1826
|
+
buildSnapshot: wfBuildSnapshot,
|
|
1827
|
+
snapshotMarkdown: wfSnapshotMarkdown,
|
|
1828
|
+
snapshotJson: wfSnapshotJson,
|
|
1829
|
+
snapshotText: wfSnapshotText,
|
|
1830
|
+
snapshotFileName: wfSnapshotFileName,
|
|
1831
|
+
snapshotTag: wfSnapshotTag,
|
|
1832
|
+
copySnapshot: wfCopySnapshot,
|
|
1833
|
+
snapshotDownload: wfSnapshotDownload,
|
|
1834
|
+
releaseSnapshotUrls: wfReleaseSnapshotUrls,
|
|
1835
|
+
snapshotUrlCount: wfSnapshotUrlCount,
|
|
1836
|
+
// the living layer, its geometry and the two attributes it reads.
|
|
1837
|
+
renderFlowEffects: wfRenderFlowEffects,
|
|
1838
|
+
fxCurve: wfFxCurve,
|
|
1839
|
+
fxGeometry: wfFxGeometry,
|
|
1840
|
+
fxBounds: wfFxBounds,
|
|
1841
|
+
fxApply: wfFxApply,
|
|
1842
|
+
fxBind: wfFxBind,
|
|
1843
|
+
fxSignature: wfFxSignature,
|
|
1844
|
+
fxQuery: wfFxQuery,
|
|
1845
|
+
motionMode: wfMotionMode,
|
|
1846
|
+
sessionRunning: wfSessionRunning,
|
|
1847
|
+
renderLive: wfRenderLive,
|
|
1848
|
+
css: function () {
|
|
1849
|
+
return WF_CSS;
|
|
1850
|
+
},
|
|
1851
|
+
dicts: function () {
|
|
1852
|
+
return { zh: DICT_ZH, en: DICT_EN };
|
|
1853
|
+
},
|
|
1854
|
+
constants: function () {
|
|
1855
|
+
return {
|
|
1856
|
+
styleId: WF_STYLE_ID,
|
|
1857
|
+
prefsKey: WF_PREFS_KEY,
|
|
1858
|
+
pollVisible: WF_POLL_VISIBLE,
|
|
1859
|
+
pollHidden: WF_POLL_HIDDEN,
|
|
1860
|
+
defaultWidth: WF_DEFAULT_WIDTH,
|
|
1861
|
+
zIndex: WF_Z_INDEX,
|
|
1862
|
+
stepGap: WF_STEP_GAP,
|
|
1863
|
+
stepRail: WF_STEP_RAIL,
|
|
1864
|
+
stepLink: WF_STEP_LINK,
|
|
1865
|
+
stepMany: WF_STEP_MANY,
|
|
1866
|
+
flowCap: WF_FLOW_CAP,
|
|
1867
|
+
branchShown: WF_BRANCH_SHOWN,
|
|
1868
|
+
branchLink: WF_BRANCH_LINK,
|
|
1869
|
+
groupMin: WF_GROUP_MIN,
|
|
1870
|
+
fxRainbow: WF_FX_RAINBOW,
|
|
1871
|
+
fxBrand: WF_FX_BRAND,
|
|
1872
|
+
curveMode: WF_CURVE_MODE,
|
|
1873
|
+
fxStroke: WF_FX_STROKE,
|
|
1874
|
+
fxGlow: WF_FX_GLOW,
|
|
1875
|
+
fxDash: WF_FX_DASH,
|
|
1876
|
+
fxSpeed: WF_FX_SPEED,
|
|
1877
|
+
livePeriod: WF_LIVE_PERIOD,
|
|
1878
|
+
pulsePeriod: WF_PULSE_PERIOD,
|
|
1879
|
+
pulseMs: WF_PULSE_MS,
|
|
1880
|
+
idleAfterMs: WF_IDLE_AFTER_MS,
|
|
1881
|
+
fxMinGap: WF_FX_MIN_GAP,
|
|
1882
|
+
// the chain's distances, so the self-test can assert the >= 28 px air, the 20–24 px
|
|
1883
|
+
// icon, and the fact that the link never overflows the gap it is drawn in.
|
|
1884
|
+
chainGap: WF_CHAIN_GAP,
|
|
1885
|
+
chainLink: WF_CHAIN_LINK,
|
|
1886
|
+
chainPad: WF_CHAIN_PAD,
|
|
1887
|
+
chainBranch: WF_CHAIN_BRANCH,
|
|
1888
|
+
chainIcon: WF_CHAIN_ICON,
|
|
1889
|
+
chainRing: WF_CHAIN_RING,
|
|
1890
|
+
chainKinds: WF_CHAIN_KINDS,
|
|
1891
|
+
chainSlots: WF_CHAIN_LINK_SLOTS,
|
|
1892
|
+
liveDots: WF_LIVE_DOTS,
|
|
1893
|
+
palettes: WF_PALETTES,
|
|
1894
|
+
motions: WF_MOTIONS,
|
|
1895
|
+
detailModes: WF_DETAIL_MODES,
|
|
1896
|
+
nodeLimits: WF_NODE_LIMITS,
|
|
1897
|
+
snapshotSchema: WF_SNAPSHOT_SCHEMA,
|
|
1898
|
+
// the single page's distances, the right page's kinds and its slot budget, so the
|
|
1899
|
+
// self-test asserts the SAME numbers the stylesheet is built from.
|
|
1900
|
+
pagePad: WF_PAGE_PAD,
|
|
1901
|
+
satellite: WF_SATELLITE,
|
|
1902
|
+
satLink: WF_SAT_LINK,
|
|
1903
|
+
unitGap: WF_UNIT_GAP,
|
|
1904
|
+
unitLink: WF_UNIT_LINK,
|
|
1905
|
+
sideSlots: WF_SIDE_SLOTS,
|
|
1906
|
+
sideFiles: WF_SIDE_FILES,
|
|
1907
|
+
rightKinds: WF_RIGHT_KINDS,
|
|
1908
|
+
};
|
|
1909
|
+
},
|
|
1910
|
+
component: WorkflowMapPanel,
|
|
1911
|
+
};
|