@anchrd/intel 0.59.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 (364) hide show
  1. package/README.md +496 -0
  2. package/bin/intel.mjs +134 -0
  3. package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
  4. package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
  5. package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
  6. package/dist/adapters/cloudflare/cloudflare.js +416 -0
  7. package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
  8. package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
  9. package/dist/adapters/content/content.d.ts +3 -0
  10. package/dist/adapters/content/content.js +128 -0
  11. package/dist/adapters/content/content.types.d.ts +38 -0
  12. package/dist/adapters/content/content.types.js +1 -0
  13. package/dist/adapters/db/db-audit.d.ts +62 -0
  14. package/dist/adapters/db/db-audit.js +150 -0
  15. package/dist/adapters/db/db-boards.d.ts +6 -0
  16. package/dist/adapters/db/db-boards.js +324 -0
  17. package/dist/adapters/db/db-feed.d.ts +30 -0
  18. package/dist/adapters/db/db-feed.js +276 -0
  19. package/dist/adapters/db/db-flows.d.ts +42 -0
  20. package/dist/adapters/db/db-flows.js +1311 -0
  21. package/dist/adapters/db/db-grants.d.ts +133 -0
  22. package/dist/adapters/db/db-grants.js +269 -0
  23. package/dist/adapters/db/db-indexing.d.ts +3 -0
  24. package/dist/adapters/db/db-indexing.js +200 -0
  25. package/dist/adapters/db/db-oauth.d.ts +3 -0
  26. package/dist/adapters/db/db-oauth.js +26 -0
  27. package/dist/adapters/db/db-prompts.d.ts +46 -0
  28. package/dist/adapters/db/db-prompts.js +144 -0
  29. package/dist/adapters/db/db.d.ts +6 -0
  30. package/dist/adapters/db/db.js +1687 -0
  31. package/dist/adapters/db/db.types.d.ts +16 -0
  32. package/dist/adapters/db/db.types.js +1 -0
  33. package/dist/adapters/document-converter/document-converter.d.ts +2 -0
  34. package/dist/adapters/document-converter/document-converter.js +26 -0
  35. package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
  36. package/dist/adapters/document-converter/document-converter.types.js +1 -0
  37. package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
  38. package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
  39. package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
  40. package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
  41. package/dist/adapters/index-queue/index-queue.d.ts +10 -0
  42. package/dist/adapters/index-queue/index-queue.js +12 -0
  43. package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
  44. package/dist/adapters/index-queue/index-queue.types.js +1 -0
  45. package/dist/adapters/json-schema/json-schema.d.ts +2 -0
  46. package/dist/adapters/json-schema/json-schema.js +77 -0
  47. package/dist/adapters/openid/openid.d.ts +5 -0
  48. package/dist/adapters/openid/openid.js +301 -0
  49. package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
  50. package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
  51. package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
  52. package/dist/adapters/remote-tools/remote-tools.js +129 -0
  53. package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
  54. package/dist/adapters/semantic-index/semantic-index.js +139 -0
  55. package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
  56. package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
  57. package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
  58. package/dist/adapters/session-cookie/session-cookie.js +87 -0
  59. package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
  60. package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
  61. package/dist/audit/audit.d.ts +2 -0
  62. package/dist/audit/audit.js +87 -0
  63. package/dist/audit/audit.types.d.ts +23 -0
  64. package/dist/audit/audit.types.js +1 -0
  65. package/dist/auth/auth.d.ts +2 -0
  66. package/dist/auth/auth.js +284 -0
  67. package/dist/auth/auth.types.d.ts +108 -0
  68. package/dist/auth/auth.types.js +1 -0
  69. package/dist/boards/boards.d.ts +44 -0
  70. package/dist/boards/boards.js +447 -0
  71. package/dist/boards/boards.types.d.ts +117 -0
  72. package/dist/boards/boards.types.js +1 -0
  73. package/dist/build/build.d.ts +2 -0
  74. package/dist/build/build.js +192 -0
  75. package/dist/build/build.types.d.ts +17 -0
  76. package/dist/build/build.types.js +1 -0
  77. package/dist/bundle/bundle.d.ts +4 -0
  78. package/dist/bundle/bundle.js +1194 -0
  79. package/dist/bundle/bundle.types.d.ts +33 -0
  80. package/dist/bundle/bundle.types.js +1 -0
  81. package/dist/cli/cli.d.ts +4 -0
  82. package/dist/cli/cli.js +262 -0
  83. package/dist/cli/cli.types.d.ts +14 -0
  84. package/dist/cli/cli.types.js +1 -0
  85. package/dist/contract/audit.d.ts +50 -0
  86. package/dist/contract/audit.js +72 -0
  87. package/dist/contract/board.d.ts +150 -0
  88. package/dist/contract/board.js +262 -0
  89. package/dist/contract/bundle.d.ts +147 -0
  90. package/dist/contract/bundle.js +139 -0
  91. package/dist/contract/contract.d.ts +112 -0
  92. package/dist/contract/contract.js +143 -0
  93. package/dist/contract/feed.d.ts +126 -0
  94. package/dist/contract/feed.js +149 -0
  95. package/dist/contract/flow-run.d.ts +346 -0
  96. package/dist/contract/flow-run.js +194 -0
  97. package/dist/contract/flow.d.ts +1057 -0
  98. package/dist/contract/flow.js +552 -0
  99. package/dist/contract/node.d.ts +551 -0
  100. package/dist/contract/node.js +456 -0
  101. package/dist/contract/share.d.ts +216 -0
  102. package/dist/contract/share.js +134 -0
  103. package/dist/contract/table.d.ts +171 -0
  104. package/dist/contract/table.js +117 -0
  105. package/dist/contract/tool.d.ts +122 -0
  106. package/dist/contract/tool.js +172 -0
  107. package/dist/feed/feed.d.ts +2 -0
  108. package/dist/feed/feed.js +77 -0
  109. package/dist/feed/feed.types.d.ts +23 -0
  110. package/dist/feed/feed.types.js +1 -0
  111. package/dist/flows/flows.d.ts +41 -0
  112. package/dist/flows/flows.js +2216 -0
  113. package/dist/flows/flows.types.d.ts +339 -0
  114. package/dist/flows/flows.types.js +1 -0
  115. package/dist/http/http.d.ts +9 -0
  116. package/dist/http/http.js +874 -0
  117. package/dist/http/http.types.d.ts +22 -0
  118. package/dist/http/http.types.js +1 -0
  119. package/dist/indexing/indexing.d.ts +6 -0
  120. package/dist/indexing/indexing.js +239 -0
  121. package/dist/indexing/indexing.types.d.ts +11 -0
  122. package/dist/indexing/indexing.types.js +1 -0
  123. package/dist/intel/intel.d.ts +3 -0
  124. package/dist/intel/intel.js +194 -0
  125. package/dist/intel/intel.types.d.ts +24 -0
  126. package/dist/intel/intel.types.js +1 -0
  127. package/dist/mcp/mcp.d.ts +2 -0
  128. package/dist/mcp/mcp.js +1292 -0
  129. package/dist/mcp/mcp.types.d.ts +25 -0
  130. package/dist/mcp/mcp.types.js +1 -0
  131. package/dist/nodes/document-links/document-links.d.ts +20 -0
  132. package/dist/nodes/document-links/document-links.js +57 -0
  133. package/dist/nodes/nodes.d.ts +2 -0
  134. package/dist/nodes/nodes.js +1667 -0
  135. package/dist/nodes/nodes.types.d.ts +511 -0
  136. package/dist/nodes/nodes.types.js +1 -0
  137. package/dist/prepare/prepare.d.ts +4 -0
  138. package/dist/prepare/prepare.js +16 -0
  139. package/dist/prepare/prepare.types.d.ts +9 -0
  140. package/dist/prepare/prepare.types.js +1 -0
  141. package/dist/prompts/prompts.d.ts +2 -0
  142. package/dist/prompts/prompts.js +65 -0
  143. package/dist/prompts/prompts.types.d.ts +71 -0
  144. package/dist/prompts/prompts.types.js +1 -0
  145. package/dist/shared/callers/callers.d.ts +28 -0
  146. package/dist/shared/callers/callers.js +30 -0
  147. package/dist/shared/csv/csv.d.ts +13 -0
  148. package/dist/shared/csv/csv.js +85 -0
  149. package/dist/shared/document-text/document-text.d.ts +21 -0
  150. package/dist/shared/document-text/document-text.js +31 -0
  151. package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
  152. package/dist/shared/gate-authorization/gate-authorization.js +26 -0
  153. package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
  154. package/dist/shared/grant-expiry/grant-expiry.js +26 -0
  155. package/dist/shared/intel-error/intel-error.d.ts +22 -0
  156. package/dist/shared/intel-error/intel-error.js +27 -0
  157. package/dist/shared/plain-title/plain-title.d.ts +16 -0
  158. package/dist/shared/plain-title/plain-title.js +45 -0
  159. package/dist/shared/problem-details/problem-details.d.ts +30 -0
  160. package/dist/shared/problem-details/problem-details.js +18 -0
  161. package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
  162. package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
  163. package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
  164. package/dist/shared/safe-return-path/safe-return-path.js +11 -0
  165. package/dist/shared/sha256/sha256.d.ts +2 -0
  166. package/dist/shared/sha256/sha256.js +14 -0
  167. package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
  168. package/dist/tools/tool-servers/tool-servers.js +177 -0
  169. package/dist/tools/tools.d.ts +2 -0
  170. package/dist/tools/tools.js +207 -0
  171. package/dist/tools/tools.types.d.ts +56 -0
  172. package/dist/tools/tools.types.js +1 -0
  173. package/examples/branding/favicon.svg +5 -0
  174. package/examples/branding/logo.svg +5 -0
  175. package/examples/branding/theme.css +16 -0
  176. package/examples/dev.vars.example +7 -0
  177. package/examples/intel.json +9 -0
  178. package/migrations/0000_intel_foundation.sql +203 -0
  179. package/migrations/0001_portal_native_tools.sql +21 -0
  180. package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
  181. package/migrations/0003_folder_permissions.sql +211 -0
  182. package/migrations/0004_subflow_runs.sql +14 -0
  183. package/migrations/0005_flow_node_cleanup.sql +28 -0
  184. package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
  185. package/migrations/0006_links_are_written_in_the_text.sql +20 -0
  186. package/migrations/0007_no_node_waits.sql +69 -0
  187. package/migrations/0008_three_layers.sql +130 -0
  188. package/migrations/0009_no_context_policy.sql +48 -0
  189. package/migrations/0010_no_run_waits_a_year.sql +27 -0
  190. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  191. package/migrations/0012_table_snapshots.sql +29 -0
  192. package/migrations/0017_a_vector_per_card.sql +38 -0
  193. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  194. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  195. package/migrations/0020_cascade_purge_replay.sql +11 -0
  196. package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
  197. package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
  198. package/migrations/0023_a_board_and_its_tasks.sql +116 -0
  199. package/migrations/0024_the_archive_is_a_column.sql +33 -0
  200. package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
  201. package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
  202. package/migrations/0027_the_runs_of_every_flow.sql +27 -0
  203. package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
  204. package/package.json +118 -0
  205. package/src/contract/CLAUDE.md +152 -0
  206. package/src/contract/README.md +102 -0
  207. package/src/contract/audit.ts +85 -0
  208. package/src/contract/board.ts +306 -0
  209. package/src/contract/bundle.ts +157 -0
  210. package/src/contract/contract.ts +161 -0
  211. package/src/contract/feed.ts +165 -0
  212. package/src/contract/flow-run.ts +241 -0
  213. package/src/contract/flow.ts +675 -0
  214. package/src/contract/node.ts +583 -0
  215. package/src/contract/share.ts +196 -0
  216. package/src/contract/table.ts +178 -0
  217. package/src/contract/tool.ts +202 -0
  218. package/ui/README.md +110 -0
  219. package/ui/components.json +21 -0
  220. package/ui/index.html +13 -0
  221. package/ui/package.json +11 -0
  222. package/ui/scripts/lint-tokens.mjs +187 -0
  223. package/ui/src/access-summary/access-summary.tsx +385 -0
  224. package/ui/src/app/action-slot/action-slot.tsx +27 -0
  225. package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
  226. package/ui/src/app/app-tree/app-tree.tsx +1175 -0
  227. package/ui/src/app/app.tsx +87 -0
  228. package/ui/src/app/header-search/header-search.tsx +294 -0
  229. package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
  230. package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
  231. package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  232. package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  233. package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
  234. package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
  235. package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
  236. package/ui/src/app/tree-move/tree-move.tsx +427 -0
  237. package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
  238. package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
  239. package/ui/src/app/user-footer/user-footer.tsx +188 -0
  240. package/ui/src/app/view-menu/view-menu.tsx +115 -0
  241. package/ui/src/app-root/app-root.tsx +109 -0
  242. package/ui/src/app-root/app-root.types.ts +27 -0
  243. package/ui/src/archive/archive.tsx +417 -0
  244. package/ui/src/attachment-detail/attachment-detail.ts +52 -0
  245. package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
  246. package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
  247. package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
  248. package/ui/src/board/board-assignee/board-assignee.ts +48 -0
  249. package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
  250. package/ui/src/board/board-chip/board-chip.tsx +46 -0
  251. package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
  252. package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
  253. package/ui/src/board/board-data/board-data.ts +195 -0
  254. package/ui/src/board/board-data/board-data.types.ts +38 -0
  255. package/ui/src/board/board-dates/board-dates.ts +40 -0
  256. package/ui/src/board/board-dates/board-dates.tsx +105 -0
  257. package/ui/src/board/board-kanban/board-kanban.ts +162 -0
  258. package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
  259. package/ui/src/board/board-open-task/board-open-task.ts +32 -0
  260. package/ui/src/board/board-panel/board-panel.tsx +259 -0
  261. package/ui/src/board/board-settings/board-settings.tsx +226 -0
  262. package/ui/src/board/board-status/board-status.tsx +60 -0
  263. package/ui/src/board/board-stripes/board-stripes.ts +128 -0
  264. package/ui/src/board/board-table/board-table.ts +53 -0
  265. package/ui/src/board/board-table/board-table.tsx +610 -0
  266. package/ui/src/board/board-task/board-task.ts +108 -0
  267. package/ui/src/board/board-task/board-task.tsx +335 -0
  268. package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
  269. package/ui/src/branding/branding.tsx +17 -0
  270. package/ui/src/branding/custom-logo.ts +1 -0
  271. package/ui/src/branding/favicon.default.svg +5 -0
  272. package/ui/src/branding/favicon.svg +5 -0
  273. package/ui/src/components/ui/avatar.tsx +39 -0
  274. package/ui/src/components/ui/button.tsx +64 -0
  275. package/ui/src/components/ui/collapsible.tsx +20 -0
  276. package/ui/src/components/ui/command.tsx +160 -0
  277. package/ui/src/components/ui/dialog.tsx +143 -0
  278. package/ui/src/components/ui/dropdown-menu.tsx +187 -0
  279. package/ui/src/components/ui/input.tsx +21 -0
  280. package/ui/src/components/ui/popover.tsx +41 -0
  281. package/ui/src/components/ui/select.tsx +163 -0
  282. package/ui/src/components/ui/separator.tsx +26 -0
  283. package/ui/src/components/ui/sheet.tsx +136 -0
  284. package/ui/src/components/ui/sidebar.tsx +693 -0
  285. package/ui/src/components/ui/skeleton.tsx +13 -0
  286. package/ui/src/components/ui/switch.tsx +25 -0
  287. package/ui/src/components/ui/table.tsx +82 -0
  288. package/ui/src/components/ui/tabs.tsx +52 -0
  289. package/ui/src/components/ui/tooltip.tsx +51 -0
  290. package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
  291. package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
  292. package/ui/src/data/query-client.ts +18 -0
  293. package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
  294. package/ui/src/data/request-refusal/request-refusal.ts +51 -0
  295. package/ui/src/data/sign-in/sign-in.ts +97 -0
  296. package/ui/src/data/sign-in/sign-in.types.ts +15 -0
  297. package/ui/src/document-link/document-link.tsx +114 -0
  298. package/ui/src/document-markdown/document-markdown.ts +195 -0
  299. package/ui/src/editor-schema/editor-schema.ts +23 -0
  300. package/ui/src/entry-picker/entry-picker.tsx +238 -0
  301. package/ui/src/feed/feed.tsx +344 -0
  302. package/ui/src/file-preview/file-preview-view.tsx +496 -0
  303. package/ui/src/file-preview/file-preview.tsx +28 -0
  304. package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
  305. package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
  306. package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  307. package/ui/src/file-preview/word-file-preview.tsx +5 -0
  308. package/ui/src/flow-runs/flow-runs.tsx +238 -0
  309. package/ui/src/flows/flows.tsx +1646 -0
  310. package/ui/src/flows/node-icon/node-icon.ts +34 -0
  311. package/ui/src/flows/node-palette/node-palette.tsx +214 -0
  312. package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
  313. package/ui/src/folder-contents/folder-contents.tsx +321 -0
  314. package/ui/src/folder-index/folder-index.ts +55 -0
  315. package/ui/src/frontmatter/frontmatter.tsx +193 -0
  316. package/ui/src/graph-pane/graph-pane.tsx +53 -0
  317. package/ui/src/hooks/use-mobile.ts +19 -0
  318. package/ui/src/i18n/custom.json +4 -0
  319. package/ui/src/i18n/de.json +609 -0
  320. package/ui/src/i18n/en.json +609 -0
  321. package/ui/src/i18n/es.json +609 -0
  322. package/ui/src/i18n/i18n-context.tsx +65 -0
  323. package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  324. package/ui/src/i18n/i18n.ts +32 -0
  325. package/ui/src/i18n/i18n.types.ts +49 -0
  326. package/ui/src/kind-icon.ts +97 -0
  327. package/ui/src/lib/utils.ts +6 -0
  328. package/ui/src/main.tsx +68 -0
  329. package/ui/src/modal/modal.tsx +53 -0
  330. package/ui/src/node-details/node-details.tsx +545 -0
  331. package/ui/src/node-editor/node-editor.tsx +304 -0
  332. package/ui/src/node-graph/graph-notice.tsx +21 -0
  333. package/ui/src/node-graph/node-graph.ts +214 -0
  334. package/ui/src/node-graph/node-graph.tsx +295 -0
  335. package/ui/src/node-import/node-import.tsx +418 -0
  336. package/ui/src/node-save-error.ts +46 -0
  337. package/ui/src/node-source/node-source.tsx +128 -0
  338. package/ui/src/node-table/node-table.tsx +107 -0
  339. package/ui/src/nodes/nodes.tsx +240 -0
  340. package/ui/src/rename-resource/rename-resource.ts +80 -0
  341. package/ui/src/resolved-names/resolved-names.tsx +67 -0
  342. package/ui/src/resource-error.ts +33 -0
  343. package/ui/src/resource-menu/resource-menu.tsx +1156 -0
  344. package/ui/src/router/router-context.ts +6 -0
  345. package/ui/src/router/router.tsx +67 -0
  346. package/ui/src/router/router.types.ts +8 -0
  347. package/ui/src/router/selection-search.ts +88 -0
  348. package/ui/src/save-button/save-button.tsx +94 -0
  349. package/ui/src/section-hint/section-hint.tsx +40 -0
  350. package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
  351. package/ui/src/styles.css +189 -0
  352. package/ui/src/table-columns/table-columns.tsx +379 -0
  353. package/ui/src/theme/custom.css +1 -0
  354. package/ui/src/theme/theme-context.tsx +89 -0
  355. package/ui/src/theme/theme.ts +70 -0
  356. package/ui/src/time/relative-time.tsx +41 -0
  357. package/ui/src/time/time-context.tsx +78 -0
  358. package/ui/src/time/time.ts +165 -0
  359. package/ui/src/title-row/title-row.tsx +185 -0
  360. package/ui/src/tools/tools.tsx +546 -0
  361. package/ui/src/user-name/user-name.ts +61 -0
  362. package/ui/src/verbatim/verbatim.tsx +68 -0
  363. package/ui/tsconfig.json +17 -0
  364. package/ui/vite.config.ts +129 -0
@@ -0,0 +1,1156 @@
1
+ import type { Flow, FlowValidation } from "@contract/flow.ts";
2
+ import type { Node } from "@contract/node.ts";
3
+ import type { ResourceVerb, UnreadableNodes } from "@contract/share.ts";
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
6
+ import {
7
+ Archive,
8
+ Columns3,
9
+ CornerLeftUp,
10
+ Ellipsis,
11
+ FolderDown,
12
+ FolderUp,
13
+ History,
14
+ Link2,
15
+ Pencil,
16
+ Share2,
17
+ ShieldCheck,
18
+ SlashSquare,
19
+ } from "lucide-react";
20
+ import type * as React from "react";
21
+ import { useState } from "react";
22
+ import { AccessSummary } from "@/access-summary/access-summary.tsx";
23
+ import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
24
+ import {
25
+ DropdownMenu,
26
+ DropdownMenuContent,
27
+ DropdownMenuItem,
28
+ DropdownMenuTrigger,
29
+ } from "@/components/ui/dropdown-menu";
30
+ import { flowEntry, IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
31
+ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
32
+ import { useI18n } from "@/i18n/i18n-context.tsx";
33
+ import { Modal } from "@/modal/modal.tsx";
34
+ import { useNodeImport } from "@/node-import/node-import.tsx";
35
+ import { useResourceRefresh, useResourceRename } from "@/rename-resource/rename-resource.ts";
36
+ import { resourceErrorKey } from "@/resource-error.ts";
37
+ import { useIntelRouterContext } from "@/router/router-context.ts";
38
+ import { selectedFrom } from "@/router/selection-search.ts";
39
+ import { TableColumnsDialog } from "@/table-columns/table-columns.tsx";
40
+ import { RelativeTime } from "@/time/relative-time.tsx";
41
+ import { useDateTime } from "@/time/time-context.tsx";
42
+
43
+ /**
44
+ * What a resource's own actions are, at the place the resource stands (#24).
45
+ *
46
+ * ⚠️ Since #58 there is exactly one such place: the title line of the open document, table,
47
+ * attachment, folder or flow. The tree row used to carry the same menu, which put ten buttons into a
48
+ * 240px column and made the sidebar something to read buttons in rather than titles. Everything the
49
+ * row menu could do is reached here — including moving, which is why this component owns the folder
50
+ * picker rather than being handed one.
51
+ *
52
+ * ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode and no
53
+ * links, a flow has no version history of the node kind. A greyed-out row still promises something
54
+ * is there. Sharing applies to all of them since #530.
55
+ */
56
+ export type ResourceTarget = { type: "node"; node: Node } | { type: "flow"; flow: Flow };
57
+
58
+ // The same thing as a tree row, because that is what a move works on: a row with a place in the
59
+ // shared tree. The two shapes are the same record seen from two screens, so this is a re-labelling
60
+ // and never a second source of truth.
61
+ function entryOf(target: ResourceTarget): TreeEntry {
62
+ return target.type === "flow"
63
+ ? flowEntry(target.flow)
64
+ : {
65
+ type: "node",
66
+ id: target.node.id,
67
+ title: target.node.title,
68
+ kind: target.node.kind,
69
+ // A menu moves a row; whether it can be expanded plays no part in that.
70
+ openable: false,
71
+ node: target.node,
72
+ };
73
+ }
74
+
75
+ function idOf(target: ResourceTarget): string {
76
+ return target.type === "node" ? target.node.id : target.flow.id;
77
+ }
78
+
79
+ function titleOf(target: ResourceTarget): string {
80
+ return target.type === "node" ? target.node.title : target.flow.title;
81
+ }
82
+
83
+ /**
84
+ * Which sentence explains a verb.
85
+ *
86
+ * ⚠️ The hint is about the SUBJECT as much as the verb, and the branch came back with #530. "See
87
+ * everything in here" is right for a folder and wrong for one flow, and a hint describing the wrong
88
+ * reach is worse than none — it is the sentence somebody reads INSTEAD of thinking about what they
89
+ * are granting. The wording that used to fork here was the agent's (#143), parked with it (#388);
90
+ * this is a different fork, on the subject rather than on a second meaning of one word.
91
+ */
92
+ export function verbHintKey(verb: ResourceVerb, subject: ResourceTarget["type"] = "node"): string {
93
+ return `${subject}.verbHint.${verb}`;
94
+ }
95
+
96
+ /**
97
+ * The three-dot menu.
98
+ *
99
+ * `variant` is only how the trigger is painted, never which entries it holds. `title` is a plain
100
+ * icon button at the end of a title line, put there by `TitleRow` and by nobody else, so it is
101
+ * always the last thing in that line (#53). `row` is the form for a row in a list — quiet until the
102
+ * row is hovered or something in it takes focus — and since #101 that list is the folder table.
103
+ *
104
+ * ⚠️ `row` needs a `group/row` on the row it sits in, or it stays invisible to the mouse: the
105
+ * reveal is `group-hover/row`, and a group that is not there never hovers. Below `md` and under
106
+ * focus it is shown regardless, because hover is not a gesture every input has.
107
+ */
108
+ export function ResourceMenu({
109
+ target,
110
+ variant,
111
+ }: {
112
+ target: ResourceTarget;
113
+ variant: "row" | "title";
114
+ }) {
115
+ const { data } = useIntelRouterContext();
116
+ const i18n = useI18n();
117
+ const dateTime = useDateTime();
118
+ const navigate = useNavigate();
119
+ const [renaming, setRenaming] = useState(false);
120
+ const [sharing, setSharing] = useState(false);
121
+ const [linksOpen, setLinksOpen] = useState(false);
122
+ const [promptOpen, setPromptOpen] = useState(false);
123
+ const [versionsOpen, setVersionsOpen] = useState(false);
124
+ const [columnsOpen, setColumnsOpen] = useState(false);
125
+ const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
126
+ const pathname = useRouterState({ select: (state) => state.location.pathname });
127
+ // ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
128
+ // the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
129
+ // the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
130
+ const move = useTreeMove();
131
+ // ⚠️ Not a query. A validation is a snapshot taken when somebody asks (#72, ADR-0003): cached
132
+ // under a key it would be re-served later as if it still held, and the one thing it must never
133
+ // claim is that a moment which has passed is still true.
134
+ const [validation, setValidation] = useState<FlowValidation | null>(null);
135
+ const validate = useMutation({
136
+ mutationFn: () => data.validateFlow(id),
137
+ onSuccess: setValidation,
138
+ });
139
+
140
+ const id = idOf(target);
141
+ const title = titleOf(target);
142
+ const node = target.type === "node" ? target.node : null;
143
+ // A folder holds things; it is not itself retrieved, and nothing is written into it that could
144
+ // link anywhere. Both entries are therefore absent on one rather than offered and inert.
145
+ const retrievable = node !== null && node.kind !== "folder";
146
+ const linkable = retrievable;
147
+ // ⚠️ Absent, not disabled — the rule at the top of this file. An import needs a folder to file
148
+ // into; a document has nowhere to put a subtree, and a greyed-out entry would promise it does.
149
+ const importable = node !== null && node.kind === "folder";
150
+ // ⚠️ Only a table has a header to change, so only a table carries the entry — absent, not
151
+ // disabled, the rule at the top of this file. Until #531 the header could be changed over MCP and
152
+ // nowhere else: a person had strictly less reach on their own table than a model did.
153
+ const isTable = node !== null && node.kind === "table";
154
+ const nodeImport = useNodeImport({ where: title });
155
+
156
+ // What a change here makes stale, and the door a rename takes: both live in
157
+ // `rename-resource` since #823, because the tree renames through the same two rules from its own
158
+ // row. A copy of either would be a second place for one of them to drift.
159
+ const refresh = useResourceRefresh(target);
160
+ const rename = useResourceRename(target, () => setRenaming(false));
161
+
162
+ // Both records take the same conflict route as a rename: `baseUpdatedAt` from the row that was on
163
+ // screen, so archiving something somebody else has just changed is refused rather than silently
164
+ // applied to a different state than the one that was read.
165
+ /**
166
+ * Offering a document as a slash command, or taking the offer back (#775).
167
+ *
168
+ * ⚠️ It writes through `node_update` rather than a door of its own: the name is a mutable field
169
+ * of the document, and `update` is the verb this repository gives mutable fields. A dedicated
170
+ * `node_prompt_set` would be a second write path onto one column, with its own conflict handling
171
+ * to keep in step.
172
+ */
173
+ const offerPrompt = useMutation({
174
+ mutationFn: async (promptName: string | null) => {
175
+ if (target.type !== "node") throw new Error("Only a document can be offered");
176
+ await data.updateNode({
177
+ nodeId: target.node.id,
178
+ baseUpdatedAt: target.node.updatedAt,
179
+ promptName,
180
+ idempotencyKey: crypto.randomUUID(),
181
+ });
182
+ },
183
+ onSuccess: async () => {
184
+ await refresh();
185
+ setPromptOpen(false);
186
+ },
187
+ });
188
+
189
+ const archive = useMutation({
190
+ mutationFn: async () => {
191
+ if (target.type === "flow") {
192
+ await data.archiveFlow({
193
+ flowId: target.flow.id,
194
+ baseUpdatedAt: target.flow.updatedAt,
195
+ archived: true,
196
+ idempotencyKey: crypto.randomUUID(),
197
+ });
198
+ return;
199
+ }
200
+ await data.archiveNode({
201
+ nodeId: target.node.id,
202
+ baseUpdatedAt: target.node.updatedAt,
203
+ archived: true,
204
+ idempotencyKey: crypto.randomUUID(),
205
+ });
206
+ },
207
+ onSuccess: async () => {
208
+ await refresh();
209
+ // The row is gone from the tree. If it was also what the screen was showing, the screen has
210
+ // nothing left to show either.
211
+ //
212
+ // ⚠️ It drops the selection where it stands rather than always landing on /nodes: a flow
213
+ // archived from the flow screen would otherwise throw the reader onto another screen for no
214
+ // reason they could see. A flow reached through the shared tree stays on /nodes, which is
215
+ // where it was being read.
216
+ if (selected === id) {
217
+ await navigate({ to: pathname === "/flows" ? "/flows" : "/nodes", search: {} });
218
+ }
219
+ },
220
+ });
221
+
222
+ // An attachment already is one original file. Every structured Intel resource needs the bundle
223
+ // that carries its type and relationships, but wrapping uploaded bytes in a zip would make the
224
+ // one Export entry give a person something other than what they put in (#699).
225
+ const exportResource = useMutation({
226
+ mutationFn: async () => {
227
+ if (node?.kind === "attachment") {
228
+ return { blob: await data.getNodeAttachment(node.id), filename: title };
229
+ }
230
+ const blob =
231
+ target.type === "flow"
232
+ ? await data.exportFlowBundle(target.flow.id)
233
+ : await data.exportNodeBundle(target.node.id);
234
+ return { blob, filename: `${title}.zip` };
235
+ },
236
+ onSuccess: ({ blob, filename }) => {
237
+ const url = URL.createObjectURL(blob);
238
+ const anchor = document.createElement("a");
239
+ anchor.href = url;
240
+ anchor.download = filename;
241
+ document.body.append(anchor);
242
+ anchor.click();
243
+ anchor.remove();
244
+ setTimeout(() => URL.revokeObjectURL(url), 0);
245
+ },
246
+ });
247
+
248
+ const failure = rename.isError
249
+ ? null // the rename dialog words its own refusal, beside the field that caused it
250
+ : archive.isError
251
+ ? archive.error
252
+ : undefined;
253
+
254
+ return (
255
+ <>
256
+ <DropdownMenu>
257
+ <DropdownMenuTrigger
258
+ data-resource-menu=""
259
+ aria-label={i18n.t("resource.menu", { title })}
260
+ className={
261
+ variant === "row"
262
+ ? "inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
263
+ : "inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-muted outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
264
+ }
265
+ >
266
+ <Ellipsis aria-hidden="true" className="size-4" />
267
+ </DropdownMenuTrigger>
268
+ <DropdownMenuContent align="start" className="min-w-40 whitespace-nowrap">
269
+ <DropdownMenuItem onSelect={() => setRenaming(true)}>
270
+ <Pencil aria-hidden="true" />
271
+ {i18n.t("resource.rename")}
272
+ </DropdownMenuItem>
273
+ {/* Directly under the rename, because they are the same gesture at two levels: one names
274
+ the table, the other names its columns. A reader looking for "change what this is
275
+ called" finds both without reading the rest of the menu (#531). */}
276
+ {isTable ? (
277
+ <DropdownMenuItem onSelect={() => setColumnsOpen(true)}>
278
+ <Columns3 aria-hidden="true" />
279
+ {i18n.t("resource.columns")}
280
+ </DropdownMenuItem>
281
+ ) : null}
282
+ {/* The dialog stays the complete keyboard and touch path. Dragging is only a mouse
283
+ shortcut; it does not need a second label beside this action. */}
284
+ {/* ⚠️ `undefined`, and that is the honest answer rather than a missing one: this menu hangs
285
+ on a resource's own title line, so there is no rendered tree level to name. Passing the
286
+ record's parent instead would claim to know a level — and for a shared row it would
287
+ name the sharer's folder, which is exactly the wrong one (#446). `useTreeMove` reads
288
+ the absence and cleans up both levels such a row can be standing in. */}
289
+ <DropdownMenuItem onSelect={() => move.start(entryOf(target), undefined, null)}>
290
+ <CornerLeftUp aria-hidden="true" />
291
+ {i18n.t("resource.move")}
292
+ </DropdownMenuItem>
293
+ {/* Every kind exports: a folder takes its subtree along, everything else is a bundle of
294
+ one (#136). The entry sits with rename and move because it acts on the whole thing,
295
+ not on its content.
296
+
297
+ ⚠️ A table has NO second entry beside this one (#532). It used to carry "Download CSV"
298
+ here, and for a table WITH a header both handed out the same bytes: `bundle.ts` writes
299
+ such a node into the zip as `<title>.csv`. Two ways to one file are two strings, two
300
+ catalogs and two tests — the zip costs one step more and is the whole difference.
301
+
302
+ ⚠️ The one case where they differed is the table that has no header yet: `downloadCsv`
303
+ refused it out loud, `tableCsv` joins no segments and writes a file of zero bytes
304
+ (#534). That is the export's gap on every kind it can hit, not something this entry
305
+ was covering — which is why it is fixed there and not by keeping a second entry.
306
+
307
+ ⚠️ The entry carried a three-clause description under its name until 2026-08-19, put
308
+ there by #433/ADR-0006 so that an irreversible loss was readable BEFORE the gesture: a
309
+ bundle takes one version per node, shares and runs stay behind, and a published flow
310
+ travels as its published graph (#585). Jack removed it — it was the only entry running
311
+ over two lines and it pushed the menu apart. What the reader now gets instead is
312
+ `excluded` in the zip's `manifest.json` and `BundleImportResult.excluded` after an
313
+ import, both of which are read only once the zip exists. That is a deliberate trade,
314
+ recorded in ADR-0006 and in #636, and NOT a leftover to be restored. */}
315
+ <DropdownMenuItem onSelect={() => exportResource.mutate()}>
316
+ <FolderDown aria-hidden="true" />
317
+ {i18n.t("resource.export")}
318
+ </DropdownMenuItem>
319
+ {/* The other half of the same round trip, next to it rather than in the tree's plus
320
+ (#346): one word in the menu, and every way in behind it.
321
+
322
+ ⚠️ It was a submenu of two until #822 — a zip and a folder — and a single file was
323
+ missing from it, because the entry for that one lived only in the tree's plus. All
324
+ three answer the same question, so the submenu became one entry and the window behind
325
+ it carries the choice. */}
326
+ {importable ? (
327
+ <DropdownMenuItem onSelect={() => nodeImport.open(id)}>
328
+ <FolderUp aria-hidden="true" />
329
+ {i18n.t("resource.import")}
330
+ </DropdownMenuItem>
331
+ ) : null}
332
+ {/* Only a flow can be run, so only a flow can be asked whether it would. Rarely needed —
333
+ which is why it is in the menu and not in the title line (#72). */}
334
+ {node === null ? (
335
+ <DropdownMenuItem onSelect={() => validate.mutate()}>
336
+ <ShieldCheck aria-hidden="true" />
337
+ {i18n.t("resource.validate")}
338
+ </DropdownMenuItem>
339
+ ) : null}
340
+ {linkable ? (
341
+ <DropdownMenuItem onSelect={() => setLinksOpen(true)}>
342
+ <Link2 aria-hidden="true" />
343
+ {i18n.t("resource.links")}
344
+ </DropdownMenuItem>
345
+ ) : null}
346
+ {/* ⚠️ Only a document, and only ever an icon and a word here. The command itself stands in
347
+ the title line of the open node; repeating it inside the menu would be a second place
348
+ saying the same thing to a reader who is already looking at the first (#775). */}
349
+ {node !== null && node.kind === "document" ? (
350
+ <DropdownMenuItem onSelect={() => setPromptOpen(true)}>
351
+ <SlashSquare aria-hidden="true" />
352
+ {i18n.t("prompt.menu")}
353
+ </DropdownMenuItem>
354
+ ) : null}
355
+ {/* Every kind of resource is shared here, a flow included since #530. What a grant on one
356
+ flow does NOT reach is said in the dialog rather than refused here. */}
357
+ <DropdownMenuItem onSelect={() => setSharing(true)}>
358
+ <Share2 aria-hidden="true" />
359
+ {i18n.t("resource.share")}
360
+ </DropdownMenuItem>
361
+ {(node === null || node.kind !== "folder") && (
362
+ <DropdownMenuItem onSelect={() => setVersionsOpen(true)}>
363
+ <History aria-hidden="true" />
364
+ {i18n.t("node.versions")}
365
+ </DropdownMenuItem>
366
+ )}
367
+ {/* A flow is archived like a document (#109). It keeps its versions and its runs; what it
368
+ loses is reach — it leaves the tree, cannot be started, and stops resolving as another
369
+ flow's callee. */}
370
+ <DropdownMenuItem onSelect={() => archive.mutate()}>
371
+ <Archive aria-hidden="true" />
372
+ {i18n.t("resource.archive")}
373
+ </DropdownMenuItem>
374
+ </DropdownMenuContent>
375
+ </DropdownMenu>
376
+ {/* The menu closes on selection, so a refusal has nowhere to live inside it. It stands over
377
+ the screen instead, where it is read whatever the title line asked for. */}
378
+ {failure !== undefined && failure !== null ? (
379
+ <MenuFailure>{i18n.t(resourceErrorKey(failure))}</MenuFailure>
380
+ ) : null}
381
+ {/* Its own sentence, not `resourceErrorKey`'s: nothing was changed, something failed to
382
+ arrive, and "the change was not saved" would send the reader looking for a change. */}
383
+ {exportResource.isError ? <MenuFailure>{i18n.t("resource.exportFailed")}</MenuFailure> : null}
384
+ {/* The import's own sentence, and its own pickers — only where the entry exists, because two
385
+ file inputs on every document's menu would be two elements nothing can ever open. */}
386
+ {importable ? nodeImport.inputs : null}
387
+ {/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
388
+ `resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
389
+ among the changes above, and one shared sentence would leave the reader guessing. */}
390
+ {move.error ? <MenuFailure>{i18n.t(moveErrorKey(move.error))}</MenuFailure> : null}
391
+ {/* ⚠️ Every reason at once, not the first one. Somebody with two missing tools should not have
392
+ to ask twice — and a success says so out loud, because an empty menu after a click reads
393
+ as "nothing happened" rather than "nothing is in the way". */}
394
+ {validation ? (
395
+ <Modal title={i18n.t("flows.validate")} close={() => setValidation(null)}>
396
+ {validation.problems.length === 0 ? (
397
+ <p className="text-sm">{i18n.t("flows.validateReady")}</p>
398
+ ) : (
399
+ <ul className="space-y-2">
400
+ {/* ⚠️ Grouped by code, one entry per reason with every detail under it (#324).
401
+ `validateFlow` files one problem per sub-flow node, so a flow calling two
402
+ unpublished flows — the NORMAL case of that error, not its edge — produced two
403
+ entries whose head sentence is the same line of text, one under the other, and
404
+ two React children under one key.
405
+
406
+ Grouping rather than making the keys unique, because the head sentence is per CODE
407
+ (`flows.problem.<code>` is one string) and repeating it under itself says nothing
408
+ the reader did not just read. What differs is the detail, and every detail is
409
+ still here — which is the promise the comment above makes: somebody with two
410
+ missing tools should not have to ask twice.
411
+
412
+ ⚠️ What identifies a detail is its POSITION in `problems`, not its text (#461).
413
+ Two details of one code are routinely the same sentence: `collectRunProblems`
414
+ files one problem per sub-flow NODE and words it with that node's label
415
+ (`packages/intel/src/flows/flows.ts`), and calling the same flow twice is the normal
416
+ case. Under `key={detail}` those two collided on one key, and React does not
417
+ answer that with a console warning alone — it drops one of the siblings on the
418
+ next render, so the reader sees ONE entry where there are two causes. */}
419
+ {[
420
+ ...validation.problems
421
+ .reduce((byCode, problem, at) => {
422
+ byCode.set(problem.code, [
423
+ ...(byCode.get(problem.code) ?? []),
424
+ { at, detail: problem.detail },
425
+ ]);
426
+ return byCode;
427
+ }, new Map<string, { at: number; detail: string }[]>())
428
+ .entries(),
429
+ ].map(([code, details]) => (
430
+ <li key={code} className="rounded-md border p-3 text-sm">
431
+ <span className="block font-medium">{i18n.t(`flows.problem.${code}`)}</span>
432
+ {details.map(({ at, detail }) => (
433
+ <span key={at} className="mt-1 block text-xs text-muted-foreground">
434
+ {detail}
435
+ </span>
436
+ ))}
437
+ </li>
438
+ ))}
439
+ </ul>
440
+ )}
441
+ {/* A snapshot says when it was taken, or it will be read as a standing verdict. */}
442
+ <p className="mt-4 text-xs text-muted-foreground">
443
+ {i18n.t("flows.validateWhen", {
444
+ when: dateTime.at(validation.checkedAt),
445
+ })}
446
+ </p>
447
+ </Modal>
448
+ ) : null}
449
+ {move.dialog}
450
+ {renaming ? (
451
+ <RenameDialog
452
+ title={title}
453
+ pending={rename.isPending}
454
+ error={rename.isError ? rename.error : null}
455
+ close={() => {
456
+ rename.reset();
457
+ setRenaming(false);
458
+ }}
459
+ submit={(next) => rename.mutate(next)}
460
+ />
461
+ ) : null}
462
+ {sharing ? <SharePanel target={target} close={() => setSharing(false)} /> : null}
463
+ {linksOpen && node ? <NodeLinksPanel node={node} close={() => setLinksOpen(false)} /> : null}
464
+ {promptOpen && node ? (
465
+ <PromptDialog
466
+ node={node}
467
+ pending={offerPrompt.isPending}
468
+ error={offerPrompt.error}
469
+ close={() => {
470
+ offerPrompt.reset();
471
+ setPromptOpen(false);
472
+ }}
473
+ submit={(promptName) => offerPrompt.mutate(promptName)}
474
+ />
475
+ ) : null}
476
+ {columnsOpen && node ? (
477
+ <TableColumnsDialog node={node} close={() => setColumnsOpen(false)} />
478
+ ) : null}
479
+ {versionsOpen ? (
480
+ <VersionHistory target={target} close={() => setVersionsOpen(false)} />
481
+ ) : null}
482
+ </>
483
+ );
484
+ }
485
+
486
+ function VersionHistory({ target, close }: { target: ResourceTarget; close(): void }) {
487
+ const { data } = useIntelRouterContext();
488
+ const i18n = useI18n();
489
+ const id = idOf(target);
490
+ const versions = useQuery({
491
+ queryKey: [target.type === "flow" ? "flow-versions" : "node-versions", id],
492
+ queryFn: async () => {
493
+ const result =
494
+ target.type === "flow" ? await data.listFlowVersions(id) : await data.listNodeVersions(id);
495
+ return result.items.map((version) => ({
496
+ id: version.id,
497
+ sequence: version.sequence,
498
+ createdAt: version.createdAt,
499
+ }));
500
+ },
501
+ });
502
+ // ⚠️ A Modal, like every other panel in this menu — NOT an `absolute` drawer. `absolute` measures
503
+ // against the nearest positioned ancestor, and this menu has no say in what that is: on the title
504
+ // line it is the title row (`title-row.tsx`, `relative z-10`, around 100px tall), which clipped
505
+ // the list to its first entry on every viewport; in the folder listing it is the table container
506
+ // (`table.tsx`, `relative … overflow-x-auto`), which clips it differently again (#628). The list
507
+ // scrolls INSIDE the dialog instead, the way `NodeLinksPanel` further down does it.
508
+ return (
509
+ <Modal title={i18n.t("node.versions")} close={close}>
510
+ {versions.isPending ? (
511
+ <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
512
+ ) : null}
513
+ {versions.isError ? (
514
+ <p role="alert" className="text-sm text-destructive">
515
+ {i18n.t(target.type === "flow" ? "flows.operationFailed" : "node.operationFailed")}
516
+ </p>
517
+ ) : null}
518
+ <ol className="max-h-72 space-y-3 overflow-y-auto">
519
+ {versions.data?.map((version) => (
520
+ <li key={version.id} className="rounded-md border p-3 text-sm">
521
+ <span className="font-medium">
522
+ {i18n.t("node.version", { sequence: version.sequence })}
523
+ </span>
524
+ <RelativeTime
525
+ value={version.createdAt}
526
+ className="mt-1 block text-xs text-muted-foreground"
527
+ />
528
+ </li>
529
+ ))}
530
+ </ol>
531
+ </Modal>
532
+ );
533
+ }
534
+
535
+ // One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
536
+ function MenuFailure({ children }: { children: React.ReactNode }) {
537
+ return (
538
+ <p
539
+ role="alert"
540
+ className="fixed inset-x-0 bottom-5 z-50 mx-auto w-fit max-w-md rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
541
+ >
542
+ {children}
543
+ </p>
544
+ );
545
+ }
546
+
547
+ /**
548
+ * ⚠️ **A taken name is the one refusal this dialog words itself**, because it is the one the reader
549
+ * can act on inside the dialog: change the name and try again. Every other refusal keeps the
550
+ * server's sentence through `resourceErrorKey`.
551
+ *
552
+ * The name of the HOLDER is deliberately not brought over. The server names it — it is the half a
553
+ * model reading over MCP needs, and it knows who may be told — but on this screen the reader is
554
+ * about to type a different name either way, and a title out of somewhere they may not look is a
555
+ * sentence with nothing to do about it.
556
+ */
557
+ function isPromptNameTaken(error: unknown): boolean {
558
+ return (
559
+ typeof error === "object" &&
560
+ error !== null &&
561
+ "code" in error &&
562
+ (error as { code: unknown }).code === "prompt_name_taken"
563
+ );
564
+ }
565
+
566
+ /**
567
+ * The one place a document is offered as a slash command, or stops being one (#775).
568
+ *
569
+ * ⚠️ **No "that name is free" box, and it is a decision rather than an omission.** Free is the
570
+ * normal case; a box that says the self-evident thing every time the dialog opens is read twice and
571
+ * then never again. Only the TAKEN name is reported, under the field, with `Save` disabled while it
572
+ * stands — the state the reader has to do something about is the only one that speaks.
573
+ *
574
+ * ⚠️ **The empty field is how a command is given up.** There is no separate switch: `promptName`
575
+ * has two states and the field has two states, and a checkbox beside it would invent a third that
576
+ * nothing can store — offered, without a name.
577
+ *
578
+ * ⚠️ **The refusal comes from the SERVER and is only mirrored here.** Uniqueness spans every
579
+ * document and flow in the installation, which no dialog can know; what this does is stop sending
580
+ * the same name a second time. Deciding freeness in the browser would be the race this whole guard
581
+ * exists against.
582
+ */
583
+ function PromptDialog({
584
+ node,
585
+ pending,
586
+ error,
587
+ close,
588
+ submit,
589
+ }: {
590
+ node: Node;
591
+ pending: boolean;
592
+ error: unknown;
593
+ close(): void;
594
+ submit(promptName: string | null): void;
595
+ }) {
596
+ const i18n = useI18n();
597
+ const [next, setNext] = useState(node.promptName ?? "");
598
+ // ⚠️ **Which value was SENT, not which one was refused**, and the difference is what lets `Save`
599
+ // come back the moment the reader types something else. A refusal about `billing` says nothing
600
+ // about `billing-monthly`; a message that outlived its name would block the very repair it asks
601
+ // for. Recorded at submit time rather than derived from the error in an effect — the error says
602
+ // THAT something was refused, and only the form knows what it sent.
603
+ const [sent, setSent] = useState<string | null>(null);
604
+ const trimmed = next.trim();
605
+ const grammar = /^[a-z0-9-]{1,40}$/;
606
+ const malformed = trimmed.length > 0 && !grammar.test(trimmed);
607
+ const stillRefused = isPromptNameTaken(error) && sent === trimmed;
608
+ const unchanged = (node.promptName ?? "") === trimmed;
609
+
610
+ return (
611
+ <Modal title={i18n.t("prompt.title")} close={close}>
612
+ <form
613
+ className="space-y-4"
614
+ onSubmit={(event) => {
615
+ event.preventDefault();
616
+ if (pending || malformed || stillRefused || unchanged) return;
617
+ setSent(trimmed);
618
+ submit(trimmed.length === 0 ? null : trimmed);
619
+ }}
620
+ >
621
+ <label className="block text-sm font-medium">
622
+ {i18n.t("prompt.field")}
623
+ <span className="mt-2 flex items-center rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
624
+ <span aria-hidden="true" className="pl-3 text-muted-foreground">
625
+ /
626
+ </span>
627
+ <input
628
+ // biome-ignore lint/a11y/noAutofocus: the dialog opens to take this one value
629
+ autoFocus
630
+ value={next}
631
+ onChange={(event) => setNext(event.target.value)}
632
+ className="w-full bg-transparent px-2 py-2 outline-none"
633
+ />
634
+ </span>
635
+ </label>
636
+ {malformed ? (
637
+ <p role="alert" className="text-sm text-destructive">
638
+ {i18n.t("prompt.invalid")}
639
+ </p>
640
+ ) : null}
641
+ {stillRefused ? (
642
+ <p role="alert" className="text-sm text-destructive">
643
+ {i18n.t("prompt.taken", { name: trimmed })}
644
+ </p>
645
+ ) : null}
646
+ {/* Everything else the field refuses is said by the server, and its sentence is the one that
647
+ knows what happened. */}
648
+ {error && !isPromptNameTaken(error) ? (
649
+ <p role="alert" className="text-sm text-destructive">
650
+ {i18n.t(resourceErrorKey(error))}
651
+ </p>
652
+ ) : null}
653
+ <p className="text-sm text-muted-foreground">{i18n.t("prompt.help")}</p>
654
+ <button
655
+ type="submit"
656
+ disabled={pending || malformed || stillRefused || unchanged}
657
+ className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
658
+ >
659
+ {pending ? i18n.t("common.saving") : i18n.t("common.save")}
660
+ </button>
661
+ </form>
662
+ </Modal>
663
+ );
664
+ }
665
+
666
+ function RenameDialog({
667
+ title,
668
+ pending,
669
+ error,
670
+ close,
671
+ submit,
672
+ }: {
673
+ title: string;
674
+ pending: boolean;
675
+ error: unknown;
676
+ close(): void;
677
+ submit(title: string): void;
678
+ }) {
679
+ const i18n = useI18n();
680
+ const [next, setNext] = useState(title);
681
+ const trimmed = next.trim();
682
+ return (
683
+ <Modal title={i18n.t("resource.renameTitle", { title })} close={close}>
684
+ <form
685
+ className="space-y-4"
686
+ onSubmit={(event) => {
687
+ event.preventDefault();
688
+ if (!pending && trimmed.length > 0 && trimmed !== title) submit(trimmed);
689
+ }}
690
+ >
691
+ {error ? (
692
+ <p role="alert" className="text-sm text-destructive">
693
+ {i18n.t(resourceErrorKey(error))}
694
+ </p>
695
+ ) : null}
696
+ <label className="block text-sm font-medium">
697
+ {i18n.t("common.title")}
698
+ <input
699
+ required
700
+ // biome-ignore lint/a11y/noAutofocus: the dialog opens to take this one value
701
+ autoFocus
702
+ value={next}
703
+ onChange={(event) => setNext(event.target.value)}
704
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
705
+ />
706
+ </label>
707
+ <button
708
+ type="submit"
709
+ disabled={pending || trimmed.length === 0 || trimmed === title}
710
+ className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
711
+ >
712
+ {pending ? i18n.t("common.saving") : i18n.t("common.save")}
713
+ </button>
714
+ </form>
715
+ </Modal>
716
+ );
717
+ }
718
+
719
+ /**
720
+ * What links to this document and what it links to — as a reading, not as a form (#41).
721
+ *
722
+ * ⚠️ There is nothing to create here any more. A relationship is written in the text of the
723
+ * document it belongs to, and this shows the result. The backlinks are the half that could never be
724
+ * written here anyway: "who points at me" is not something the author of this document decides.
725
+ *
726
+ * ⚠️ Every row is a link whose two ends this reader may see — the server filters the list through
727
+ * the same predicate as every other read. A relationship reaching into a part of the tree they may
728
+ * not enter is absent rather than shown without a name.
729
+ */
730
+ function NodeLinksPanel({ node, close }: { node: Node; close(): void }) {
731
+ const { data } = useIntelRouterContext();
732
+ const i18n = useI18n();
733
+ const navigate = useNavigate();
734
+ const links = useQuery({
735
+ queryKey: ["node-links", node.id],
736
+ queryFn: () => data.listNodeLinks(node.id),
737
+ });
738
+ // The dialog needs the names of the documents at the other end, and the graph is the one
739
+ // authorized list of them the UI can ask for without walking the whole tree again. It is asked
740
+ // for here rather than by the screen, because here is where it is read.
741
+ const nodeGraph = useQuery({
742
+ queryKey: ["node-graph"],
743
+ queryFn: () => data.getNodeGraph(),
744
+ });
745
+ const titles = new Map(
746
+ (nodeGraph.data?.nodes ?? []).map((candidate) => [candidate.id, candidate.title]),
747
+ );
748
+
749
+ return (
750
+ <Modal title={i18n.t("node.links")} close={close}>
751
+ <p className="mb-4 text-sm text-muted-foreground">{i18n.t("node.linksHelp")}</p>
752
+ <ul className="max-h-72 space-y-2 overflow-y-auto">
753
+ {links.data?.items.map((link) => {
754
+ const outgoing = link.sourceNodeId === node.id;
755
+ const otherId = outgoing ? link.targetNodeId : link.sourceNodeId;
756
+ return (
757
+ <li key={link.id}>
758
+ <button
759
+ type="button"
760
+ onClick={() => {
761
+ close();
762
+ void navigate({ to: "/nodes", search: { select: otherId } });
763
+ }}
764
+ className="flex w-full items-center gap-3 rounded-md border p-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
765
+ >
766
+ <span className="min-w-0 text-sm">
767
+ <span className="block truncate font-medium">
768
+ {titles.get(otherId) ?? otherId}
769
+ </span>
770
+ <span className="text-xs text-muted-foreground">
771
+ {outgoing ? i18n.t("node.linkOutgoing") : i18n.t("node.linkIncoming")}
772
+ </span>
773
+ </span>
774
+ </button>
775
+ </li>
776
+ );
777
+ })}
778
+ {links.data?.items.length === 0 && (
779
+ <li className="text-sm text-muted-foreground">{i18n.t("node.noLinks")}</li>
780
+ )}
781
+ </ul>
782
+ </Modal>
783
+ );
784
+ }
785
+
786
+ // The one share dialog in the product, for every kind of resource that can be shared — a folder, a
787
+ // document, a table, and since #530 a flow on its own. Which verbs it offers is the server's
788
+ // answer, not this component's: `execute` never appears on a document, because a document has
789
+ // nothing to run, and all four appear on a flow.
790
+ /**
791
+ * Which of the three principals the dialog can SET (#431).
792
+ *
793
+ * ⚠️ `user` is deliberately absent, and it is a decision rather than an omission. Setting one means
794
+ * naming a Gate user id, and Intel has no way to turn a person into an id or an id into a person
795
+ * (#261) — so the only offer this dialog could make is a field for a raw ULID, which is worse than
796
+ * the address it would replace: unverifiable when typed and unreadable afterwards. It stays reachable
797
+ * over the API and over MCP, where the caller already holds the id. The day #261 lands it becomes a
798
+ * picker, not a text field.
799
+ *
800
+ * ⚠️ `organization` IS here, and the reason is the asymmetry it removes: the dialog could already
801
+ * DRAW an organization grant ("Everyone in the organization") and not make one, and MCP could make
802
+ * one all along — a person had strictly less reach than a model on the same tree. It needs nothing
803
+ * Intel does not have, because there is nothing to name.
804
+ */
805
+ type SharePrincipalKind = "email" | "organization";
806
+
807
+ // What the grant just made does not cover, in the two directions it can fail to (#530): documents
808
+ // the grantee cannot read, and flows the grantee cannot start. Both are `null` until an answer
809
+ // arrives, so "no warning yet" and "nothing to warn about" stay distinguishable.
810
+ type ShareWarnings = { unreadable: UnreadableNodes; unrunnable: UnreadableNodes } | null;
811
+
812
+ // Whether there is anything to say. A warning with neither a title nor a count is silence, and
813
+ // drawing an empty box for it would make every ordinary grant look like it had a caveat.
814
+ function withheld(value: UnreadableNodes): boolean {
815
+ return value.titles.length > 0 || value.hidden > 0;
816
+ }
817
+
818
+ function SharePanel({ target, close }: { target: ResourceTarget; close(): void }) {
819
+ const { data } = useIntelRouterContext();
820
+ const i18n = useI18n();
821
+ const queryClient = useQueryClient();
822
+ const [principalKind, setPrincipalKind] = useState<SharePrincipalKind>("email");
823
+ const [email, setEmail] = useState("");
824
+ const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
825
+ // What the grant just made does not cover. It survives the form being cleared, because it is the
826
+ // answer to the question the user just asked and they need a moment to read it.
827
+ const [warnings, setWarnings] = useState<ShareWarnings>(null);
828
+ const isFlow = target.type === "flow";
829
+ const resourceId = idOf(target);
830
+ const grants = useQuery({
831
+ queryKey: ["resource-grants", target.type, resourceId],
832
+ queryFn: () => (isFlow ? data.listFlowGrants(resourceId) : data.listGrants(resourceId)),
833
+ });
834
+ // ⚠️ TWO questions, and neither list answers the other's. `listGrants` names the grants THIS node
835
+ // carries — the ones this screen may withdraw — and it is the one that brings `applicableVerbs`,
836
+ // which decides what the form below may offer at all (ADR-0004 §2). The effective list is the
837
+ // DISPLAY answer: it crosses every folder above and names owners, who reach the node without a
838
+ // revocable row of their own.
839
+ //
840
+ // ⚠️ Showing only the first was the bug in #803: the circles in the header read the effective
841
+ // list, this dialog read the direct one, and the two answered "who has access" differently — the
842
+ // dialog stayed empty while the header showed people. The same key as the header's, so both are
843
+ // invalidated by one mutation and can never drift apart again.
844
+ const effective = useQuery({
845
+ queryKey: ["effective-node-grants", target.type, resourceId],
846
+ queryFn: () =>
847
+ isFlow ? data.listEffectiveFlowAccess(resourceId) : data.listEffectiveAccess(resourceId),
848
+ retry: false,
849
+ staleTime: Number.POSITIVE_INFINITY,
850
+ });
851
+ // ⚠️ Both queries are invalidated by every mutation below, and the flow key is under the same
852
+ // effective prefix as the node key: a grant on a flow changes what the summary of the folder
853
+ // above it shows, and the other way round.
854
+ const invalidate = async () => {
855
+ await Promise.all([
856
+ queryClient.invalidateQueries({ queryKey: ["resource-grants", target.type, resourceId] }),
857
+ queryClient.invalidateQueries({ queryKey: ["effective-node-grants"] }),
858
+ ]);
859
+ };
860
+ const share = useMutation({
861
+ // One request per verb, because one grant is one verb. The keys differ so a retry of the whole
862
+ // form replays each verb on its own rather than collapsing them into one.
863
+ mutationFn: async () => {
864
+ let last: ShareWarnings = null;
865
+ for (const verb of verbs) {
866
+ const principal =
867
+ principalKind === "organization"
868
+ ? ({ type: "organization" } as const)
869
+ : ({ type: "email", email } as const);
870
+ // The last verb's answer is the one kept: every request describes the access in force after
871
+ // it, so the newest is the only one still true.
872
+ const result = isFlow
873
+ ? await data.shareFlow({
874
+ flowId: resourceId,
875
+ principal,
876
+ verb,
877
+ expiresAt: null,
878
+ idempotencyKey: crypto.randomUUID(),
879
+ })
880
+ : await data.shareNode({
881
+ resourceId,
882
+ principal,
883
+ verb,
884
+ expiresAt: null,
885
+ idempotencyKey: crypto.randomUUID(),
886
+ });
887
+ last = { unreadable: result.unreadable, unrunnable: result.unrunnable };
888
+ }
889
+ return last;
890
+ },
891
+ onSuccess: (result) => {
892
+ setEmail("");
893
+ // ⚠️ The choice is cleared with the address, not left standing. It survived a successful grant
894
+ // until the review of #431 caught it, and "everyone in the organization" is the one setting
895
+ // that must never be the quiet default for the NEXT grant somebody makes in the same dialog.
896
+ setPrincipalKind("email");
897
+ setWarnings(result);
898
+ },
899
+ // ⚠️ `onSettled`, not `onSuccess`. One request per verb means a run can end halfway: three verbs
900
+ // granted, the fourth refused. On `onSuccess` the list would then still show the state from
901
+ // before, and the user would read a permission picture that is not the one in force. Of all the
902
+ // things to be silently wrong about, access is the worst.
903
+ onSettled: invalidate,
904
+ });
905
+ const revoke = useMutation({
906
+ mutationFn: (grantId: string) =>
907
+ isFlow
908
+ ? data.revokeFlowGrant({
909
+ flowId: resourceId,
910
+ grantId,
911
+ idempotencyKey: crypto.randomUUID(),
912
+ })
913
+ : data.revokeGrant({
914
+ resourceId,
915
+ grantId,
916
+ idempotencyKey: crypto.randomUUID(),
917
+ }),
918
+ onSettled: invalidate,
919
+ });
920
+
921
+ /**
922
+ * The one refusal in this dialog that names something the reader has to act on.
923
+ *
924
+ * ⚠️ Taking back `organization` + `execute` on a folder is refused with `409
925
+ * folder_execute_in_use` while flows from outside still call into it (`nodes.ts`,
926
+ * `revokeGrant`), and the CALLERS are what makes that refusal actionable. Rendering it as the
927
+ * generic "access was not changed" threw exactly that away and left the reader with a dialog that
928
+ * refuses and will not say why, for the one grant that is hardest to undo.
929
+ *
930
+ * ⚠️ Since #448 they arrive as data — the flows this actor may see by name, the rest as a count —
931
+ * so the sentence is written here, in the reader's language, exactly like the `shareUnreadable`
932
+ * warning below it. The server still decides WHICH of them may be named; that is an authorization
933
+ * answer and nothing on this side recomputes it.
934
+ */
935
+ const revokeError = revoke.error instanceof IntelRequestError ? revoke.error : null;
936
+ const revokeInUse = revokeError?.code === "folder_execute_in_use" ? revokeError : null;
937
+
938
+ return (
939
+ <Modal title={i18n.t("node.share")} close={close}>
940
+ {revokeInUse ? (
941
+ <div role="alert" className="mb-4 space-y-1 text-sm text-destructive">
942
+ <p>{i18n.t("node.revokeInUse")}</p>
943
+ {/* An API that predates #448 sends no `callers`, and its sentence is then the only place
944
+ the names exist at all. Showing it beats showing nothing — losing the titles is the
945
+ failure this ticket is about. */}
946
+ {revokeInUse.callers === null ? (
947
+ <p className="text-xs">{revokeInUse.message}</p>
948
+ ) : (
949
+ <p className="text-xs">
950
+ {revokeInUse.callers.titles.length > 0
951
+ ? i18n.t("node.revokeInUseCallers", {
952
+ titles: revokeInUse.callers.titles.join(", "),
953
+ })
954
+ : i18n.t("node.revokeInUseCallersHidden", { count: revokeInUse.callers.hidden })}
955
+ {revokeInUse.callers.titles.length > 0 && revokeInUse.callers.hidden > 0
956
+ ? ` ${i18n.t("node.revokeInUseCallersMore", { count: revokeInUse.callers.hidden })}`
957
+ : ""}
958
+ {` ${i18n.t("node.revokeInUseHint")}`}
959
+ </p>
960
+ )}
961
+ </div>
962
+ ) : (
963
+ (share.isError || revoke.isError || grants.isError) && (
964
+ <p role="alert" className="mb-4 text-sm text-destructive">
965
+ {i18n.t("node.shareFailed")}
966
+ </p>
967
+ )
968
+ )}
969
+ {/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
970
+ given. A node reference across the folder edge is a possible failure, not a way around
971
+ permissions, so nothing here blocks anything (ADR-0004 §4). */}
972
+ {warnings && withheld(warnings.unreadable) && (
973
+ <div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
974
+ {warnings.unreadable.titles.length > 0 ? (
975
+ <p>
976
+ {i18n.t(`${target.type}.shareUnreadable`, {
977
+ titles: warnings.unreadable.titles.join(", "),
978
+ })}
979
+ {warnings.unreadable.hidden > 0
980
+ ? ` ${i18n.t(`${target.type}.shareUnreadableMore`, { count: warnings.unreadable.hidden })}`
981
+ : ""}
982
+ </p>
983
+ ) : (
984
+ <p>
985
+ {i18n.t(`${target.type}.shareUnreadableHidden`, {
986
+ count: warnings.unreadable.hidden,
987
+ })}
988
+ </p>
989
+ )}
990
+ <p className="mt-1 text-xs text-muted-foreground">
991
+ {i18n.t(`${target.type}.shareUnreadableHint`)}
992
+ </p>
993
+ </div>
994
+ )}
995
+ {/* The half a folder grant never needed: a grant on ONE flow stops at that flow, so the flows
996
+ it calls are somebody else's to grant. `status` and not `alert` for the same reason as
997
+ above — the access was given, and this says what it does not reach (#530). */}
998
+ {warnings && withheld(warnings.unrunnable) && (
999
+ <div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
1000
+ {warnings.unrunnable.titles.length > 0 ? (
1001
+ <p>
1002
+ {i18n.t("flow.shareUnrunnable", { titles: warnings.unrunnable.titles.join(", ") })}
1003
+ {warnings.unrunnable.hidden > 0
1004
+ ? ` ${i18n.t("flow.shareUnrunnableMore", { count: warnings.unrunnable.hidden })}`
1005
+ : ""}
1006
+ </p>
1007
+ ) : (
1008
+ <p>{i18n.t("flow.shareUnrunnableHidden", { count: warnings.unrunnable.hidden })}</p>
1009
+ )}
1010
+ <p className="mt-1 text-xs text-muted-foreground">{i18n.t("flow.shareUnrunnableHint")}</p>
1011
+ </div>
1012
+ )}
1013
+ {effective.data && (effective.data.items.length > 0 || effective.data.ownerIds.length > 0) ? (
1014
+ <div className="mb-5">
1015
+ <AccessSummary
1016
+ grants={effective.data.items}
1017
+ ownerIds={effective.data.ownerIds}
1018
+ // Withdrawal is offered for the grants that sit HERE. An inherited one is in force but
1019
+ // belongs to the folder it was written on, and the reader may not manage that folder.
1020
+ revocableOn={resourceId}
1021
+ // Named only where the reader may see the folder — the answer leaves out the rest, and
1022
+ // the row then says "inherited" without saying from where.
1023
+ sources={effective.data.sources}
1024
+ onRevoke={(grantId) => {
1025
+ share.reset();
1026
+ revoke.mutate(grantId);
1027
+ }}
1028
+ />
1029
+ </div>
1030
+ ) : null}
1031
+ <form
1032
+ onSubmit={(event) => {
1033
+ event.preventDefault();
1034
+ // ⚠️ The other mutation's error is cleared before this one starts, in both directions. A
1035
+ // refused withdrawal keeps its `409` until something resets it, and `revokeInUse` takes
1036
+ // precedence over the general sentence — so without this, a share that failed AFTER a
1037
+ // refused withdrawal said nothing at all, and the reader was left reading a sentence
1038
+ // about the wrong action. That is the exact failure mode #430 removed one level up.
1039
+ revoke.reset();
1040
+ share.mutate();
1041
+ }}
1042
+ className="space-y-4 border-t pt-5"
1043
+ >
1044
+ {/* ⚠️ Two of the three principals, and the missing one is a decision — see
1045
+ `SharePrincipalKind`. Radios rather than a select: there are two, and a reader has to see
1046
+ that granting to the whole organization is a thing this dialog does at all. */}
1047
+ <fieldset className="space-y-2">
1048
+ <legend className="text-sm font-medium">{i18n.t("node.shareWith")}</legend>
1049
+ {(["email", "organization"] as const).map((kind) => (
1050
+ <label key={kind} className="flex items-center gap-2 text-sm">
1051
+ <input
1052
+ type="radio"
1053
+ name="share-principal"
1054
+ checked={principalKind === kind}
1055
+ onChange={() => setPrincipalKind(kind)}
1056
+ aria-describedby={kind === "organization" ? "share-organization-hint" : undefined}
1057
+ className="size-4 border outline-none focus-visible:ring-2 focus-visible:ring-ring"
1058
+ />
1059
+ <span>
1060
+ {i18n.t(kind === "email" ? "node.shareWithEmail" : "node.shareWithOrganization")}
1061
+ </span>
1062
+ </label>
1063
+ ))}
1064
+ </fieldset>
1065
+ {principalKind === "email" ? (
1066
+ <div>
1067
+ {/* ⚠️ The hint stands OUTSIDE the label and is tied on with `aria-describedby`. Inside
1068
+ it, its words would become part of the field's accessible NAME — a screen reader
1069
+ would announce the whole paragraph as the label of the input, and every query by
1070
+ label would have to spell it out too. */}
1071
+ <label className="block text-sm font-medium">
1072
+ {i18n.t("node.email")}
1073
+ <input
1074
+ type="email"
1075
+ required
1076
+ value={email}
1077
+ onChange={(event) => setEmail(event.target.value)}
1078
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
1079
+ />
1080
+ </label>
1081
+ </div>
1082
+ ) : (
1083
+ /* ⚠️ `aria-live` on the CONTAINER, which is already mounted whenever this branch is on
1084
+ screen — not on the warning inside it. A live region has to exist before its content
1085
+ changes, so putting it on a paragraph that appears at the same moment announces
1086
+ nothing. The gap it closes: `Run` is ticked while the focus sits in the verb fieldset,
1087
+ far from here, and the sentence about what is being made would otherwise arrive in
1088
+ silence. (The other way in is covered already: `aria-describedby` on the radio reads
1089
+ this whole box out when the radio takes focus.) */
1090
+ <div
1091
+ id="share-organization-hint"
1092
+ aria-live="polite"
1093
+ className="space-y-2 rounded-md border bg-muted p-3 text-xs"
1094
+ >
1095
+ <p className="text-muted-foreground">{i18n.t("node.shareOrganizationHint")}</p>
1096
+ {/* ⚠️ `organization` + `execute` on a folder is not one grant among four: it is the
1097
+ definition of a LIBRARY (ADR-0004 §3) — the one grant a flow may call across a folder
1098
+ edge for, so flows from anywhere in the tree may then reach in. Two clicks in this
1099
+ dialog now do what used to take an API call, and the reader has to be told what they
1100
+ are about to make BEFORE they make it.
1101
+
1102
+ ⚠️ The second sentence is the one that matters most, because it is about the way
1103
+ back: `revokeGrant` refuses with `folder_execute_in_use` while callers remain, so
1104
+ this is the single hardest grant in the product to undo. Naming the consequence after
1105
+ the fact would be the "wrong sentence" class anchrd/core#111 collects.
1106
+
1107
+ It is a warning and not a block, deliberately: the library is a documented, wanted
1108
+ shape, MCP has always been able to make one, and refusing it here would put a person
1109
+ back below a model on the same tree — the very asymmetry this ticket removed. */}
1110
+ {/* ⚠️ Only on a node, and that is not an oversight. What makes a folder a library is
1111
+ `execute` for the organization ON THE FOLDER: `callReach` reads `node_grants` along
1112
+ the callee's ancestors and nothing else, so the same grant on a flow itself makes no
1113
+ library and reaches no caller outside its subtree. Showing this line there would
1114
+ warn about a consequence that cannot happen — and about a refusal on the way back
1115
+ that would never come (#530). */}
1116
+ {!isFlow && verbs.includes("execute") ? (
1117
+ <p role="note" className="font-medium text-destructive">
1118
+ {i18n.t("node.shareLibraryWarning")}
1119
+ </p>
1120
+ ) : null}
1121
+ </div>
1122
+ )}
1123
+ <fieldset className="space-y-2">
1124
+ <legend className="text-sm font-medium">{i18n.t("node.verbs")}</legend>
1125
+ {(grants.data?.applicableVerbs ?? []).map((verb) => (
1126
+ <label key={verb} className="flex items-center gap-2 text-sm">
1127
+ <input
1128
+ type="checkbox"
1129
+ checked={verbs.includes(verb)}
1130
+ onChange={(event) =>
1131
+ setVerbs((current) =>
1132
+ event.target.checked
1133
+ ? [...current, verb]
1134
+ : current.filter((candidate) => candidate !== verb),
1135
+ )
1136
+ }
1137
+ className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
1138
+ />
1139
+ <span>{i18n.t(`node.verb.${verb}`)}</span>
1140
+ <span className="text-xs text-muted-foreground">
1141
+ {i18n.t(verbHintKey(verb, target.type))}
1142
+ </span>
1143
+ </label>
1144
+ ))}
1145
+ </fieldset>
1146
+ <button
1147
+ type="submit"
1148
+ disabled={share.isPending || verbs.length === 0}
1149
+ className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
1150
+ >
1151
+ {i18n.t("node.shareAction")}
1152
+ </button>
1153
+ </form>
1154
+ </Modal>
1155
+ );
1156
+ }