@xdelivered/emberflow 0.2.0

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.
Files changed (414) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/bin/commands.test.ts +185 -0
  4. package/bin/commands.ts +306 -0
  5. package/bin/emberflow.mjs +38 -0
  6. package/bin/init.test.ts +165 -0
  7. package/bin/init.ts +297 -0
  8. package/bin/runtime.mjs +105 -0
  9. package/bin/runtime.test.ts +167 -0
  10. package/bin/skillTargets.test.ts +108 -0
  11. package/bin/skillTargets.ts +36 -0
  12. package/bin/skillTemplates.test.ts +37 -0
  13. package/bin/tsconfig.json +23 -0
  14. package/dist/bin/commands.d.ts +24 -0
  15. package/dist/bin/commands.js +252 -0
  16. package/dist/bin/init.d.ts +17 -0
  17. package/dist/bin/init.js +241 -0
  18. package/dist/bin/runtime.d.mts +39 -0
  19. package/dist/bin/runtime.mjs +96 -0
  20. package/dist/bin/skillTargets.d.ts +13 -0
  21. package/dist/bin/skillTargets.js +22 -0
  22. package/dist/server/agents/claudeAdapter.d.ts +8 -0
  23. package/dist/server/agents/claudeAdapter.js +190 -0
  24. package/dist/server/agents/claudeParse.d.ts +2 -0
  25. package/dist/server/agents/claudeParse.js +162 -0
  26. package/dist/server/agents/codexAdapter.d.ts +13 -0
  27. package/dist/server/agents/codexAdapter.js +174 -0
  28. package/dist/server/agents/codexParse.d.ts +2 -0
  29. package/dist/server/agents/codexParse.js +74 -0
  30. package/dist/server/agents/detect.d.ts +23 -0
  31. package/dist/server/agents/detect.js +35 -0
  32. package/dist/server/agents/gitScope.d.ts +24 -0
  33. package/dist/server/agents/gitScope.js +95 -0
  34. package/dist/server/agents/modelRejectionHint.d.ts +8 -0
  35. package/dist/server/agents/modelRejectionHint.js +12 -0
  36. package/dist/server/agents/prompt.d.ts +79 -0
  37. package/dist/server/agents/prompt.js +364 -0
  38. package/dist/server/agents/runManager.d.ts +90 -0
  39. package/dist/server/agents/runManager.js +259 -0
  40. package/dist/server/agents/types.d.ts +15 -0
  41. package/dist/server/agents/types.js +1 -0
  42. package/dist/server/apiStore.d.ts +60 -0
  43. package/dist/server/apiStore.js +249 -0
  44. package/dist/server/auth/enforce.d.ts +1 -0
  45. package/dist/server/auth/enforce.js +6 -0
  46. package/dist/server/auth/types.d.ts +1 -0
  47. package/dist/server/auth/types.js +1 -0
  48. package/dist/server/auth/verifiers.d.ts +1 -0
  49. package/dist/server/auth/verifiers.js +4 -0
  50. package/dist/server/authAttach.d.ts +19 -0
  51. package/dist/server/authAttach.js +79 -0
  52. package/dist/server/cli.d.ts +7 -0
  53. package/dist/server/cli.js +804 -0
  54. package/dist/server/client.d.ts +130 -0
  55. package/dist/server/client.js +247 -0
  56. package/dist/server/environments.d.ts +103 -0
  57. package/dist/server/environments.js +486 -0
  58. package/dist/server/flowStore.d.ts +37 -0
  59. package/dist/server/flowStore.js +117 -0
  60. package/dist/server/httpOperations.d.ts +32 -0
  61. package/dist/server/httpOperations.js +62 -0
  62. package/dist/server/index.d.ts +1 -0
  63. package/dist/server/index.js +1022 -0
  64. package/dist/server/infrastructure.d.ts +51 -0
  65. package/dist/server/infrastructure.js +116 -0
  66. package/dist/server/login.d.ts +21 -0
  67. package/dist/server/login.js +44 -0
  68. package/dist/server/mcp.d.ts +1 -0
  69. package/dist/server/mcp.js +182 -0
  70. package/dist/server/migrateFlows.d.ts +8 -0
  71. package/dist/server/migrateFlows.js +25 -0
  72. package/dist/server/mockHandler.d.ts +15 -0
  73. package/dist/server/mockHandler.js +35 -0
  74. package/dist/server/nodesPayload.d.ts +14 -0
  75. package/dist/server/nodesPayload.js +14 -0
  76. package/dist/server/normalizeFlow.d.ts +21 -0
  77. package/dist/server/normalizeFlow.js +48 -0
  78. package/dist/server/openBrowser.d.ts +8 -0
  79. package/dist/server/openBrowser.js +16 -0
  80. package/dist/server/operationResult.d.ts +12 -0
  81. package/dist/server/operationResult.js +26 -0
  82. package/dist/server/pathGuard.d.ts +1 -0
  83. package/dist/server/pathGuard.js +16 -0
  84. package/dist/server/pathSafety.d.ts +20 -0
  85. package/dist/server/pathSafety.js +35 -0
  86. package/dist/server/projectConfig.d.ts +47 -0
  87. package/dist/server/projectConfig.js +61 -0
  88. package/dist/server/projectMode.d.ts +26 -0
  89. package/dist/server/projectMode.js +44 -0
  90. package/dist/server/redact.d.ts +6 -0
  91. package/dist/server/redact.js +37 -0
  92. package/dist/server/runRegistry.d.ts +84 -0
  93. package/dist/server/runRegistry.js +200 -0
  94. package/dist/server/runtime/RuntimeAdapter.d.ts +22 -0
  95. package/dist/server/runtime/RuntimeAdapter.js +1 -0
  96. package/dist/server/runtime/expressAdapter.d.ts +19 -0
  97. package/dist/server/runtime/expressAdapter.js +56 -0
  98. package/dist/server/scenarioTest.d.ts +14 -0
  99. package/dist/server/scenarioTest.js +66 -0
  100. package/dist/server/subflowRunner.d.ts +32 -0
  101. package/dist/server/subflowRunner.js +72 -0
  102. package/dist/server/testRunner.d.ts +70 -0
  103. package/dist/server/testRunner.js +151 -0
  104. package/dist/src/config.d.ts +3 -0
  105. package/dist/src/config.js +2 -0
  106. package/dist/src/engine/authVerify.d.ts +33 -0
  107. package/dist/src/engine/authVerify.js +63 -0
  108. package/dist/src/engine/diagnostics.d.ts +62 -0
  109. package/dist/src/engine/diagnostics.js +148 -0
  110. package/dist/src/engine/executor.d.ts +151 -0
  111. package/dist/src/engine/executor.js +684 -0
  112. package/dist/src/engine/httpError.d.ts +7 -0
  113. package/dist/src/engine/httpError.js +14 -0
  114. package/dist/src/engine/index.d.ts +11 -0
  115. package/dist/src/engine/index.js +11 -0
  116. package/dist/src/engine/output.d.ts +11 -0
  117. package/dist/src/engine/output.js +20 -0
  118. package/dist/src/engine/publish.d.ts +30 -0
  119. package/dist/src/engine/publish.js +95 -0
  120. package/dist/src/engine/registry.d.ts +34 -0
  121. package/dist/src/engine/registry.js +67 -0
  122. package/dist/src/engine/scenarios.d.ts +12 -0
  123. package/dist/src/engine/scenarios.js +19 -0
  124. package/dist/src/engine/schemaCheck.d.ts +4 -0
  125. package/dist/src/engine/schemaCheck.js +34 -0
  126. package/dist/src/engine/trace.d.ts +12 -0
  127. package/dist/src/engine/trace.js +12 -0
  128. package/dist/src/engine/types.d.ts +308 -0
  129. package/dist/src/engine/types.js +1 -0
  130. package/dist/src/engine/validation.d.ts +26 -0
  131. package/dist/src/engine/validation.js +318 -0
  132. package/dist/src/flows/anomaly-flows.d.ts +23 -0
  133. package/dist/src/flows/anomaly-flows.js +151 -0
  134. package/dist/src/flows/city-sweep-flow.d.ts +10 -0
  135. package/dist/src/flows/city-sweep-flow.js +127 -0
  136. package/dist/src/flows/login-flow.d.ts +13 -0
  137. package/dist/src/flows/login-flow.js +145 -0
  138. package/dist/src/flows/pradar-flows.d.ts +5 -0
  139. package/dist/src/flows/pradar-flows.js +375 -0
  140. package/dist/src/flows/weather-flow.d.ts +12 -0
  141. package/dist/src/flows/weather-flow.js +150 -0
  142. package/dist/src/lib/pathParams.d.ts +3 -0
  143. package/dist/src/lib/pathParams.js +6 -0
  144. package/dist/src/nodes/anomaly.d.ts +2 -0
  145. package/dist/src/nodes/anomaly.js +556 -0
  146. package/dist/src/nodes/flow-control.d.ts +7 -0
  147. package/dist/src/nodes/flow-control.js +270 -0
  148. package/dist/src/nodes/index.d.ts +3 -0
  149. package/dist/src/nodes/index.js +20 -0
  150. package/dist/src/nodes/login.d.ts +7 -0
  151. package/dist/src/nodes/login.js +158 -0
  152. package/dist/src/nodes/loops.d.ts +11 -0
  153. package/dist/src/nodes/loops.js +60 -0
  154. package/dist/src/nodes/pradar/index.d.ts +7 -0
  155. package/dist/src/nodes/pradar/index.js +9 -0
  156. package/dist/src/nodes/pradar/logic.d.ts +189 -0
  157. package/dist/src/nodes/pradar/logic.js +370 -0
  158. package/dist/src/nodes/pradar/nodes.d.ts +42 -0
  159. package/dist/src/nodes/pradar/nodes.js +801 -0
  160. package/dist/src/nodes/requireAuth.d.ts +20 -0
  161. package/dist/src/nodes/requireAuth.js +48 -0
  162. package/dist/src/nodes/response.d.ts +8 -0
  163. package/dist/src/nodes/response.js +33 -0
  164. package/dist/src/nodes/weather.d.ts +33 -0
  165. package/dist/src/nodes/weather.js +260 -0
  166. package/package.json +92 -0
  167. package/server/agentRoute.test.ts +157 -0
  168. package/server/agents/__fixtures__/fake-claude-writer.mjs +23 -0
  169. package/server/agents/__fixtures__/fake-claude.mjs +55 -0
  170. package/server/agents/__fixtures__/fake-codex-writer.mjs +27 -0
  171. package/server/agents/__fixtures__/fake-codex.mjs +52 -0
  172. package/server/agents/__fixtures__/fake-version-empty.mjs +3 -0
  173. package/server/agents/__fixtures__/fake-version-fail.mjs +4 -0
  174. package/server/agents/__fixtures__/fake-version-garbage.mjs +4 -0
  175. package/server/agents/__fixtures__/fake-version-good.mjs +4 -0
  176. package/server/agents/__fixtures__/fake-version-hang.mjs +3 -0
  177. package/server/agents/claudeAdapter.test.ts +127 -0
  178. package/server/agents/claudeAdapter.ts +212 -0
  179. package/server/agents/claudeParse.ts +193 -0
  180. package/server/agents/codexAdapter.test.ts +125 -0
  181. package/server/agents/codexAdapter.ts +198 -0
  182. package/server/agents/codexParse.test.ts +58 -0
  183. package/server/agents/codexParse.ts +126 -0
  184. package/server/agents/detect.test.ts +65 -0
  185. package/server/agents/detect.ts +43 -0
  186. package/server/agents/gitScope.test.ts +129 -0
  187. package/server/agents/gitScope.ts +110 -0
  188. package/server/agents/modelRejectionHint.test.ts +30 -0
  189. package/server/agents/modelRejectionHint.ts +14 -0
  190. package/server/agents/prompt.test.ts +613 -0
  191. package/server/agents/prompt.ts +527 -0
  192. package/server/agents/runManager.test.ts +251 -0
  193. package/server/agents/runManager.ts +286 -0
  194. package/server/agents/types.ts +17 -0
  195. package/server/apiMount.test.ts +591 -0
  196. package/server/apiStore.test.ts +324 -0
  197. package/server/apiStore.ts +241 -0
  198. package/server/auth/enforce.test.ts +61 -0
  199. package/server/auth/enforce.ts +6 -0
  200. package/server/auth/types.ts +5 -0
  201. package/server/auth/verifiers.test.ts +37 -0
  202. package/server/auth/verifiers.ts +9 -0
  203. package/server/authAttach.test.ts +175 -0
  204. package/server/authAttach.ts +99 -0
  205. package/server/cli.ts +847 -0
  206. package/server/cliAuth.test.ts +229 -0
  207. package/server/cliCreate.test.ts +131 -0
  208. package/server/cliDoctor.test.ts +326 -0
  209. package/server/cliTest.test.ts +735 -0
  210. package/server/client.ts +314 -0
  211. package/server/demoProject.test.ts +29 -0
  212. package/server/diagnosticsRoute.test.ts +169 -0
  213. package/server/environments.test.ts +620 -0
  214. package/server/environments.ts +603 -0
  215. package/server/environmentsRoute.test.ts +385 -0
  216. package/server/errorWorkflow.test.ts +205 -0
  217. package/server/flowStore.test.ts +84 -0
  218. package/server/flowStore.ts +125 -0
  219. package/server/httpOperations.test.ts +377 -0
  220. package/server/httpOperations.ts +96 -0
  221. package/server/index.ts +1111 -0
  222. package/server/infrastructure.test.ts +154 -0
  223. package/server/infrastructure.ts +161 -0
  224. package/server/infrastructureRoute.test.ts +105 -0
  225. package/server/login.test.ts +153 -0
  226. package/server/login.ts +67 -0
  227. package/server/mcp.ts +278 -0
  228. package/server/migrateFlows.test.ts +55 -0
  229. package/server/migrateFlows.ts +23 -0
  230. package/server/mockHandler.test.ts +138 -0
  231. package/server/mockHandler.ts +52 -0
  232. package/server/mockRun.test.ts +367 -0
  233. package/server/mockServe.test.ts +467 -0
  234. package/server/nodesPayload.test.ts +29 -0
  235. package/server/nodesPayload.ts +26 -0
  236. package/server/normalizeFlow.test.ts +80 -0
  237. package/server/normalizeFlow.ts +59 -0
  238. package/server/openBrowser.test.ts +20 -0
  239. package/server/openBrowser.ts +22 -0
  240. package/server/operationResult.test.ts +67 -0
  241. package/server/operationResult.ts +27 -0
  242. package/server/pathGuard.test.ts +19 -0
  243. package/server/pathGuard.ts +12 -0
  244. package/server/pathSafety.test.ts +38 -0
  245. package/server/pathSafety.ts +33 -0
  246. package/server/projectConfig.test.ts +67 -0
  247. package/server/projectConfig.ts +101 -0
  248. package/server/projectMode.test.ts +106 -0
  249. package/server/projectMode.ts +59 -0
  250. package/server/redact.test.ts +88 -0
  251. package/server/redact.ts +35 -0
  252. package/server/redactEgress.test.ts +281 -0
  253. package/server/runRegistry.ts +270 -0
  254. package/server/runsAuth.test.ts +119 -0
  255. package/server/runtime/RuntimeAdapter.ts +24 -0
  256. package/server/runtime/expressAdapter.test.ts +38 -0
  257. package/server/runtime/expressAdapter.ts +70 -0
  258. package/server/scenarioTest.test.ts +148 -0
  259. package/server/scenarioTest.ts +88 -0
  260. package/server/setupStatus.test.ts +127 -0
  261. package/server/subflowRunner.ts +104 -0
  262. package/server/testRunner.ts +216 -0
  263. package/server/tsconfig.json +20 -0
  264. package/server/workflowTestRoute.test.ts +297 -0
  265. package/src/App.tsx +233 -0
  266. package/src/components/AgentConsole.tsx +461 -0
  267. package/src/components/BranchRulesEditor.tsx +156 -0
  268. package/src/components/CreateModal.tsx +275 -0
  269. package/src/components/Dock.tsx +487 -0
  270. package/src/components/EnvironmentDialog.tsx +569 -0
  271. package/src/components/EnvironmentPicker.tsx +357 -0
  272. package/src/components/EnvironmentsDialog.tsx +144 -0
  273. package/src/components/ExecutionPager.tsx +67 -0
  274. package/src/components/InfraPanel.test.tsx +103 -0
  275. package/src/components/InfraPanel.tsx +171 -0
  276. package/src/components/Inspector.tsx +1182 -0
  277. package/src/components/Json.tsx +50 -0
  278. package/src/components/JsonModal.tsx +52 -0
  279. package/src/components/LogMessage.tsx +61 -0
  280. package/src/components/NodeRunModal.tsx +213 -0
  281. package/src/components/RunLogPanel.tsx +397 -0
  282. package/src/components/RunbookView.tsx +1085 -0
  283. package/src/components/Scenarios.tsx +476 -0
  284. package/src/components/SettingsDialog.tsx +229 -0
  285. package/src/components/Sidebar.tsx +436 -0
  286. package/src/components/StatusBar.tsx +302 -0
  287. package/src/components/Toolbar.tsx +663 -0
  288. package/src/components/WelcomeDialog.test.tsx +107 -0
  289. package/src/components/WelcomeDialog.tsx +412 -0
  290. package/src/components/agentMarkdown.test.tsx +91 -0
  291. package/src/components/agentMarkdown.tsx +158 -0
  292. package/src/components/ui/badge.tsx +31 -0
  293. package/src/components/ui/button.tsx +46 -0
  294. package/src/components/ui/combobox.tsx +114 -0
  295. package/src/components/ui/command.tsx +82 -0
  296. package/src/components/ui/dialog.tsx +73 -0
  297. package/src/components/ui/input.tsx +20 -0
  298. package/src/components/ui/popover.tsx +32 -0
  299. package/src/components/ui/resizable.tsx +44 -0
  300. package/src/components/ui/tabs.tsx +36 -0
  301. package/src/config.ts +4 -0
  302. package/src/engine/authVerify.ts +81 -0
  303. package/src/engine/diagnostics.test.ts +437 -0
  304. package/src/engine/diagnostics.ts +211 -0
  305. package/src/engine/executor.test.ts +1339 -0
  306. package/src/engine/executor.ts +840 -0
  307. package/src/engine/httpError.test.ts +12 -0
  308. package/src/engine/httpError.ts +16 -0
  309. package/src/engine/index.ts +11 -0
  310. package/src/engine/output.ts +21 -0
  311. package/src/engine/publish.test.ts +176 -0
  312. package/src/engine/publish.ts +127 -0
  313. package/src/engine/registry.test.ts +64 -0
  314. package/src/engine/registry.ts +80 -0
  315. package/src/engine/scenarios.test.ts +55 -0
  316. package/src/engine/scenarios.ts +29 -0
  317. package/src/engine/schemaCheck.test.ts +21 -0
  318. package/src/engine/schemaCheck.ts +35 -0
  319. package/src/engine/trace.test.ts +19 -0
  320. package/src/engine/trace.ts +23 -0
  321. package/src/engine/types.test.ts +22 -0
  322. package/src/engine/types.ts +316 -0
  323. package/src/engine/validation.test.ts +314 -0
  324. package/src/engine/validation.ts +354 -0
  325. package/src/flows/anomaly-flows.test.ts +81 -0
  326. package/src/flows/anomaly-flows.ts +202 -0
  327. package/src/flows/city-sweep-flow.ts +131 -0
  328. package/src/flows/flows.test.ts +30 -0
  329. package/src/flows/login-flow.ts +150 -0
  330. package/src/flows/pradar-flows.test.ts +204 -0
  331. package/src/flows/pradar-flows.ts +419 -0
  332. package/src/flows/weather-flow.ts +154 -0
  333. package/src/globals.css +147 -0
  334. package/src/lib/pathParams.test.ts +24 -0
  335. package/src/lib/pathParams.ts +6 -0
  336. package/src/lib/registerLens.test.ts +79 -0
  337. package/src/lib/registerLens.ts +59 -0
  338. package/src/lib/runbookModel.test.ts +61 -0
  339. package/src/lib/runbookModel.ts +290 -0
  340. package/src/lib/runbookProjection.test.ts +417 -0
  341. package/src/lib/runbookProjection.ts +254 -0
  342. package/src/lib/useTailAnchor.ts +53 -0
  343. package/src/lib/utils.ts +7 -0
  344. package/src/main.tsx +13 -0
  345. package/src/nodes/anomaly.test.ts +98 -0
  346. package/src/nodes/anomaly.ts +637 -0
  347. package/src/nodes/effects-scan.test.ts +44 -0
  348. package/src/nodes/flow-control.test.ts +280 -0
  349. package/src/nodes/flow-control.ts +305 -0
  350. package/src/nodes/index.ts +22 -0
  351. package/src/nodes/login.test.ts +81 -0
  352. package/src/nodes/login.ts +186 -0
  353. package/src/nodes/loops.ts +69 -0
  354. package/src/nodes/pradar/index.ts +11 -0
  355. package/src/nodes/pradar/logic.test.ts +288 -0
  356. package/src/nodes/pradar/logic.ts +582 -0
  357. package/src/nodes/pradar/nodes.test.ts +146 -0
  358. package/src/nodes/pradar/nodes.ts +955 -0
  359. package/src/nodes/requireAuth.test.ts +75 -0
  360. package/src/nodes/requireAuth.ts +55 -0
  361. package/src/nodes/response.test.ts +33 -0
  362. package/src/nodes/response.ts +39 -0
  363. package/src/nodes/traceDetail.test.ts +39 -0
  364. package/src/nodes/weather.test.ts +65 -0
  365. package/src/nodes/weather.ts +306 -0
  366. package/src/store/agentClient.test.ts +103 -0
  367. package/src/store/agentClient.ts +131 -0
  368. package/src/store/apiTree.test.ts +17 -0
  369. package/src/store/apiTree.ts +73 -0
  370. package/src/store/builderStore.test.ts +1234 -0
  371. package/src/store/builderStore.ts +1879 -0
  372. package/src/store/infraClient.ts +63 -0
  373. package/src/store/nodeMeta.test.ts +24 -0
  374. package/src/store/nodeMeta.ts +21 -0
  375. package/src/store/persistence.test.ts +15 -0
  376. package/src/store/persistence.ts +54 -0
  377. package/src/store/serverRunner.ts +381 -0
  378. package/src/store/setupClient.ts +57 -0
  379. package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
  380. package/studio-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
  381. package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
  382. package/studio-dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
  383. package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
  384. package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
  385. package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
  386. package/studio-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
  387. package/studio-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  388. package/studio-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  389. package/studio-dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  390. package/studio-dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  391. package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
  392. package/studio-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
  393. package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
  394. package/studio-dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
  395. package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
  396. package/studio-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
  397. package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
  398. package/studio-dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
  399. package/studio-dist/assets/index-DNJwW-hM.css +1 -0
  400. package/studio-dist/assets/index-O26dKRjW.js +65 -0
  401. package/studio-dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  402. package/studio-dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  403. package/studio-dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  404. package/studio-dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  405. package/studio-dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  406. package/studio-dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  407. package/studio-dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  408. package/studio-dist/favicon.svg +1 -0
  409. package/studio-dist/icons.svg +24 -0
  410. package/studio-dist/index.html +14 -0
  411. package/templates/skills/emberflow-basics/SKILL.md +316 -0
  412. package/templates/skills/emberflow-model-process/SKILL.md +209 -0
  413. package/templates/skills/emberflow-new-workflow/SKILL.md +211 -0
  414. package/templates/skills/emberflow-review-workflow/SKILL.md +148 -0
@@ -0,0 +1,1085 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import { ChevronRightIcon, ChevronsDownUpIcon, ChevronsUpDownIcon, SparklesIcon } from 'lucide-react';
3
+ import { Badge } from '@/components/ui/badge';
4
+ import { cn } from '@/lib/utils';
5
+ import { useBuilderStore } from '../store/builderStore';
6
+ import { Json } from './Json';
7
+ import { buildRunbook } from '../lib/runbookModel';
8
+ import type { RunbookBranchGroup, RunbookItem, RunbookLoopGroup, RunbookStep } from '../lib/runbookModel';
9
+ import { iterationSummary, projectRunbook } from '../lib/runbookProjection';
10
+ import type { RunbookProjection, StepVisualStatus } from '../lib/runbookProjection';
11
+ import type {WorkflowRun } from '../engine';
12
+
13
+ function formatDuration(ms: number): string {
14
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
15
+ }
16
+
17
+ /** Minimum gap between auto-scrolls — one deliberate move at a time, never a
18
+ * flurry. */
19
+ const SCROLL_MIN_GAP_MS = 1100;
20
+
21
+ /** Fade-out duration for outcome-text crossfades; the swap happens at the
22
+ * midpoint and the fade back in mirrors it, for a ~440ms round trip. */
23
+ const CROSSFADE_MS = 220;
24
+
25
+ /** Reveal floor between two terminal steps. A cheap flow finishes server-side
26
+ * in a single frame; releasing steps no faster than this turns that burst into
27
+ * a legible sequence you can actually read filling the document. Genuinely slow
28
+ * steps (LLM calls) already outlast the floor, so they never wait on it — the
29
+ * queue adds pacing only when the engine outran the eye. */
30
+ const STEP_REVEAL_MIN_MS = 350;
31
+
32
+ /** Per-iteration pace for a loop's mini-dots — a third of the step floor so a
33
+ * five-iteration loop fills in ~0.6s instead of stalling the document, but
34
+ * never faster than 120ms (below that the dots read as a flicker, not a fill). */
35
+ const LOOP_REVEAL_TICK_MS = Math.max(120, STEP_REVEAL_MIN_MS / 3);
36
+
37
+ interface PacedReveal {
38
+ /** Maps a step's real projection status to what the document should show: a
39
+ * terminal step waits at 'idle' until released, flashes 'active' as it is
40
+ * released, then settles to its real status. */
41
+ displayStatus: (nodeId: string, real: StepVisualStatus) => StepVisualStatus;
42
+ /** The step currently being released (rendered active), for scroll-follow;
43
+ * null when the queue is empty. */
44
+ activeDisplayId: string | null;
45
+ }
46
+
47
+ /**
48
+ * The paced-reveal queue. As steps reach a terminal status they are enqueued in
49
+ * document order and released one at a time at STEP_REVEAL_MIN_MS spacing; the
50
+ * released step renders 'active' (breathing) for one interval before settling.
51
+ * A terminal step that arrives alone once the floor has already elapsed (a slow
52
+ * node finishing, or a manual Step click between which the user paused far
53
+ * longer than the floor) is revealed immediately — the queue never adds latency
54
+ * on top of real latency. reduced-motion keeps the sequencing (it is
55
+ * information, not decoration); the breathing ring itself is stilled by CSS.
56
+ */
57
+ function usePacedReveal(orderedStepIds: string[], projection: RunbookProjection, runId: string | null): PacedReveal {
58
+ const [revealed, setRevealed] = useState<Set<string>>(new Set());
59
+ const [activeId, setActiveId] = useState<string | null>(null);
60
+
61
+ const queueRef = useRef<string[]>([]);
62
+ const enqueuedRef = useRef<Set<string>>(new Set());
63
+ const activeRef = useRef<string | null>(null);
64
+ const lastReleaseRef = useRef(0);
65
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
66
+ const runIdRef = useRef<string | null>(runId);
67
+
68
+ const drain = useRef<() => void>(() => {});
69
+ drain.current = () => {
70
+ timerRef.current = null;
71
+ const now = Date.now();
72
+ const wait = lastReleaseRef.current + STEP_REVEAL_MIN_MS - now;
73
+ if (wait > 0) {
74
+ timerRef.current = setTimeout(() => drain.current(), wait);
75
+ return;
76
+ }
77
+ // The step released last interval has now breathed long enough — settle it.
78
+ if (activeRef.current) {
79
+ const done = activeRef.current;
80
+ activeRef.current = null;
81
+ setRevealed((prev) => new Set(prev).add(done));
82
+ }
83
+ if (queueRef.current.length === 0) {
84
+ setActiveId(null);
85
+ return;
86
+ }
87
+ const next = queueRef.current.shift()!;
88
+ activeRef.current = next;
89
+ lastReleaseRef.current = now;
90
+ setActiveId(next);
91
+ timerRef.current = setTimeout(() => drain.current(), STEP_REVEAL_MIN_MS);
92
+ };
93
+
94
+ const schedule = () => {
95
+ if (timerRef.current) return; // already draining
96
+ if (queueRef.current.length === 0 && activeRef.current === null) return;
97
+ timerRef.current = setTimeout(() => drain.current(), 0);
98
+ };
99
+
100
+ useEffect(() => {
101
+ const now = Date.now();
102
+ // New run (or reset): forget the previous fill, and stamp the release clock
103
+ // to now so the first burst paces from step one rather than dumping instantly.
104
+ if (runId !== runIdRef.current) {
105
+ runIdRef.current = runId;
106
+ queueRef.current = [];
107
+ enqueuedRef.current = new Set();
108
+ activeRef.current = null;
109
+ lastReleaseRef.current = now;
110
+ if (timerRef.current) {
111
+ clearTimeout(timerRef.current);
112
+ timerRef.current = null;
113
+ }
114
+ setRevealed(new Set());
115
+ setActiveId(null);
116
+ }
117
+
118
+ for (const id of orderedStepIds) {
119
+ const st = projection.steps.get(id)?.status;
120
+ if ((st === 'ran' || st === 'failed') && !enqueuedRef.current.has(id)) {
121
+ enqueuedRef.current.add(id);
122
+ const idle = queueRef.current.length === 0 && activeRef.current === null;
123
+ if (idle && now - lastReleaseRef.current >= STEP_REVEAL_MIN_MS) {
124
+ // Straggler: floor already elapsed, reveal with no artificial flash.
125
+ lastReleaseRef.current = now;
126
+ setRevealed((prev) => new Set(prev).add(id));
127
+ } else {
128
+ queueRef.current.push(id);
129
+ }
130
+ }
131
+ }
132
+ schedule();
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ }, [projection, runId, orderedStepIds]);
135
+
136
+ useEffect(
137
+ () => () => {
138
+ if (timerRef.current) clearTimeout(timerRef.current);
139
+ },
140
+ [],
141
+ );
142
+
143
+ const displayStatus = (nodeId: string, real: StepVisualStatus): StepVisualStatus => {
144
+ if (activeId === nodeId) return 'active';
145
+ if (real === 'active') return 'active'; // engine is genuinely mid-flight here
146
+ if (real === 'ran' || real === 'failed') return revealed.has(nodeId) ? real : 'idle';
147
+ return real;
148
+ };
149
+
150
+ return { displayStatus, activeDisplayId: activeId };
151
+ }
152
+
153
+ /**
154
+ * Loop mini-dots fill at their own gentle pace: terminal iterations are
155
+ * revealed one tick at a time so a completed loop sweeps rather than snapping.
156
+ * Returns a display copy of `statuses` with not-yet-revealed terminal dots held
157
+ * at 'pending' and the one being revealed shown 'running'.
158
+ */
159
+ function usePacedLoopStatuses(
160
+ statuses: ('done' | 'failed' | 'pending' | 'running')[],
161
+ resetKey: string,
162
+ ): ('done' | 'failed' | 'pending' | 'running')[] {
163
+ const terminalIdx = useMemo(
164
+ () => statuses.map((s, i) => (s === 'done' || s === 'failed' ? i : -1)).filter((i) => i >= 0),
165
+ [statuses],
166
+ );
167
+ const [revealedCount, setRevealedCount] = useState(0);
168
+ const prevResetKey = useRef(resetKey);
169
+ if (resetKey !== prevResetKey.current) {
170
+ prevResetKey.current = resetKey;
171
+ // Reset synchronously during render so a fresh run never flashes the old fill.
172
+ if (revealedCount !== 0) setRevealedCount(0);
173
+ }
174
+
175
+ useEffect(() => {
176
+ if (revealedCount >= terminalIdx.length) return;
177
+ const t = setTimeout(() => setRevealedCount((c) => c + 1), LOOP_REVEAL_TICK_MS);
178
+ return () => clearTimeout(t);
179
+ }, [revealedCount, terminalIdx.length]);
180
+
181
+ const revealedSet = new Set(terminalIdx.slice(0, revealedCount));
182
+ const releasingIdx = terminalIdx[revealedCount];
183
+ return statuses.map((s, i) => {
184
+ if (s === 'running') return 'running';
185
+ if (s === 'done' || s === 'failed') {
186
+ if (revealedSet.has(i)) return s;
187
+ if (i === releasingIdx) return 'running';
188
+ return 'pending';
189
+ }
190
+ return s;
191
+ });
192
+ }
193
+
194
+ /** Flattens the runbook tree to its step node ids in document (render) order —
195
+ * the order terminal steps are released in. */
196
+ function flattenStepIds(items: RunbookItem[], out: string[] = []): string[] {
197
+ for (const item of items) {
198
+ if (item.kind === 'step') out.push(item.nodeId);
199
+ else flattenStepIds(item.items, out);
200
+ }
201
+ return out;
202
+ }
203
+
204
+ /** Crossfades text changes instead of snapping: fade the old text out, swap
205
+ * the displayed string once it's invisible, then let the caller's own
206
+ * transition fade it back in via the returned `fading` flag. */
207
+ function useCrossfadeText(text: string): { display: string; fading: boolean } {
208
+ const [display, setDisplay] = useState(text);
209
+ const [fading, setFading] = useState(false);
210
+
211
+ useEffect(() => {
212
+ if (text === display) return;
213
+ setFading(true);
214
+ const timeout = setTimeout(() => {
215
+ setDisplay(text);
216
+ setFading(false);
217
+ }, CROSSFADE_MS);
218
+ return () => clearTimeout(timeout);
219
+ // eslint-disable-next-line react-hooks/exhaustive-deps
220
+ }, [text]);
221
+
222
+ return { display, fading };
223
+ }
224
+
225
+ /** Shared read-only context threaded through the item tree — avoids re-deriving
226
+ * the same store values at every nesting level. */
227
+ interface RunbookCtx {
228
+ projection: RunbookProjection;
229
+ run: WorkflowRun | null;
230
+ /** Reset key for per-group local state (manual overrides, loop chip
231
+ * selection): `${flow.id}:${run?.id}`. Flow id is part of the key because
232
+ * this view isn't remounted per flow and group keys are plain node ids that
233
+ * collide across flows — without it, toggles/chips on flow A bleed into
234
+ * flow B whenever the run identity alone doesn't change. */
235
+ resetKey: string;
236
+ selectedNodeId: string | null;
237
+ selectNode: (id: string | null) => void;
238
+ /** Registers/unregisters a step row's DOM element for the scroll-band
239
+ * auto-scroll effect, keyed by nodeId. */
240
+ registerRow: (nodeId: string, el: HTMLElement | null) => void;
241
+ /** Selected loop iteration index for the nearest enclosing loop group, set
242
+ * by clicking a rail chip; undefined outside a loop or before any chip is
243
+ * clicked, in which case rows show the live projection as normal. */
244
+ loopIterIndex?: number;
245
+ /** Simple register renders outcomes only; technical also reveals type names. */
246
+ register: 'simple' | 'technical';
247
+ /** Present when this flow is an HTTP operation: the request contract, rendered
248
+ * in place of the entry Input node's normal row so step 1 reads as the route. */
249
+ request?: {
250
+ inputNodeId: string;
251
+ method: string;
252
+ path: string;
253
+ fields: { name: string; type?: string; required?: boolean }[];
254
+ /** Input node defaults, used to build the example request body (technical). */
255
+ defaults: Record<string, unknown>;
256
+ };
257
+ /** Maps a step's real projection status through the paced-reveal queue to the
258
+ * status the document should actually show right now. */
259
+ displayStatus: (nodeId: string, real: StepVisualStatus) => StepVisualStatus;
260
+ /** Header toggle: expand every branch group regardless of taken state. */
261
+ expandAll: boolean;
262
+ }
263
+
264
+ /** The 9px status square: idle is an empty outline, ran/failed fill solid,
265
+ * active fills and breathes with the ember pulse, skipped dims. */
266
+ function StatusRing({ status }: { status: StepVisualStatus }) {
267
+ return (
268
+ <span
269
+ className={cn(
270
+ 'flex size-[9px] shrink-0 items-center justify-center rounded-[3px] border border-border',
271
+ status === 'ran' && 'border-success bg-success',
272
+ status === 'failed' && 'border-destructive bg-destructive',
273
+ status === 'active' && 'ember-step-active border-highlight bg-highlight',
274
+ status === 'skipped' && 'opacity-40',
275
+ )}
276
+ >
277
+ {status === 'ran' && <span className="text-[7px] leading-none font-bold text-background">✓</span>}
278
+ </span>
279
+ );
280
+ }
281
+
282
+ /** Trace-kind badge labels + colors for the technical register. Colors are the
283
+ * brief's fixed palette (db blue, http violet, llm gold); compute stays muted.
284
+ * Borders render at 35% alpha so the chip reads as a quiet annotation, not a
285
+ * button. */
286
+ const KIND_LABEL: Record<string, string> = { db: 'SQL', http: 'HTTP', llm: 'LLM', compute: 'FN' };
287
+ const KIND_COLOR: Record<string, string> = { db: '#8fb8d8', http: '#c9a6e8', llm: '#d8c88f' };
288
+
289
+ const KIND_TITLE: Record<string, string> = {
290
+ db: 'Database query',
291
+ http: 'HTTP call',
292
+ llm: 'LLM call',
293
+ compute: 'Pure function',
294
+ };
295
+
296
+ /** Small label for the "what it calls" line, keyed off kind. */
297
+ const KIND_DETAIL_LABEL: Record<string, string> = {
298
+ db: 'query',
299
+ http: 'endpoint',
300
+ llm: 'model',
301
+ compute: 'detail',
302
+ };
303
+
304
+ /** Trace-kind badge with a hover card: the quiet chip expands into the step's
305
+ * full technical story — mechanism (full node description), the live trace
306
+ * line (receipt · exact duration · IO keys), and effect semantics. CSS-only
307
+ * hover; the card ignores pointer events so it never traps the mouse. */
308
+ function KindBadge({
309
+ kind,
310
+ typeName,
311
+ tech,
312
+ mutation,
313
+ error,
314
+ }: {
315
+ kind: 'db' | 'http' | 'llm' | 'compute';
316
+ typeName: string;
317
+ tech?: string;
318
+ mutation?: boolean;
319
+ error?: string;
320
+ }) {
321
+ const registry = useBuilderStore((st) => st.registry);
322
+ const definition = registry.has(typeName) ? registry.get(typeName).definition : undefined;
323
+ const label = KIND_LABEL[kind];
324
+ const color = KIND_COLOR[kind];
325
+ const chip =
326
+ kind === 'compute' ? (
327
+ <span className="rounded border border-border/35 px-1 font-mono text-[8.5px] leading-tight text-muted-foreground">
328
+ {label}
329
+ </span>
330
+ ) : (
331
+ <span
332
+ className="rounded border px-1 font-mono text-[8.5px] leading-tight"
333
+ style={{ color, borderColor: `${color}59` }}
334
+ >
335
+ {label}
336
+ </span>
337
+ );
338
+ return (
339
+ <span className="group/badge relative shrink-0 cursor-help">
340
+ {chip}
341
+ <span className="pointer-events-none absolute top-full left-0 z-50 mt-1.5 hidden w-[380px] rounded-lg border border-border bg-card p-3 shadow-[0_18px_48px_rgb(0_0_0/0.55)] group-hover/badge:block">
342
+ <span className="flex items-baseline gap-2">
343
+ <span className="font-mono text-[9px] tracking-wide" style={kind !== 'compute' ? { color } : undefined}>
344
+ {KIND_TITLE[kind]}
345
+ </span>
346
+ <span className="font-mono text-[10.5px] text-foreground">{typeName}</span>
347
+ </span>
348
+ {definition?.description && (
349
+ <span className="mt-1.5 block text-[11.5px] leading-relaxed text-muted-foreground">
350
+ {definition.description}
351
+ </span>
352
+ )}
353
+ {definition?.traceDetail && (
354
+ <span className="mt-2 block">
355
+ <span
356
+ className="mr-1.5 font-mono text-[8.5px] tracking-wide uppercase"
357
+ style={kind !== 'compute' ? { color } : undefined}
358
+ >
359
+ {KIND_DETAIL_LABEL[kind]}
360
+ </span>
361
+ <span className="font-mono text-[10.5px] leading-relaxed break-words text-foreground/90">
362
+ {definition.traceDetail}
363
+ </span>
364
+ </span>
365
+ )}
366
+ {tech && (
367
+ <span className="mt-2 block border-t border-border/60 pt-2 font-mono text-[10.5px] leading-relaxed text-foreground/90">
368
+ {tech}
369
+ </span>
370
+ )}
371
+ {error && (
372
+ <span className="mt-1.5 block font-mono text-[10.5px] text-destructive-foreground">{error}</span>
373
+ )}
374
+ {mutation && (
375
+ <span className="mt-1.5 block text-[10px] text-warn">
376
+ ⚡ mutation — dry-runs under safe mode, executes live otherwise
377
+ </span>
378
+ )}
379
+ </span>
380
+ </span>
381
+ );
382
+ }
383
+
384
+ function StepRow({ step, ctx }: { step: RunbookStep; ctx: RunbookCtx }) {
385
+ const registry = useBuilderStore((s) => s.registry);
386
+ const proj = ctx.projection.steps.get(step.nodeId);
387
+ const status = ctx.displayStatus(step.nodeId, proj?.status ?? 'idle');
388
+ const executed = status === 'ran' || status === 'failed' || status === 'active';
389
+ const technical = ctx.register === 'technical';
390
+
391
+ // Result and display nodes surface their collected payload right in the
392
+ // document — an inline expandable JSON box, so the run's actual result is
393
+ // readable without hopping to the Inspector. Auto-opens once it has run.
394
+ // Result (internal terminus), Response (HTTP terminus — its { status, body }
395
+ // is the operation's resulting JSON), and display-tagged nodes surface their
396
+ // output inline, auto-expanding once they run.
397
+ const showsResult =
398
+ step.typeName === 'Result' ||
399
+ step.typeName === 'Response' ||
400
+ (registry.has(step.typeName) && (registry.get(step.typeName).definition.tags?.includes('display') ?? false));
401
+ const resultState = ctx.run?.nodeStates[step.nodeId];
402
+ const resultValue = resultState?.output ?? resultState?.input;
403
+ const hasResult = showsResult && resultValue !== undefined;
404
+ const [resultOpen, setResultOpen] = useState(false);
405
+ const [requestOpen, setRequestOpen] = useState(false);
406
+ const wasExecuted = useRef(false);
407
+ if (hasResult && (status === 'ran' || status === 'failed') && !wasExecuted.current) {
408
+ wasExecuted.current = true;
409
+ if (!resultOpen) setResultOpen(true);
410
+ }
411
+ if (!hasResult && wasExecuted.current) wasExecuted.current = false;
412
+
413
+ // The outcome column: simple speaks the human outcome (last info log, else
414
+ // the step's description); technical speaks the projection's `tech` line (type
415
+ // · ms · in→out, `#N` receipt-prefixed) once the step has run. Before it runs,
416
+ // technical falls back to the description rather than the bare type name —
417
+ // repeating the type here would just double the type-mono badge beside the name.
418
+ // A loop chip is selected and this row lives in that loop's body with a
419
+ // recorded execution for the chosen iteration: show that iteration's data
420
+ // instead of the live (latest) projection. Falls through to the normal
421
+ // live text otherwise (e.g. iteration index has no execution yet).
422
+ let text = technical
423
+ ? executed
424
+ ? proj?.tech ?? step.typeName
425
+ : step.description || step.typeName
426
+ : executed
427
+ ? proj?.outcome || step.simpleDescription
428
+ : step.simpleDescription;
429
+ let destructive = status === 'failed';
430
+ if (ctx.loopIterIndex != null) {
431
+ const exec = ctx.run?.nodeStates[step.nodeId]?.executions?.[ctx.loopIterIndex];
432
+ if (exec) {
433
+ text = iterationSummary(exec);
434
+ destructive = !!exec.error;
435
+ }
436
+ }
437
+
438
+ const { display, fading } = useCrossfadeText(text);
439
+ const selected = ctx.selectedNodeId === step.nodeId;
440
+
441
+ // For an HTTP operation, the entry Input node renders as the request contract:
442
+ // method + path, then the accepted fields (required marked). Register-aware —
443
+ // simple shows plain field names, technical adds `:type`. Keeps the row's
444
+ // number, status ring, and selection so it's still step 1 of the run.
445
+ const isRequest = ctx.request?.inputNodeId === step.nodeId;
446
+ if (isRequest && ctx.request) {
447
+ const req = ctx.request;
448
+ const methodColor = KIND_COLOR.http;
449
+ // Technical only: an example request body. Use a real scalar default when
450
+ // present, but replace internal {$env}/{$secret} refs (and absent fields)
451
+ // with a `<type>` placeholder — a client would POST a value, not the ref.
452
+ const exampleBody = Object.fromEntries(
453
+ req.fields.map((f) => {
454
+ const d = req.defaults[f.name];
455
+ const isRef = !!d && typeof d === 'object' && !Array.isArray(d) && ('$env' in d || '$secret' in d);
456
+ const usable = f.name in req.defaults && !isRef;
457
+ return [f.name, usable ? d : `<${f.type ?? 'any'}>`];
458
+ }),
459
+ );
460
+ return (
461
+ <>
462
+ <div
463
+ ref={(el) => ctx.registerRow(step.nodeId, el)}
464
+ role="button"
465
+ tabIndex={0}
466
+ onClick={() => ctx.selectNode(step.nodeId)}
467
+ onKeyDown={(e) => {
468
+ if (e.key === 'Enter' || e.key === ' ') {
469
+ e.preventDefault();
470
+ ctx.selectNode(step.nodeId);
471
+ }
472
+ }}
473
+ className={cn(
474
+ '-mx-2.5 flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 transition-colors hover:bg-highlight/5',
475
+ selected && 'bg-highlight/15 ring-2 ring-inset ring-highlight shadow-lg',
476
+ )}
477
+ >
478
+ <span className="w-11 shrink-0 text-right font-mono text-[10.5px] text-muted-foreground">{step.number}</span>
479
+ <StatusRing status={status} />
480
+ <span
481
+ className="shrink-0 rounded border px-1.5 py-px font-mono text-[10px] font-semibold tracking-wide"
482
+ style={{ color: methodColor, borderColor: `${methodColor}59` }}
483
+ >
484
+ {req.method}
485
+ </span>
486
+ <span className="min-w-0 flex-1 truncate font-mono text-[13px] text-foreground">{req.path}</span>
487
+ {req.fields.length > 0 && (
488
+ <span className="shrink-0 text-[10px] uppercase tracking-widest text-muted-foreground">
489
+ {req.fields.length} field{req.fields.length === 1 ? '' : 's'}
490
+ </span>
491
+ )}
492
+ </div>
493
+ {/* Technical: reveal the request shape as JSON on demand. Simple stays
494
+ clean — just the endpoint above. */}
495
+ {technical && req.fields.length > 0 && (
496
+ <div className="ml-[54px] mb-1">
497
+ <button
498
+ type="button"
499
+ onClick={() => setRequestOpen((o) => !o)}
500
+ aria-expanded={requestOpen}
501
+ className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground transition-colors hover:text-foreground"
502
+ >
503
+ <ChevronRightIcon className={cn('size-3 transition-transform', requestOpen && 'rotate-90')} />
504
+ Request
505
+ </button>
506
+ {requestOpen && (
507
+ <div className="mt-1">
508
+ <Json value={exampleBody} maxHeight={320} />
509
+ </div>
510
+ )}
511
+ </div>
512
+ )}
513
+ </>
514
+ );
515
+ }
516
+
517
+ return (
518
+ <>
519
+ <div
520
+ ref={(el) => ctx.registerRow(step.nodeId, el)}
521
+ role="button"
522
+ tabIndex={0}
523
+ onClick={() => ctx.selectNode(step.nodeId)}
524
+ onKeyDown={(e) => {
525
+ if (e.key === 'Enter' || e.key === ' ') {
526
+ e.preventDefault();
527
+ ctx.selectNode(step.nodeId);
528
+ }
529
+ }}
530
+ className={cn(
531
+ '-mx-2.5 flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 transition-colors hover:bg-highlight/5',
532
+ selected && 'bg-highlight/15 font-medium ring-2 ring-inset ring-highlight shadow-lg',
533
+ )}
534
+ >
535
+ <span className="w-11 shrink-0 text-right font-mono text-[10.5px] text-muted-foreground">{step.number}</span>
536
+ <StatusRing status={status} />
537
+ <span className="shrink-0 truncate text-[13.5px]" style={{ fontWeight: 550 }}>
538
+ {step.label}
539
+ </span>
540
+ {technical && step.traceKind && (
541
+ <KindBadge
542
+ kind={step.traceKind}
543
+ typeName={step.typeName}
544
+ tech={proj?.tech}
545
+ mutation={step.mutation}
546
+ error={status === 'failed' ? ctx.run?.nodeStates[step.nodeId]?.error : undefined}
547
+ />
548
+ )}
549
+ <span className={cn('shrink-0 font-mono text-[9.5px] text-muted-foreground', !technical && 'hidden')}>
550
+ {step.typeName}
551
+ </span>
552
+ {step.mutation && (
553
+ <span className="shrink-0 text-[11px] text-warn" title="Mutation: has side effects">
554
+
555
+ </span>
556
+ )}
557
+ {(() => {
558
+ if (!technical) return null;
559
+ const attempts = ctx.run?.nodeStates[step.nodeId]?.attempts;
560
+ return attempts !== undefined && attempts > 1 ? (
561
+ <span
562
+ className="shrink-0 rounded-sm border border-warn/40 px-1 font-mono text-[10px] text-warn"
563
+ title={`Implementation retried — ${attempts} attempts total`}
564
+ >
565
+ retried ×{attempts - 1}
566
+ </span>
567
+ ) : null;
568
+ })()}
569
+ {ctx.run?.nodeStates[step.nodeId]?.mocked && (
570
+ <span
571
+ className="shrink-0 rounded-sm border border-warn/40 px-1 font-mono text-[10px] text-warn"
572
+ title="This node returned its scenario mock — nothing executed."
573
+ >
574
+ mocked
575
+ </span>
576
+ )}
577
+ {step.subflow && (
578
+ <span className="shrink-0 rounded border border-dashed border-border px-1 text-[10px] text-muted-foreground">
579
+ ↳ subflow
580
+ </span>
581
+ )}
582
+ {step.decisionArms && step.decisionArms.length > 0 && (
583
+ <span className="shrink-0 rounded border border-border px-1 text-[10px] text-muted-foreground">
584
+ ⑂ {step.decisionArms.length}
585
+ </span>
586
+ )}
587
+ <span
588
+ className={cn(
589
+ 'min-w-0 flex-1 truncate transition-opacity ease-out',
590
+ technical ? 'font-mono text-[11px]' : 'text-[12.5px]',
591
+ destructive ? 'text-destructive' : status === 'active' ? 'text-highlight' : 'text-muted-foreground',
592
+ !executed && 'opacity-55',
593
+ fading && 'opacity-0',
594
+ )}
595
+ style={{ transitionDuration: `${CROSSFADE_MS}ms` }}
596
+ >
597
+ {display}
598
+ </span>
599
+ {status === 'ran' && proj?.durationMs != null && (
600
+ <span className="shrink-0 font-mono text-[10px] text-muted-foreground">{formatDuration(proj.durationMs)}</span>
601
+ )}
602
+ </div>
603
+ {hasResult && (
604
+ <div className="ml-[54px] mb-1">
605
+ <button
606
+ type="button"
607
+ onClick={() => setResultOpen((o) => !o)}
608
+ aria-expanded={resultOpen}
609
+ className="flex cursor-pointer items-center gap-1.5 py-0.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground transition-colors hover:text-foreground"
610
+ >
611
+ <ChevronRightIcon className={cn('size-3 transition-transform', resultOpen && 'rotate-90')} />
612
+ Result
613
+ </button>
614
+ {resultOpen && (
615
+ <div className="mt-1">
616
+ <Json value={resultValue} maxHeight={320} />
617
+ </div>
618
+ )}
619
+ </div>
620
+ )}
621
+ </>
622
+ );
623
+ }
624
+
625
+ function BranchGroupRow({ group, ctx }: { group: RunbookBranchGroup; ctx: RunbookCtx }) {
626
+ const armKey = `${group.ownerId}::${group.arm}`;
627
+ const arm = ctx.projection.arms.get(armKey);
628
+ const taken = arm?.takenNow ?? false;
629
+
630
+ // A sibling arm of the same owner took the run and this one didn't — dim
631
+ // and collapse it (the panel-choreography feel: only the taken path stays
632
+ // lit), unless the user explicitly opened it.
633
+ const dimmed = useMemo(
634
+ () =>
635
+ !taken &&
636
+ [...ctx.projection.arms.entries()].some(
637
+ ([key, a]) => key !== armKey && key.startsWith(`${group.ownerId}::`) && a.takenNow,
638
+ ),
639
+ [ctx.projection.arms, armKey, group.ownerId, taken],
640
+ );
641
+
642
+ // Taken groups start open; others start closed. A group that becomes taken
643
+ // mid-run force-opens unless the user just closed it; a dimmed sibling
644
+ // force-collapses unless the user just opened it. Both overrides — and the
645
+ // open/closed baseline itself — forget themselves whenever the reset key
646
+ // changes (new run OR flow switch: node ids collide across flows).
647
+ const [openState, setOpen] = useState(taken);
648
+ const open = ctx.expandAll || openState;
649
+ const wasTaken = useRef(taken);
650
+ const manualOverride = useRef<'open' | 'closed' | null>(null);
651
+ const prevResetKey = useRef(ctx.resetKey);
652
+
653
+ useEffect(() => {
654
+ if (ctx.resetKey !== prevResetKey.current) {
655
+ prevResetKey.current = ctx.resetKey;
656
+ manualOverride.current = null;
657
+ wasTaken.current = taken;
658
+ setOpen(taken);
659
+ }
660
+ // eslint-disable-next-line react-hooks/exhaustive-deps
661
+ }, [ctx.resetKey]);
662
+
663
+ useEffect(() => {
664
+ if (taken && !wasTaken.current && manualOverride.current !== 'closed') setOpen(true);
665
+ wasTaken.current = taken;
666
+ }, [taken]);
667
+
668
+ useEffect(() => {
669
+ if (dimmed && manualOverride.current !== 'open') setOpen(false);
670
+ }, [dimmed]);
671
+
672
+ const toggleOpen = () => {
673
+ setOpen((o) => {
674
+ manualOverride.current = o ? 'closed' : 'open';
675
+ return !o;
676
+ });
677
+ };
678
+
679
+ return (
680
+ <div className="py-0.5">
681
+ <div
682
+ role="button"
683
+ tabIndex={0}
684
+ onClick={toggleOpen}
685
+ onKeyDown={(e) => {
686
+ if (e.key === 'Enter' || e.key === ' ') {
687
+ e.preventDefault();
688
+ toggleOpen();
689
+ }
690
+ }}
691
+ className={cn(
692
+ '-mx-2.5 flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1 transition-opacity duration-[400ms]',
693
+ (dimmed || (!open && !taken)) && 'opacity-50',
694
+ )}
695
+ >
696
+ <span className="w-11 shrink-0 text-right font-mono text-[10.5px] text-muted-foreground">{group.number}</span>
697
+ <ChevronRightIcon
698
+ className={cn('size-3 shrink-0 text-muted-foreground transition-transform', open && 'rotate-90')}
699
+ />
700
+ <span className="min-w-0 truncate text-[13px]">
701
+ If <span className="font-medium text-highlight">{group.arm}</span>:
702
+ </span>
703
+ {taken && (
704
+ <Badge variant="highlight" className="ml-auto shrink-0 text-[9px]">
705
+ this run
706
+ </Badge>
707
+ )}
708
+ </div>
709
+ {open && (
710
+ <div className={cn('ml-[54px] border-l-[1.5px] pl-3', taken ? 'border-highlight/40' : 'border-border')}>
711
+ <RunbookItems items={group.items} ctx={ctx} />
712
+ </div>
713
+ )}
714
+ </div>
715
+ );
716
+ }
717
+
718
+ const loopDotColor: Record<string, string> = {
719
+ pending: 'bg-muted-foreground/40',
720
+ done: 'bg-success',
721
+ failed: 'bg-destructive',
722
+ running: 'bg-highlight motion-safe:animate-pulse',
723
+ };
724
+
725
+ function LoopGroupRow({ group, ctx }: { group: RunbookLoopGroup; ctx: RunbookCtx }) {
726
+ const loop = ctx.projection.loops.get(group.forEachId);
727
+ const realStatuses = loop?.statuses ?? [];
728
+ // Mini-dots fill at the loop pace; the rail's presence and chip selection key
729
+ // off the real statuses so a chip is clickable the moment its iteration ran.
730
+ const statuses = usePacedLoopStatuses(realStatuses, ctx.resetKey);
731
+ // Chip rail only appears once at least one iteration has actually executed
732
+ // (not merely counted from an expected total) — dots alone carry the
733
+ // "nothing's happened yet" state.
734
+ const hasExecutions = realStatuses.some((s) => s !== 'pending');
735
+
736
+ // Which iteration chip is selected — swaps the body rows below to that
737
+ // execution's data. Clears whenever the reset key changes (new run OR flow
738
+ // switch: forEach ids collide across flows).
739
+ const [selectedIteration, setSelectedIteration] = useState<number | null>(null);
740
+ const prevResetKey = useRef(ctx.resetKey);
741
+ useEffect(() => {
742
+ if (ctx.resetKey !== prevResetKey.current) {
743
+ prevResetKey.current = ctx.resetKey;
744
+ setSelectedIteration(null);
745
+ }
746
+ }, [ctx.resetKey]);
747
+
748
+ const bodyCtx: RunbookCtx = { ...ctx, loopIterIndex: selectedIteration ?? undefined };
749
+
750
+ // The array being swept lives on the ForEach node's input; each chip is one
751
+ // element. Show the *current* element's value subtly beside the rail so you
752
+ // can read exactly what's being looped right now — the selected chip if the
753
+ // user picked one, else the running iteration, else the last that ran.
754
+ const feInput = ctx.run?.nodeStates[group.forEachId]?.input as Record<string, unknown> | undefined;
755
+ const items = Array.isArray(feInput?.items) ? (feInput!.items as unknown[]) : undefined;
756
+ const runningIdx = statuses.indexOf('running');
757
+ let lastRanIdx = -1;
758
+ for (let i = statuses.length - 1; i >= 0; i--) {
759
+ if (statuses[i] === 'done' || statuses[i] === 'failed') { lastRanIdx = i; break; }
760
+ }
761
+ const activeIdx = selectedIteration ?? (runningIdx >= 0 ? runningIdx : lastRanIdx);
762
+ const activeItem = items && activeIdx >= 0 ? items[activeIdx] : undefined;
763
+ const activeItemText = activeItem === undefined ? '' : compactValue(activeItem);
764
+
765
+ return (
766
+ <div className="py-0.5">
767
+ <div className="-mx-2.5 flex items-center gap-2.5 rounded-lg px-2.5 py-1">
768
+ <span className="w-11 shrink-0 text-right font-mono text-[10.5px] text-muted-foreground">{group.number}</span>
769
+ <span className="min-w-0 truncate text-[13px]">
770
+ For each — <span className="font-medium">{group.label}</span> ({loop?.count ?? 0})
771
+ </span>
772
+ <div className="ml-auto flex shrink-0 items-center gap-1">
773
+ {statuses.map((s, i) => (
774
+ <span key={i} className={cn('size-[5px] shrink-0 rounded-full', loopDotColor[s])} />
775
+ ))}
776
+ </div>
777
+ </div>
778
+ <div className="ml-[54px] border-l-[1.5px] border-border pl-3">
779
+ {hasExecutions && (
780
+ <div className="mb-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 pt-1">
781
+ <div className="flex flex-wrap items-center gap-1">
782
+ {statuses.map((s, i) => (
783
+ <button
784
+ key={i}
785
+ type="button"
786
+ onClick={() => setSelectedIteration(i)}
787
+ className={cn(
788
+ 'flex items-center gap-1 rounded border border-border px-1.5 py-0.5 font-mono text-[9.5px] text-muted-foreground hover:border-highlight/40',
789
+ (selectedIteration === i || (selectedIteration === null && i === activeIdx)) &&
790
+ 'border-highlight/60 text-foreground',
791
+ )}
792
+ >
793
+ {i + 1}
794
+ <span className={cn('size-1.5 rounded-full', loopDotColor[s])} />
795
+ </button>
796
+ ))}
797
+ </div>
798
+ {activeItemText && (
799
+ <span
800
+ className="min-w-0 truncate font-mono text-[10px] text-muted-foreground/60"
801
+ title={typeof activeItem === 'string' ? activeItem : JSON.stringify(activeItem, null, 2)}
802
+ >
803
+ {activeItemText}
804
+ </span>
805
+ )}
806
+ </div>
807
+ )}
808
+ <RunbookItems items={group.items} ctx={bodyCtx} />
809
+ </div>
810
+ </div>
811
+ );
812
+ }
813
+
814
+ /** One-line, ~80-char preview of a loop item — a bare string as-is, anything
815
+ * structured as compact JSON — for the subtle "what's looping now" annotation. */
816
+ function compactValue(value: unknown): string {
817
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
818
+ if (text === undefined) return '';
819
+ return text.length > 80 ? `${text.slice(0, 79)}…` : text;
820
+ }
821
+
822
+ function RunbookItems({ items, ctx }: { items: RunbookItem[]; ctx: RunbookCtx }) {
823
+ return (
824
+ <div className="flex flex-col gap-0.5">
825
+ {items.map((item) => {
826
+ if (item.kind === 'step') return <StepRow key={item.nodeId} step={item} ctx={ctx} />;
827
+ if (item.kind === 'branch') {
828
+ return <BranchGroupRow key={`${item.ownerId}::${item.arm}`} group={item} ctx={ctx} />;
829
+ }
830
+ return <LoopGroupRow key={item.forEachId} group={item} ctx={ctx} />;
831
+ })}
832
+ </div>
833
+ );
834
+ }
835
+
836
+ function RunbookHeader({
837
+ name,
838
+ environment,
839
+ subtitle,
840
+ expandAll,
841
+ onToggleExpandAll,
842
+ }: {
843
+ name: string;
844
+ environment?: string;
845
+ subtitle: string;
846
+ expandAll: boolean;
847
+ onToggleExpandAll: () => void;
848
+ }) {
849
+ return (
850
+ <div className="mb-6">
851
+ <h1 className="text-[22px] font-bold tracking-tight">{name}</h1>
852
+ <div className="mt-1.5 flex items-center gap-2 text-[12px] text-muted-foreground">
853
+ {environment && (
854
+ <Badge variant="outline" className="text-[9px] uppercase">
855
+ {environment}
856
+ </Badge>
857
+ )}
858
+ <span className="truncate">{subtitle}</span>
859
+ <button
860
+ type="button"
861
+ onClick={onToggleExpandAll}
862
+ title={expandAll ? 'Collapse branches' : 'Expand all branches'}
863
+ aria-label={expandAll ? 'Collapse branches' : 'Expand all branches'}
864
+ aria-pressed={expandAll}
865
+ className={cn(
866
+ 'ml-auto flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors',
867
+ expandAll ? 'bg-secondary/60 text-foreground' : 'text-muted-foreground hover:bg-secondary/40 hover:text-foreground',
868
+ )}
869
+ >
870
+ {expandAll ? <ChevronsDownUpIcon className="size-3.5" /> : <ChevronsUpDownIcon className="size-3.5" />}
871
+ </button>
872
+ </div>
873
+ </div>
874
+ );
875
+ }
876
+
877
+ /** Calm end-of-document run readout. While the reveal queue is still draining
878
+ * (`filling`) it stays "Running" even though the engine already finished — the
879
+ * document isn't done until the last step has been read out. */
880
+ function RunbookFooter({ run, filling }: { run: WorkflowRun; filling: boolean }) {
881
+ const live = run.status === 'running' || filling;
882
+ const ran = Object.values(run.nodeStates).filter(
883
+ (s) => s.status === 'succeeded' || s.status === 'failed',
884
+ ).length;
885
+ const label = live
886
+ ? 'Running'
887
+ : run.status === 'succeeded'
888
+ ? 'Completed'
889
+ : run.status === 'failed'
890
+ ? 'Failed'
891
+ : 'Cancelled';
892
+ const tone = live
893
+ ? 'text-highlight'
894
+ : run.status === 'failed'
895
+ ? 'text-destructive'
896
+ : run.status === 'succeeded'
897
+ ? 'text-success'
898
+ : 'text-muted-foreground';
899
+ return (
900
+ <div className="mt-8 flex items-center gap-2 border-t border-border/60 pt-3 text-[11px] text-muted-foreground">
901
+ <span className={cn('size-1.5 shrink-0 rounded-full bg-current', tone, live && 'motion-safe:animate-pulse')} />
902
+ <span className={cn('font-medium', tone)}>{label}</span>
903
+ <span className="font-mono text-[10.5px] text-muted-foreground/80">
904
+ {ran} step{ran === 1 ? '' : 's'} executed
905
+ </span>
906
+ </div>
907
+ );
908
+ }
909
+
910
+ /**
911
+ * The executable runbook: a linear document rendering of the flow — numbered
912
+ * steps, branch groups with taken/coverage state, and loop groups with an
913
+ * iteration rail — the primary way to read a flow.
914
+ * Row clicks drive selection; the Inspector (not a second detail panel here)
915
+ * shows the clicked node's full state.
916
+ */
917
+ /** The canvas holding pattern shown while the agent scaffolds a just-created
918
+ * operation. The stub (name + route) is already selected; this stands in for
919
+ * the empty body until the agent writes the flow, which then loads live. */
920
+ function BuildingHolding({ route }: { route?: string }) {
921
+ return (
922
+ <div className="mt-10 flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-highlight/30 bg-highlight/[0.03] px-6 py-14 text-center">
923
+ <span className="ember-step-active flex size-11 items-center justify-center rounded-full bg-highlight/15 text-highlight">
924
+ <SparklesIcon className="size-5" />
925
+ </span>
926
+ <div className="flex flex-col gap-1">
927
+ <span className="text-[14px] font-medium text-foreground">Building this operation…</span>
928
+ <span className="max-w-sm text-[12.5px] leading-relaxed text-muted-foreground">
929
+ The agent is writing the flow. It appears here live the moment it’s done — you can watch its
930
+ progress in the Agent panel.
931
+ </span>
932
+ </div>
933
+ {route && (
934
+ <span className="rounded-md border border-border/60 bg-secondary/40 px-2 py-1 font-mono text-[11.5px] text-muted-foreground">
935
+ {route}
936
+ </span>
937
+ )}
938
+ <span className="flex items-center gap-1.5 text-[11px] uppercase tracking-widest text-highlight/80">
939
+ <span className="size-1.5 animate-pulse rounded-full bg-highlight" />
940
+ waiting for the agent
941
+ </span>
942
+ </div>
943
+ );
944
+ }
945
+
946
+ export function RunbookView({ register = 'simple' }: { register?: 'simple' | 'technical' } = {}) {
947
+ const flow = useBuilderStore((s) => s.flow);
948
+ const registry = useBuilderStore((s) => s.registry);
949
+ const buildingOperationId = useBuilderStore((s) => s.buildingOperationId);
950
+ const run = useBuilderStore((s) => s.run);
951
+ const logs = useBuilderStore((s) => s.logs);
952
+ const runHistory = useBuilderStore((s) => s.runHistory);
953
+ const selectedNodeId = useBuilderStore((s) => s.selectedNodeId);
954
+ const selectNode = useBuilderStore((s) => s.selectNode);
955
+
956
+ const doc = useMemo(() => buildRunbook(flow, registry), [flow, registry]);
957
+ const projection = useMemo(
958
+ () => projectRunbook(doc, run, logs, runHistory, flow.id),
959
+ [doc, run, logs, runHistory],
960
+ );
961
+
962
+ // Paced reveal: the document fills at a readable rhythm even when the engine
963
+ // finished in one frame. displayStatus trails the real projection; the run is
964
+ // still "filling" while activeDisplayId is non-null.
965
+ const orderedStepIds = useMemo(() => flattenStepIds(doc.items), [doc]);
966
+ const { displayStatus, activeDisplayId } = usePacedReveal(orderedStepIds, projection, run?.id ?? null);
967
+
968
+ // ── calm auto-scroll: keep the active row inside the 20%-75% band of the
969
+ // scroll container, at most once every SCROLL_MIN_GAP_MS. ──
970
+ const scrollRef = useRef<HTMLDivElement>(null);
971
+ const [expandAll, setExpandAll] = useState(false);
972
+ const rowRefsMap = useRef(new Map<string, HTMLElement>());
973
+ const registerRow = (nodeId: string, el: HTMLElement | null) => {
974
+ if (el) rowRefsMap.current.set(nodeId, el);
975
+ else rowRefsMap.current.delete(nodeId);
976
+ };
977
+
978
+ // Scroll follows the *display* cursor ONLY while there is live motion — the
979
+ // paced reveal draining, or an actively running (incl. stepped) run. At rest
980
+ // the document never repositions itself: the reader owns the scrollbar.
981
+ const activeNodeId = useMemo(() => {
982
+ if (activeDisplayId) return activeDisplayId;
983
+ if (run?.status !== 'running') return null;
984
+ const runningId = Object.entries(run.nodeStates).find(([, s]) => s.status === 'running')?.[0];
985
+ // Stepped runs sit 'running' between steps with no running node — follow
986
+ // the step cursor (selection tracks the just-executed step).
987
+ return runningId ?? selectedNodeId;
988
+ }, [activeDisplayId, run, selectedNodeId]);
989
+
990
+ const activeNodeIdRef = useRef(activeNodeId);
991
+ activeNodeIdRef.current = activeNodeId;
992
+
993
+ // Manual scroll input detaches the follow for the rest of this run — the
994
+ // wheel always beats the camera. A new run re-attaches.
995
+ const detachedRef = useRef(false);
996
+ useEffect(() => {
997
+ detachedRef.current = false;
998
+ }, [run?.id]);
999
+ useEffect(() => {
1000
+ const el = scrollRef.current;
1001
+ if (!el) return;
1002
+ const detach = () => {
1003
+ detachedRef.current = true;
1004
+ };
1005
+ el.addEventListener('wheel', detach, { passive: true });
1006
+ el.addEventListener('touchmove', detach, { passive: true });
1007
+ return () => {
1008
+ el.removeEventListener('wheel', detach);
1009
+ el.removeEventListener('touchmove', detach);
1010
+ };
1011
+ }, []);
1012
+
1013
+ useEffect(() => {
1014
+ let lastScrollAt = 0;
1015
+ const tick = setInterval(() => {
1016
+ const container = scrollRef.current;
1017
+ const id = activeNodeIdRef.current;
1018
+ if (!container || !id || detachedRef.current) return;
1019
+ const row = rowRefsMap.current.get(id);
1020
+ if (!row) return;
1021
+ const containerRect = container.getBoundingClientRect();
1022
+ const rowRect = row.getBoundingClientRect();
1023
+ const relativeTop = (rowRect.top - containerRect.top) / containerRect.height;
1024
+ if (relativeTop >= 0.2 && relativeTop <= 0.75) return; // already in the calm band
1025
+ const now = Date.now();
1026
+ if (now - lastScrollAt < SCROLL_MIN_GAP_MS) return;
1027
+ lastScrollAt = now;
1028
+ row.scrollIntoView({ block: 'center', behavior: 'smooth' });
1029
+ }, 250);
1030
+ return () => clearInterval(tick);
1031
+ }, []);
1032
+
1033
+ // When the flow is an HTTP operation, the entry Input node IS the request
1034
+ // contract — surface method/path/fields there instead of a generic step.
1035
+ const request = useMemo(() => {
1036
+ if (!flow.http) return undefined;
1037
+ const entry = flow.nodes.find((n) => n.type === 'Input');
1038
+ if (!entry) return undefined;
1039
+ const rawFields = (entry.config?.fields as { name: string; type?: string; required?: boolean }[] | undefined) ?? [];
1040
+ return {
1041
+ inputNodeId: entry.id,
1042
+ method: flow.http.method,
1043
+ path: flow.http.path,
1044
+ fields: rawFields.filter((f) => f && typeof f.name === 'string'),
1045
+ defaults: (entry.config?.defaults as Record<string, unknown> | undefined) ?? {},
1046
+ };
1047
+ }, [flow]);
1048
+
1049
+ const ctx: RunbookCtx = {
1050
+ projection,
1051
+ run,
1052
+ resetKey: `${flow.id}:${run?.id ?? 'none'}`,
1053
+ selectedNodeId,
1054
+ selectNode,
1055
+ registerRow,
1056
+ register,
1057
+ displayStatus,
1058
+ expandAll,
1059
+ request,
1060
+ };
1061
+ const subtitle = flow.folder ?? `${flow.nodes.length} step${flow.nodes.length === 1 ? '' : 's'}`;
1062
+ const building = buildingOperationId === flow.id;
1063
+
1064
+ return (
1065
+ <div ref={scrollRef} className="h-full min-h-0 overflow-y-auto bg-background">
1066
+ <div className="mx-auto max-w-[800px] px-4 pt-[26px] pb-[140px]">
1067
+ <RunbookHeader
1068
+ name={flow.name}
1069
+ environment={flow.environment}
1070
+ subtitle={subtitle}
1071
+ expandAll={expandAll}
1072
+ onToggleExpandAll={() => setExpandAll((v) => !v)}
1073
+ />
1074
+ {building ? (
1075
+ <BuildingHolding route={flow.http ? `${flow.http.method} ${flow.http.path}` : undefined} />
1076
+ ) : (
1077
+ <>
1078
+ <RunbookItems items={doc.items} ctx={ctx} />
1079
+ {run && <RunbookFooter run={run} filling={activeDisplayId !== null} />}
1080
+ </>
1081
+ )}
1082
+ </div>
1083
+ </div>
1084
+ );
1085
+ }