@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,1175 @@
1
+ import type { NodeKind } from "@contract/node.ts";
2
+ import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
3
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
4
+ import { ChevronRight, CornerLeftUp, FolderOpen, Plus, Upload } from "lucide-react";
5
+ import { useEffect, useId, useRef, useState } from "react";
6
+ import { createTreeExpansion } from "@/app/tree-expansion/tree-expansion.ts";
7
+ import type { ExpandedLevel } from "@/app/tree-expansion/tree-expansion.types.ts";
8
+ import {
9
+ allTreeLevelsKey,
10
+ type MoveDestination,
11
+ moveErrorKey,
12
+ moveVerdict,
13
+ parentOf,
14
+ treeLevelKey,
15
+ useTreeMove,
16
+ } from "@/app/tree-move/tree-move.tsx";
17
+ import { folderTrail } from "@/app/tree-reveal/tree-reveal.ts";
18
+ import type { RevealArea } from "@/app/tree-reveal/tree-reveal.types.ts";
19
+ import {
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuItem,
23
+ DropdownMenuSeparator,
24
+ DropdownMenuTrigger,
25
+ } from "@/components/ui/dropdown-menu";
26
+ import {
27
+ SidebarGroup,
28
+ SidebarMenu,
29
+ SidebarMenuButton,
30
+ SidebarMenuItem,
31
+ SidebarMenuSkeleton,
32
+ } from "@/components/ui/sidebar";
33
+ import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
34
+ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
35
+ import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
36
+ import { folderContentsOf } from "@/folder-index/folder-index.ts";
37
+ import { useI18n } from "@/i18n/i18n-context.tsx";
38
+ import { iconFor, kindIcons } from "@/kind-icon.ts";
39
+ import { Modal } from "@/modal/modal.tsx";
40
+ import { useNodeImport } from "@/node-import/node-import.tsx";
41
+ import { useResourceRename } from "@/rename-resource/rename-resource.ts";
42
+ import { resourceErrorKey } from "@/resource-error.ts";
43
+ import { useIntelRouterContext } from "@/router/router-context.ts";
44
+ import { selectedFrom } from "@/router/selection-search.ts";
45
+
46
+ /**
47
+ * What the plus can MAKE — derived from `NodeKind`, never written out again (#395).
48
+ *
49
+ * ⚠️ Three decisions about this MENU, each excluded by name: `attachment` because an attachment is
50
+ * uploaded rather than created, `task` because a task lives on a board and the API refuses one
51
+ * filed under a folder, and `flow` added because a flow shares the tree without being a node
52
+ * (ADR-0004 §1). The LIST of node kinds is not a decision here, and a copy of it would go stale in
53
+ * the direction that already bit us — the parked kinds stood in such a union long after nothing
54
+ * could make one.
55
+ *
56
+ * ⚠️ `task` was NOT excluded until #661, and the wide form carried it in silence from #647 onward:
57
+ * a menu entry that could only ever answer with a refusal. It is a type error now.
58
+ */
59
+ type NewKind = Exclude<NodeKind, "attachment" | "task"> | "flow";
60
+ type Creating = { parentId: string | null; kind: NewKind };
61
+
62
+ /**
63
+ * A byte count as the sentence beside it says it (#821).
64
+ *
65
+ * ⚠️ Megabytes of a million, because that is what the limit IS: the constants are round decimal
66
+ * numbers, and dividing them by 1024 would put "47.7 MB" in front of somebody whose file manager
67
+ * says the same file is 48 MB. The number in the message and the number in the check have to be
68
+ * the same number.
69
+ */
70
+ // A level is either a folder of the shared tree or the flows one flow calls. The second kind is
71
+ // derived from that flow's graph, not from `parent_id` (ADR-0004 §3), which is why it is its own
72
+ // level rather than another folder.
73
+ type Level = { id: string | null; type: ExpandedLevel["type"] };
74
+
75
+ // One key per level. `null` is the root; every expanded row adds one of its own, and nothing else is
76
+ // ever asked for.
77
+ function levelKey(level: Level): readonly unknown[] {
78
+ return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
79
+ }
80
+
81
+ // What a drag carries: the row, and the LEVEL it was picked up from.
82
+ //
83
+ // ⚠️ The two are not the same, and that is the whole of #446. Since #429 the root level also shows
84
+ // rows whose own record names a folder this reader may never see — a shared document reaches the
85
+ // root only through the share. Asking `parentOf` again during the move answers with that invisible
86
+ // folder, and then the root offers "move to the top" to a row already at the top, while the level
87
+ // it actually left is never cleaned up. The level is known where the row is rendered; carrying it
88
+ // is cheaper than deriving it wrongly.
89
+ type Carried = { entry: TreeEntry; level: string | null };
90
+
91
+ // What a row puts on the clipboard for anybody outside the tree. Its own media type rather than
92
+ // `text/plain`, so a drop target that means "move this row" and one that means "make a node for
93
+ // this" cannot be confused by the same payload.
94
+ export const TreeEntryMediaType = "application/x-intel-tree-entry";
95
+
96
+ export function AppTree() {
97
+ const { data } = useIntelRouterContext();
98
+ const i18n = useI18n();
99
+ const queryClient = useQueryClient();
100
+ const navigate = useNavigate();
101
+ // ⚠️ Read once and written through, the same shape `sidebar-preferences` uses for the shell's own
102
+ // two values — and here for two reasons rather than one. A reload starts from `[]`, which is the
103
+ // obvious half; the smaller-looking one is that the shell UNMOUNTS this panel's content when the
104
+ // sidebar collapses (`app-sidebar.tsx`, #29), so component state alone dies at the first click on
105
+ // the sidebar trigger and the tree comes back closed from a gesture that says nothing about
106
+ // folders (#805).
107
+ const expansion = useRef(createTreeExpansion()).current;
108
+ const stored = useRef(expansion.read()).current;
109
+ const [expanded, setExpanded] = useState<readonly ExpandedLevel[]>(stored.levels);
110
+ // ⚠️ The write is an effect rather than a line in each mutator, and that is a decision taken when
111
+ // the SECOND writer arrived (#809): the reveal below sets `expanded` from inside a promise, where
112
+ // the state of this render is no longer the newest one. Every writer therefore uses the updater
113
+ // form and nobody writes storage by hand — one place, and it cannot be forgotten by the third.
114
+ const revealedFor = useRef<string | null>(stored.revealed);
115
+ useEffect(() => {
116
+ expansion.write({ levels: expanded, revealed: revealedFor.current });
117
+ }, [expanded, expansion]);
118
+ const [creating, setCreating] = useState<Creating | null>(null);
119
+ // Which row is being renamed in place, if any (#823). The ENTRY rather than its id: the mutation
120
+ // needs `updatedAt` from the row that was read, and looking it up again from the level would read
121
+ // a row that may have moved on — which is the one thing `baseUpdatedAt` exists to catch.
122
+ const [renaming, setRenaming] = useState<TreeEntry | null>(null);
123
+ const [dragged, setDragged] = useState<Carried | null>(null);
124
+ const draggedRef = useRef<Carried | null>(null);
125
+ // The id of the one sentence every movable row is described by (#590). Minted rather than written
126
+ // down: two trees on one page would otherwise share a literal id, and the second would describe
127
+ // its rows with the first one's element.
128
+ const movableId = useId();
129
+ // Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
130
+ // folder row. Three answers, because "the root" and "nothing" are not the same drop.
131
+ const [over, setOver] = useState<string | null | undefined>(undefined);
132
+ // The drop's half of the move. The other half is the folder picker in the title line's menu, and
133
+ // both go through the same mutation (#58, `useTreeMove`) — the tree only adds what a tree can add,
134
+ // which is opening the folder the row just landed in.
135
+ const move = useTreeMove({
136
+ onMoved: (destination) => {
137
+ if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
138
+ },
139
+ });
140
+ /**
141
+ * One window for everything that comes in from the machine (#822).
142
+ *
143
+ * ⚠️ Until #822 this held the ZIP and the folder, and a single file had an entry of its own
144
+ * beside them; a folder's three-dot menu had the first two and not the third, so a file could be
145
+ * brought to the top level and not into a folder. All three answer the same question, so they sit
146
+ * behind the same word — and the gap closed without anybody having to notice it was there.
147
+ */
148
+ const nodeImport = useNodeImport({
149
+ where: i18n.t("tree.move.root"),
150
+ onImported: (parentId) => {
151
+ if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
152
+ },
153
+ onUploaded: async (result) => {
154
+ const only = result.created.length === 1 ? result.created[0] : undefined;
155
+ // ⚠️ One file lands where it was made; several do not. A run of ten cannot navigate to one of
156
+ // them without choosing for the reader — and the choice would be arbitrary, because the tree
157
+ // shows all ten anyway. What both cases share is the reveal, which is what puts them there.
158
+ try {
159
+ if (only !== undefined) await navigate({ to: "/nodes", search: { select: only } });
160
+ } finally {
161
+ await reveal(result.parentId, "node-graph");
162
+ }
163
+ },
164
+ });
165
+
166
+ const location = useRouterState({
167
+ select: (state) => ({
168
+ pathname: state.location.pathname,
169
+ select: selectedFrom(state.location.search),
170
+ }),
171
+ });
172
+
173
+ // ⚠️ The whole point of #11: one query per level, and a level only exists while its folder is
174
+ // open. A recursive load would put an N+1 on every page of the app, because the tree is now on
175
+ // every page.
176
+ // An expanded flow shows what it calls — with the same arrow promise one level deeper: a called
177
+ // flow that calls flows of its own can be expanded further (#59).
178
+ async function flowCallRows(flowId: string): Promise<TreeEntry[]> {
179
+ const calls = await data.listFlowCalls(flowId);
180
+ const openable = new Set(calls.withCalls);
181
+ return calls.items.map((flow) => flowEntry(flow, openable.has(flow.id)));
182
+ }
183
+
184
+ /**
185
+ * The levels that still have a row to hang on — `expanded` minus everything the tree no longer
186
+ * shows (#521).
187
+ *
188
+ * ⚠️ `expanded` is only ever added to. A folder that was opened and has since left the tree —
189
+ * archived, moved away, its share revoked — used to keep its level in `useQueries` for the rest
190
+ * of the session, and every write from then on re-read a list nothing draws. Nothing was wrong on
191
+ * screen (`isOpen` needs the row, so the level was never rendered); the requests were simply paid
192
+ * for, and the number grew with the length of the SESSION rather than with what is on screen.
193
+ *
194
+ * ⚠️ Read from the cache rather than from `levels` below, and that is not a shortcut: the answer
195
+ * is needed BEFORE `useQueries` is told what to load, and deriving it from the results afterwards
196
+ * can only correct the list one render too late — by which time the request has been made.
197
+ *
198
+ * ⚠️ A walk down from the root, not "is the row anywhere": a level whose own row is gone must not
199
+ * keep vouching for the levels under it, or a whole opened branch would survive its top.
200
+ *
201
+ * ⚠️ A row is enough, and `openable` is deliberately NOT part of it although `isOpen` checks it.
202
+ * `reveal` opens a folder BEFORE the invalidation that gives it its arrow, so a folder that was
203
+ * empty a moment ago still says `openable: false` at that instant — judging on it would close the
204
+ * folder #519 exists to have opened. A refetch does not make a level unknown either: TanStack
205
+ * keeps the previous data while it refetches, so an ordinary invalidation closes nothing.
206
+ */
207
+ const rowLevel = (entry: TreeEntry) =>
208
+ `${entry.kind === "folder" ? "folder" : "flow"}:${entry.id}`;
209
+ const rootLevel: Level = { id: null, type: "folder" };
210
+ const open: Level[] = [];
211
+ let frontier: Level[] = [rootLevel];
212
+ let waiting = [...expanded];
213
+ while (frontier.length > 0 && waiting.length > 0) {
214
+ const shown = new Set(
215
+ frontier.flatMap((level) =>
216
+ (queryClient.getQueryData<TreeEntry[]>(levelKey(level)) ?? []).map(rowLevel),
217
+ ),
218
+ );
219
+ frontier = waiting.filter((level) => shown.has(`${level.type}:${level.id}`));
220
+ waiting = waiting.filter((level) => !frontier.includes(level));
221
+ open.push(...frontier);
222
+ }
223
+
224
+ const parents: Level[] = [rootLevel, ...open];
225
+ const levels = useQueries({
226
+ queries: parents.map((parent) => ({
227
+ queryKey: levelKey(parent),
228
+ queryFn: async () =>
229
+ parent.type === "flow" && parent.id !== null
230
+ ? // The same row a folder's level builds for a flow, from the same function: what hangs
231
+ // under an expanded flow differs in where the flows come from, never in what a flow row
232
+ // is (#30).
233
+ await flowCallRows(parent.id)
234
+ : await data.listTreeChildren(parent.id),
235
+ })),
236
+ });
237
+ const levelFor = (level: Level) =>
238
+ levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
239
+ const root = levels[0];
240
+
241
+ // ⚠️ The list the reader opened is also trimmed here, and not only where it is read: `open`
242
+ // above keeps a departed level from being LOADED, but the entry itself would otherwise sit in
243
+ // `expanded` until the tab is closed — and a folder that comes back would come back open, which
244
+ // is a claim about a gesture nobody made in this tree. A gesture is where trimming is free: it
245
+ // is an event, so there is no render to loop through.
246
+ //
247
+ function toggle(level: ExpandedLevel, wanted: boolean) {
248
+ setExpanded((current) => {
249
+ const rest = current.filter(
250
+ (entry) =>
251
+ (entry.id !== level.id || entry.type !== level.type) &&
252
+ open.some((live) => live.id === entry.id && live.type === entry.type),
253
+ );
254
+ return wanted ? [...rest, level] : rest;
255
+ });
256
+ }
257
+
258
+ /**
259
+ * The tree opens itself down to whatever is selected, and this is the one place that does it
260
+ * (#809). Everything else about a selection is drawing: `isActive` marks a row the tree happens to
261
+ * be showing anyway, which for a document three folders down is nothing at all — the reader sees
262
+ * the document open beside a tree that says nothing about where it is.
263
+ *
264
+ * ⚠️ ONCE per selection, and the ref is what makes it once. This effect runs on every render while
265
+ * the selection stands, so opening the trail each time would take a folder the reader has just
266
+ * closed and open it again: the tree would be arguing with the hand. It is deliberately NOT reset
267
+ * when the selection goes away either — coming back to the same row through Tools or the archive
268
+ * is the same selection, and it has been revealed already.
269
+ *
270
+ * ⚠️ A row the tree already draws costs NOTHING. That is not an optimisation to be dropped: an
271
+ * ordinary click in the tree would otherwise read one node per level above it, on a surface that
272
+ * stands on every screen of the application.
273
+ */
274
+ const selectedArea: RevealArea | null =
275
+ location.pathname === "/nodes" ? "nodes" : location.pathname === "/flows" ? "flows" : null;
276
+ const drawn = (id: string) =>
277
+ [rootLevel, ...open].some((level) =>
278
+ (queryClient.getQueryData<TreeEntry[]>(levelKey(level)) ?? []).some(
279
+ (entry) => entry.id === id,
280
+ ),
281
+ );
282
+
283
+ // ⚠️ Written through, not merely held. A ref dies with the component, and this component dies
284
+ // every time the sidebar is collapsed — while `expanded` survives, because that is what #805
285
+ // built. Held in memory alone, a folder the reader closed by hand would be opened again by the
286
+ // next mount, by a reveal that had forgotten it already happened.
287
+ function remember(selected: string) {
288
+ revealedFor.current = selected;
289
+ expansion.write({ levels: expanded, revealed: selected });
290
+ }
291
+
292
+ useEffect(() => {
293
+ const selected = location.select;
294
+ if (selected === null || selectedArea === null) return;
295
+ if (revealedFor.current === selected) return;
296
+ if (drawn(selected)) {
297
+ remember(selected);
298
+ return;
299
+ }
300
+ remember(selected);
301
+ void folderTrail(
302
+ {
303
+ // ⚠️ Through the query client, and under the keys the screens themselves use: the selected
304
+ // row is the one thing the screen beside this tree has already asked for, so the first step
305
+ // of the trail is normally answered from the cache and costs no request at all.
306
+ node: async (nodeId) =>
307
+ (
308
+ await queryClient.fetchQuery({
309
+ queryKey: ["node", nodeId],
310
+ queryFn: () => data.getNode(nodeId),
311
+ })
312
+ ).node,
313
+ flow: async (flowId) =>
314
+ (
315
+ await queryClient.fetchQuery({
316
+ queryKey: ["flow", flowId],
317
+ queryFn: () => data.getFlow(flowId),
318
+ })
319
+ ).flow,
320
+ },
321
+ { id: selected, area: selectedArea },
322
+ ).then((trail) => {
323
+ // ⚠️ The walk takes as long as the reads above it, and the reader does not wait: two quick
324
+ // clicks, or two hits from the search, start a second walk while this one is still out. The
325
+ // answer that comes back late would then open the folders of a row nobody is looking at any
326
+ // more — nothing is lost, but the tree stands open at a place the selection left.
327
+ if (revealedFor.current !== selected) return;
328
+ if (trail.length === 0) return;
329
+ // The updater form, because this lands after an await and the `expanded` of the render that
330
+ // started the walk may be several gestures old by now.
331
+ setExpanded((current) => {
332
+ const missing = trail.filter(
333
+ (level) => !current.some((entry) => entry.id === level.id && entry.type === level.type),
334
+ );
335
+ return missing.length === 0 ? current : [...current, ...missing];
336
+ });
337
+ });
338
+ });
339
+
340
+ /**
341
+ * ⚠️ Opening the trail is half the answer: a tree taller than the sidebar can have the row open
342
+ * and out of sight, which reads exactly like not having opened it. `nearest` rather than `center`,
343
+ * so a row that is already visible does not jump.
344
+ */
345
+ const activeRow = useRef<HTMLButtonElement | null>(null);
346
+ const scrolledTo = useRef<string | null>(null);
347
+ useEffect(() => {
348
+ const selected = location.select;
349
+ if (selected === null || activeRow.current === null) return;
350
+ if (scrolledTo.current === selected) return;
351
+ scrolledTo.current = selected;
352
+ activeRow.current.scrollIntoView({ block: "nearest" });
353
+ });
354
+
355
+ // ⚠️ The level a row appears in is not the only thing that goes stale. The header search reads
356
+ // the whole flow list, and the graph is what the link picker and the flow editor's tree links
357
+ // offer — a row created here has to reach those too, or another screen keeps showing yesterday.
358
+ //
359
+ // ⚠️ And the level written into is not enough either: the folder's own ROW says whether it can be
360
+ // opened at all, and that row stands one level higher (`allTreeLevelsKey`, #519). Without it the
361
+ // `toggle` above opens a folder whose arrow never appeared, so the first thing filed into an empty
362
+ // folder was invisible until the page was reloaded.
363
+ async function reveal(
364
+ parentId: string | null,
365
+ collection: "flows" | "node-graph",
366
+ ): Promise<void> {
367
+ if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
368
+ await Promise.all([
369
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
370
+ queryClient.invalidateQueries({ queryKey: [collection] }),
371
+ // The graph view of that same level draws exactly this list, so it goes stale for the same
372
+ // reason the level does (#19).
373
+ queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
374
+ ]);
375
+ }
376
+
377
+ const create = useMutation({
378
+ mutationFn: async ({
379
+ parentId,
380
+ kind,
381
+ title,
382
+ columns,
383
+ }: Creating & { title: string; columns: string[] }) => {
384
+ if (kind === "flow") {
385
+ const flow = await data.createFlow({
386
+ parentId,
387
+ title,
388
+ description: null,
389
+ idempotencyKey: crypto.randomUUID(),
390
+ });
391
+ return { area: "/flows" as const, id: flow.id, parentId, collection: "flows" as const };
392
+ }
393
+ const node = await data.createNodes({
394
+ parentId,
395
+ kind,
396
+ title,
397
+ description: null,
398
+ idempotencyKey: crypto.randomUUID(),
399
+ });
400
+ // ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
401
+ // contract every later append is measured against (#40). A table without a header would sit
402
+ // there refusing every append, so the second call happens here rather than being left to the
403
+ // person who created it.
404
+ if (kind === "table") {
405
+ await data.defineTable({
406
+ nodeId: node.id,
407
+ columns,
408
+ idempotencyKey: crypto.randomUUID(),
409
+ });
410
+ }
411
+ /**
412
+ * ⚠️ **A board's columns are written at creation, in the language of whoever made it** (#675).
413
+ * The server has a fallback for a board nobody configured, but it cannot know the reader's
414
+ * language — and columns are SHARED data: two people on one board must read the same names,
415
+ * and the settings dialog edits one list. Translating per reader would give them different
416
+ * boards. Written once, they are data anybody can rename.
417
+ *
418
+ * ⚠️ It is a second call and it may fail on its own. That is survivable and deliberately not
419
+ * repaired here: the board then answers with the server's fallback and is usable, which is
420
+ * the same state a board made over MCP is in. A failure that made the whole creation fail
421
+ * would trade a usable board for none.
422
+ */
423
+ if (kind === "board") {
424
+ try {
425
+ await data.updateBoard({
426
+ boardId: node.id,
427
+ columns: [
428
+ { id: "backlog", title: i18n.t("board.column.default.backlog"), terminal: false },
429
+ { id: "todo", title: i18n.t("board.column.default.todo"), terminal: false },
430
+ { id: "doing", title: i18n.t("board.column.default.doing"), terminal: false },
431
+ { id: "done", title: i18n.t("board.column.default.done"), terminal: true },
432
+ ],
433
+ idempotencyKey: crypto.randomUUID(),
434
+ });
435
+ } catch {
436
+ /**
437
+ * ⚠️ **Swallowed on purpose, and the `catch` is what makes that true.** Without it the
438
+ * failure travels out of the mutation: the node exists, but `onSuccess` never runs, the
439
+ * screen says "creating failed", and the obvious response — try again — makes a SECOND
440
+ * board, because nothing tells the reader the first one is already there.
441
+ *
442
+ * What is lost by swallowing is one thing and it is repairable: the board answers with
443
+ * the server's fallback columns instead of the ones in this reader's language. That is
444
+ * exactly the state a board made over MCP is in, and the settings dialog renames them.
445
+ */
446
+ }
447
+ }
448
+ return {
449
+ area: "/nodes" as const,
450
+ id: node.id,
451
+ parentId,
452
+ collection: "node-graph" as const,
453
+ };
454
+ },
455
+ onSuccess: async (created) => {
456
+ setCreating(null);
457
+ // ⚠️ The screen first, the refreshes after. Since #519 `reveal` waits on EVERY mounted level,
458
+ // and a React Query retry is paused while the tab is not in front (#212) — awaiting that
459
+ // before navigating would leave somebody who just made a document sitting on the old screen
460
+ // for as long as one level takes to answer.
461
+ //
462
+ // ⚠️ `finally`, because both screens are lazy chunks: a failed chunk load throws out of
463
+ // `navigate`, and without this the tree, the collections and the graph would silently keep
464
+ // yesterday's rows for the rest of the session — the very state this ticket is about.
465
+ try {
466
+ await navigate({ to: created.area, search: { select: created.id } });
467
+ } finally {
468
+ await reveal(created.parentId, created.collection);
469
+ }
470
+ },
471
+ });
472
+
473
+ // What a drop on this target would do. `undefined` means nothing is being dragged, so the target
474
+ // is not a target at all.
475
+ function verdictFor(
476
+ carried: Carried | null,
477
+ targetId: string | null,
478
+ targetAncestors: ReadonlySet<string>,
479
+ ) {
480
+ if (!carried) return undefined;
481
+ return moveVerdict({
482
+ draggedId: carried.entry.id,
483
+ draggedParentId: parentOf(carried.entry),
484
+ draggedLevel: carried.level,
485
+ targetId,
486
+ targetAncestors,
487
+ });
488
+ }
489
+
490
+ function dropHandlers(target: MoveDestination, ancestors: ReadonlySet<string>) {
491
+ // Two readings of the same question. The render-time one drives what is on screen; the handlers
492
+ // ask `draggedRef` instead, because a drag's events can all arrive before React has re-rendered
493
+ // and a closure over the previous render would then move the previous row.
494
+ const verdict = verdictFor(dragged, target.id, ancestors);
495
+ return {
496
+ verdict,
497
+ props: {
498
+ onDragOver: (event: React.DragEvent) => {
499
+ if (verdictFor(draggedRef.current, target.id, ancestors) !== "ok") {
500
+ event.dataTransfer.dropEffect = "none";
501
+ return;
502
+ }
503
+ // Without `preventDefault` the browser refuses the drop, so this is also what makes the
504
+ // "not allowed" cursor appear over every target the tree has ruled out.
505
+ event.preventDefault();
506
+ event.dataTransfer.dropEffect = "move";
507
+ setOver(target.id);
508
+ },
509
+ onDragLeave: () => setOver((current) => (current === target.id ? undefined : current)),
510
+ onDrop: (event: React.DragEvent) => {
511
+ event.preventDefault();
512
+ const carried = draggedRef.current;
513
+ setOver(undefined);
514
+ setDragged(null);
515
+ draggedRef.current = null;
516
+ if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
517
+ move.start(carried.entry, carried.level, target);
518
+ }
519
+ },
520
+ },
521
+ };
522
+ }
523
+
524
+ function renderLevel(parent: Level, ancestors: ReadonlySet<string>, label?: string) {
525
+ const level = levelFor(parent);
526
+ if (!level || level.isPending) {
527
+ return (
528
+ <SidebarMenu aria-label={label}>
529
+ {[0, 1, 2].map((row) => (
530
+ <SidebarMenuItem key={row}>
531
+ <SidebarMenuSkeleton showIcon />
532
+ </SidebarMenuItem>
533
+ ))}
534
+ </SidebarMenu>
535
+ );
536
+ }
537
+ // ⚠️ A refusal, not a failure (#430). While this branch did not exist, a reader whose share had
538
+ // been revoked — or who holds no `intel.nodes.read` at all — watched the three skeleton bars
539
+ // above for as long as the retries lasted, and the retries are paused whenever the tab is not
540
+ // in front. Neither of those two cases is worth trying again, so what stands here is a sentence
541
+ // and no button.
542
+ const refusal = refusalOf(level.error);
543
+ if (refusal) {
544
+ return (
545
+ <p role="status" className="px-2 py-1.5 text-sm text-muted-foreground">
546
+ {i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
547
+ </p>
548
+ );
549
+ }
550
+ if (level.isError) {
551
+ return (
552
+ <div role="alert" className="space-y-2 px-2 py-1.5 text-sm">
553
+ <p className="text-destructive">{i18n.t("tree.failed")}</p>
554
+ <button
555
+ type="button"
556
+ onClick={() => void level.refetch()}
557
+ className="rounded-md border px-2 py-1 text-xs outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring"
558
+ >
559
+ {i18n.t("common.retry")}
560
+ </button>
561
+ </div>
562
+ );
563
+ }
564
+ // ⚠️ The cycle guard, in the form a per-level load needs: a broken `parentId` that points back
565
+ // up would otherwise open the same folder inside itself for as long as anyone keeps clicking.
566
+ // The recursion is gone, so the depth limit that used to save us is gone with it; what stops it
567
+ // now is that a row already on the path from the root is not rendered again.
568
+ // ⚠️ The folder's own text is not one of its contents (#824): a document called `index`
569
+ // describes the folder it is in, and it appears in that folder's details rather than as a row
570
+ // under it. `folderContentsOf` is the one place that decides which row that is.
571
+ const entries =
572
+ folderContentsOf(parent.id, level.data ?? []).filter((entry) => !ancestors.has(entry.id)) ??
573
+ [];
574
+ if (entries.length === 0) {
575
+ // ⚠️ Nothing that is empty says so any more (#59). "This flow calls no other flow" used to
576
+ // stand here as an answer rather than filler — but a row that cannot be opened is the answer,
577
+ // and now no such row can be opened: the chevron only appears where there is something under
578
+ // it. The sentence would only ever be read by somebody who got here another way.
579
+ //
580
+ // `tree.empty` at the ROOT stays, and for a different reason that still holds: a completely
581
+ // blank sidebar on a fresh installation leaves a new user without any way in.
582
+ const message = parent.type === "folder" && parent.id === null ? i18n.t("tree.empty") : null;
583
+ // The folder keeps its list, empty. The structure is what carries "there is nothing here" to
584
+ // a screen reader — "list, 0 items" — which is the same answer the missing rows give a reader
585
+ // who can see them, and neither of them is a sentence.
586
+ return message === null ? (
587
+ <SidebarMenu aria-label={label} />
588
+ ) : (
589
+ <p className="px-2 py-1.5 text-sm text-muted-foreground">{message}</p>
590
+ );
591
+ }
592
+ return (
593
+ <SidebarMenu aria-label={label}>
594
+ {entries.map((entry) => renderRow(entry, parent, ancestors))}
595
+ </SidebarMenu>
596
+ );
597
+ }
598
+
599
+ // ⚠️ A level under a FLOW is derived: its rows are that flow's calls, read out of its graph
600
+ // (ADR-0004 §3, `levelKey`). Nothing there has a `parent_id` to rewrite, so those rows are neither
601
+ // dragged nor dropped on — a move made there would silently be a move of the shared flow itself.
602
+ function renderRow(entry: TreeEntry, parent: Level, ancestors: ReadonlySet<string>) {
603
+ const derived = parent.type === "flow";
604
+ const isFolder = entry.kind === "folder";
605
+ // ⚠️ A flow expands too, and what appears under it is what it calls — read from its graph, not
606
+ // from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
607
+ // the answer to "what do I run, and how" (ADR-0004 §3).
608
+ const level: Level & { id: string } = { id: entry.id, type: isFolder ? "folder" : "flow" };
609
+ // ⚠️ No longer "is of a kind that can be expanded", but "has something to expand that this
610
+ // reader may see" (#59). The difference is an arrow that keeps its promise — before, it stood
611
+ // on every folder and every flow, empty ones included.
612
+ const expandable = entry.openable;
613
+ // ⚠️ `open`, not `expanded`: what is drawn and what is loaded answer the same question, or the
614
+ // tree would draw a level it never asked for (#521).
615
+ const isOpen =
616
+ expandable && open.some((live) => live.id === entry.id && live.type === level.type);
617
+ const Icon = isOpen && isFolder ? FolderOpen : iconFor(entry);
618
+ const area = entry.type === "flow" ? "/flows" : "/nodes";
619
+ const isActive = location.select === entry.id && location.pathname === area;
620
+ // A row's plus files into that row's place: inside a folder, beside anything else.
621
+ //
622
+ // ⚠️ "Beside it" is the level this row is shown IN, not the parent its own record names. Since
623
+ // #429 the root level also carries rows whose `parentId` points at a folder this reader may not
624
+ // see — a shared document is filed inside the sharer's folder, and only the share reaches down
625
+ // to it. Filing beside such a row through that id would try to write into a folder the reader
626
+ // cannot even open, and the refusal would arrive as a bare "could not be created".
627
+ //
628
+ // ⚠️ A derived level is the exception, because it has no folder to be filed in: what a flow
629
+ // calls is read from its graph, so the record's own parent is the only answer available there.
630
+ //
631
+ // ⚠️ It is the only one, not a safe one. A called flow may be a LIBRARY flow — `execute` without
632
+ // `read`, which ADR-0004 §2/§3 allows on purpose — and its folder is then one this reader may
633
+ // not open at all. The plus there can still be refused, exactly as the plus on any read-only
634
+ // folder row elsewhere in the tree has always been able to be. That is the older question of
635
+ // what a plus should do where writing is not allowed, and it is not this ticket's: what #429
636
+ // changed is a plus pointing at a folder that is not even the level being shown.
637
+ const target = isFolder ? entry.id : derived ? parentOf(entry) : parent.id;
638
+ // Only a folder takes a drop, and only on a level that owns its rows. Everything else keeps the
639
+ // default cursor while a drag is in progress, which is the answer "not here" without a word.
640
+ const drop =
641
+ isFolder && !derived ? dropHandlers({ id: entry.id, title: entry.title }, ancestors) : null;
642
+ const isDragged = dragged?.entry.id === entry.id;
643
+ // While a drag is in progress every row says which of the three it is: the row being carried,
644
+ // a folder that would take it, or something that would not. Silence on the last two is what
645
+ // turns a drag into a guess that only the drop answers.
646
+ const highlight =
647
+ dragged === null || isDragged
648
+ ? ""
649
+ : drop?.verdict === "ok"
650
+ ? over === entry.id
651
+ ? "bg-sidebar-accent ring-2 ring-sidebar-ring"
652
+ : "ring-1 ring-sidebar-border"
653
+ : "opacity-40";
654
+
655
+ return (
656
+ <SidebarMenuItem key={entry.id}>
657
+ {/* ⚠️ The row becomes the field, rather than growing one beside it (#823). A row that keeps
658
+ its button while an input sits next to it has two things claiming the same name, and the
659
+ plus and the drag handle would still be there for a row that is, for the moment, a
660
+ sentence being typed. */}
661
+ {renaming?.id === entry.id ? (
662
+ <RenameRow entry={renaming} close={() => setRenaming(null)} />
663
+ ) : (
664
+ <>
665
+ {/* ⚠️ `group/row` belongs HERE and not on the `SidebarMenuItem` above, which is what #252
666
+ was: the item also holds the open sub-level, and `group-hover/row` is a DESCENDANT
667
+ selector — every plus further down matched the ancestor's hover, so pointing at one
668
+ folder lit up its whole open subtree. This div holds one row and none of its children,
669
+ which is the only element on which "the row is hovered" means that one row.
670
+
671
+ ⚠️ The colouring sits on the ROW, not on the button inside it — the difference the
672
+ ticket is about. It used to hang on `SidebarMenuButton`, whose siblings the plus and this
673
+ menu are, so the grey stopped short of them: two icons standing outside the very row they
674
+ operate, with a pale strip left over on the right. Taken from gate, where the same
675
+ mistake was made and unmade three times (`service-tree.tsx`, GATE-76/-78/-81) — there the
676
+ row carries the fill and `flex-1` on the label pushes the actions to the end *inside* it.
677
+ The button keeps its own hover and active fill switched off rather than doubled, so the
678
+ row shows one surface instead of two overlapping ones. */}
679
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: Native drag is the pointer shortcut;
680
+ the nested button and move dialog remain the keyboard path. */}
681
+ <div
682
+ draggable={!derived}
683
+ onDragStart={(event) => {
684
+ // ⚠️ `draggable={!derived}` is not enough on its own: `dragstart` bubbles, so a drag
685
+ // begun on a descendant of a derived row would still land here — and carry a level
686
+ // (`["tree", <flow-id>]`) that nothing ever wrote. That is the very class of key #446
687
+ // is about, so the handler refuses it rather than trusting the attribute.
688
+ if (derived) return;
689
+ // Native drag initiation on form controls is not interoperable: Safari can leave a
690
+ // draggable button looking grabbable without ever starting the drag. The row owns the
691
+ // gesture while its button remains the keyboard-reachable navigation control (#483).
692
+ event.dataTransfer.effectAllowed = "copyMove";
693
+ event.dataTransfer.setData("text/plain", entry.id);
694
+ event.dataTransfer.setData(
695
+ TreeEntryMediaType,
696
+ JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
697
+ );
698
+ // The level travels with the row, read from the level this row was rendered into —
699
+ // never from its own record, which for a shared row names a folder the reader cannot
700
+ // see (#446).
701
+ const carried: Carried = { entry, level: parent.id };
702
+ draggedRef.current = carried;
703
+ setDragged(carried);
704
+ }}
705
+ onDragEnd={() => {
706
+ draggedRef.current = null;
707
+ setDragged(null);
708
+ setOver(undefined);
709
+ }}
710
+ {...(drop?.props ?? {})}
711
+ className={`group/row flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
712
+ isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
713
+ } ${derived ? "" : "cursor-grab active:cursor-grabbing"}`}
714
+ >
715
+ {expandable ? (
716
+ <button
717
+ type="button"
718
+ aria-expanded={isOpen}
719
+ aria-label={i18n.t(
720
+ isFolder
721
+ ? isOpen
722
+ ? "tree.collapse"
723
+ : "tree.expand"
724
+ : isOpen
725
+ ? "tree.collapseCalls"
726
+ : "tree.expandCalls",
727
+ { title: entry.title },
728
+ )}
729
+ onClick={() => toggle(level, !isOpen)}
730
+ // The icons inside a coloured row need a step of their own, or their hover would be
731
+ // the row's colour on the row's colour and read as nothing at all.
732
+ //
733
+ // ⚠️ A square of exactly `TREE_GUTTER` with the icon CENTRED in it (#453). It used to
734
+ // be `p-1` around a 14px icon — 22px wide, so the arrow's centre sat at 11px while the
735
+ // line of the level below started at 14px: the line ran PAST the arrow instead of
736
+ // under it. On the fourth level that line is the only thing left saying whose content
737
+ // one is reading, and missing its arrow it reads as a separation instead of a
738
+ // belonging. Centred, the hit area also gains the air on the left it never had, and it
739
+ // gets bigger rather than smaller — an arrow that sits well but is harder to hit is no
740
+ // improvement, on touch least of all.
741
+ className={`${TREE_GUTTER} flex items-center justify-center rounded-md text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent-foreground/10 focus-visible:ring-2 focus-visible:ring-sidebar-ring`}
742
+ >
743
+ <ChevronRight
744
+ aria-hidden="true"
745
+ className={`size-3.5 transition-transform ${isOpen ? "rotate-90" : ""}`}
746
+ />
747
+ </button>
748
+ ) : (
749
+ // ⚠️ The SAME width as the button, not one step less: a row without an arrow used to sit
750
+ // 2px left of one with an arrow, and nothing said why.
751
+ <span aria-hidden="true" className={TREE_GUTTER} />
752
+ )}
753
+ {/* Navigation stays a real button. The enclosing row carries the native drag gesture;
754
+ putting `draggable` on this form control is precisely the Safari failure from #483. */}
755
+ <SidebarMenuButton
756
+ ref={isActive ? activeRow : null}
757
+ /**
758
+ * ⚠️ A double click OPENS and then renames, and that is the decided behaviour rather
759
+ * than an accident (#823): the first of the two clicks is an ordinary click, and an
760
+ * ordinary click on a row opens it. Swallowing the navigation would mean telling a
761
+ * single click from a double one before either has finished — a delay on every click
762
+ * in the tree, paid by everybody, so that nobody ever sees the row they clicked.
763
+ *
764
+ * ⚠️ F2 is the same act for a keyboard, and the tree has no other way to reach it: the
765
+ * three-dot menu is not in the row (#58), it is in the title line of the open thing.
766
+ */
767
+ onDoubleClick={() => setRenaming(entry)}
768
+ onKeyDown={(event) => {
769
+ if (event.key === "F2") {
770
+ event.preventDefault();
771
+ setRenaming(entry);
772
+ }
773
+ }}
774
+ isActive={isActive}
775
+ /* ⚠️ What says the row is movable BEFORE anything touches it (#590). Until now only
776
+ `cursor-grab` said it, and that is a statement addressed to a pointer that is already
777
+ on the row — nobody navigating by keyboard, by touch or by ear ever received it, and
778
+ a way nobody sees is a way nobody uses. That is how the dead gesture of #483 stayed
779
+ dead for months without a single report.
780
+
781
+ ⚠️ A DESCRIPTION and not part of the name: the name is the title, and twenty rows
782
+ each announcing an instruction would bury it. It is also one shared element rather
783
+ than one per row — the sentence is the same for every movable row, and a copy per row
784
+ is a copy per row to keep in step.
785
+
786
+ ⚠️ Absent on a derived row, and that is the whole discrimination: what a flow calls
787
+ has no `parent_id` to rewrite and is not draggable (`draggable={!derived}`), so
788
+ claiming it were movable would send somebody after a gesture that is refused. */
789
+ aria-describedby={derived ? undefined : movableId}
790
+ data-drop={
791
+ dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
792
+ }
793
+ className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${isDragged ? "opacity-50" : ""} ${highlight}`}
794
+ onClick={() => void navigate({ to: area, search: { select: entry.id } })}
795
+ >
796
+ <Icon aria-hidden="true" className="size-4 shrink-0" />
797
+ <span className="truncate">{entry.title}</span>
798
+ {/* The icon carries the type on screen; this carries it everywhere else, and it is why
799
+ a flow needs no suffix in its name to be told from a document. */}
800
+ <span className="sr-only">{i18n.t(`tree.kind.${entry.kind}`)}</span>
801
+ </SidebarMenuButton>
802
+ <AddMenu
803
+ label={i18n.t("tree.add", { title: entry.title })}
804
+ onSelect={(kind) => {
805
+ if (kind === "import") nodeImport.open(target);
806
+ else setCreating({ parentId: target, kind });
807
+ }}
808
+ />
809
+ {/* ⚠️ Nothing else. The three-dot menu stood here until #58 and does not any more: two
810
+ buttons per row put ten of them into a 240px column, and one then reads buttons instead
811
+ of titles — the tree is for reading. Every action it held is in the title line of the
812
+ thing itself, which every row opens by being clicked, including a folder, whose screen
813
+ is nothing but that line. Do not put a second one back here: the point of the menu is
814
+ that it is always in the same place, and two places are not one. */}
815
+ </div>
816
+ </>
817
+ )}
818
+ {/* The same rows one indent deeper: one row component for every depth, so the plus on the
819
+ fourth level is the same plus as on the first. The list is named after the row it hangs
820
+ under — indentation says whose contents these are to anyone who can see it, and this is
821
+ the same sentence for anyone who cannot. */}
822
+ {isOpen ? (
823
+ <div className={TREE_LINE}>
824
+ {renderLevel(
825
+ level,
826
+ new Set([...ancestors, entry.id]),
827
+ i18n.t(isFolder ? "tree.contents" : "tree.calls", { title: entry.title }),
828
+ )}
829
+ </div>
830
+ ) : null}
831
+ </SidebarMenuItem>
832
+ );
833
+ }
834
+
835
+ const rootDrop = dropHandlers({ id: null, title: i18n.t("tree.move.root") }, new Set());
836
+
837
+ return (
838
+ <SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
839
+ {/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
840
+ named for assistive technology instead. */}
841
+ {renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
842
+ {/* ⚠️ The sentence every movable row points at (#590). It names BOTH ways on purpose: the
843
+ drag for whoever has a pointer, and the entry in the title line for whoever has not —
844
+ `resource.move` is the same word it carries there. Naming only the drag would describe a
845
+ gesture that keyboard and touch do not have, which is the state this ticket found.
846
+
847
+ ⚠️ It is rendered AFTER the rows and it is `sr-only`, so it takes no space and moves
848
+ nothing. Anything a row could be pushed down by is the abort of #483 all over again — and
849
+ an element that exists from the first render pushes nothing in any case. */}
850
+ <span id={movableId} className="sr-only">
851
+ {i18n.t("tree.move.rowHint")}
852
+ </span>
853
+ {/* The root has no row to drop on, so while something is being carried it gets one. It is the
854
+ only way back out of a folder, and it appears exactly when it can be used.
855
+ ⚠️ BELOW the tree, and that is not a matter of taste (#483). It used to stand above it, so
856
+ the moment a drag began every row moved down by its height — including the row being
857
+ dragged. Chromium ABORTS a drag whose source shifts under it: `dragstart` fired, `dragend`
858
+ followed six milliseconds later, and no `drag`, `dragover` or `drop` ever came. The hand
859
+ appeared and nothing moved, which is exactly what the ticket describes. Below the tree the
860
+ strip pushes nothing that sits above it, so the source stays where the gesture started.
861
+ Measured in a real Chromium: with the strip above, the gesture dies; with it here, the same
862
+ drag reaches the drop and opens the move dialog. */}
863
+ {rootDrop.verdict === undefined || dragged === null ? null : (
864
+ <button
865
+ type="button"
866
+ {...rootDrop.props}
867
+ onClick={() => {
868
+ // The strip is drawn for every verdict — greyed out when it would refuse — so the click
869
+ // has to read it too. Without this, a click on the grey strip performs exactly the write
870
+ // the drop path refuses, and `initial` being set skips the picker where the verdict is
871
+ // otherwise enforced.
872
+ if (rootDrop.verdict !== "ok") return;
873
+ move.start(dragged.entry, dragged.level, {
874
+ id: null,
875
+ title: i18n.t("tree.move.root"),
876
+ });
877
+ }}
878
+ data-drop={rootDrop.verdict}
879
+ className={`mt-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
880
+ rootDrop.verdict === "ok"
881
+ ? over === null
882
+ ? "bg-sidebar-accent ring-2 ring-sidebar-ring"
883
+ : "text-sidebar-foreground"
884
+ : "text-muted-foreground opacity-40"
885
+ }`}
886
+ >
887
+ <CornerLeftUp aria-hidden="true" className="size-4 shrink-0" />
888
+ {i18n.t("tree.move.dropRoot")}
889
+ </button>
890
+ )}
891
+ {create.isError ? (
892
+ <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
893
+ {i18n.t("tree.createFailed")}
894
+ </p>
895
+ ) : null}
896
+ {/* Four refusals, four sentences — and by the time one is read the row is already back where
897
+ it started, because the rollback happens in `onError` rather than here. */}
898
+ {move.error ? (
899
+ <p role="alert" className="px-2 py-1.5 text-sm text-destructive">
900
+ {i18n.t(moveErrorKey(move.error))}
901
+ </p>
902
+ ) : null}
903
+ {/* The root has no row of its own, so it gets one — otherwise a tree could only ever grow
904
+ inside the folders it already has, and the plus would stand loose under the list, attached
905
+ to nothing (#24). As a whole row it is also the one thing on screen when the tree is empty,
906
+ which is exactly when somebody needs to be told where to start. */}
907
+ {root?.isPending ? null : (
908
+ <div className="mt-1 flex items-center gap-0.5">
909
+ {/* ⚠️ No gutter here any more (#453). It was meant to line the plus up with the row ICONS
910
+ above it — and the price was that the row stood half a step right of everything
911
+ belonging to the top level, so it read as a child of the list while it means the list
912
+ itself. It begins on the same left edge as the arrows of the top level, which is the
913
+ edge that says "this level". */}
914
+ <AddMenu
915
+ label={i18n.t("tree.addRoot")}
916
+ description={i18n.t("tree.addRoot.long")}
917
+ variant="row"
918
+ onSelect={(kind) => {
919
+ if (kind === "import") nodeImport.open(null);
920
+ else setCreating({ parentId: null, kind });
921
+ }}
922
+ />
923
+ </div>
924
+ )}
925
+ {nodeImport.inputs}
926
+ {creating ? (
927
+ <Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
928
+ <CreateForm
929
+ kind={creating.kind}
930
+ pending={create.isPending}
931
+ submit={(title, columns) => create.mutate({ ...creating, title, columns })}
932
+ />
933
+ </Modal>
934
+ ) : null}
935
+ {move.dialog}
936
+ </SidebarGroup>
937
+ );
938
+ }
939
+
940
+ // ── The geometry of the tree: two measurements that belong together (#453) ───────────────────────
941
+ //
942
+ // The disclosure arrow sits in a 24px SQUARE, and the vertical line of an open level starts at 12px
943
+ // — the centre column of that square. The two used to be arrived at independently (`p-1` around a
944
+ // 14px icon = 22px, line at `ml-3.5` = 14px), which is why the line ran BESIDE its arrow rather than
945
+ // underneath it.
946
+ //
947
+ // ⚠️ The result is no small matter: on the fourth level the line is the only thing left saying whose
948
+ // content you are reading. If it misses its arrow, it reads as a separation instead of a belonging.
949
+ //
950
+ // That is why the two stand HERE side by side: whoever changes one sees the other.
951
+ const TREE_GUTTER = "size-6 shrink-0";
952
+ const TREE_LINE = "ml-3 border-l border-sidebar-border pl-2";
953
+
954
+ // ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
955
+ // having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
956
+ //
957
+ // `row` is the root's form: a full-width row that says what it does, because the root has no title
958
+ // beside which a bare icon would mean anything.
959
+ //
960
+ // ⚠️ Both variants offer the same things since #822, and that is a change from #346 rather than a
961
+ // slip: the root used to be the only plus with an import, because a folder had one in its own
962
+ // three-dot menu — but the plus of a folder row already carried "Upload file", so a folder row
963
+ // could bring in a file and the three-dot menu next to it could not. One word now covers all three
964
+ // sources, in both menus, and the difference disappears with it.
965
+ //
966
+ // ⚠️ In the `row` form the visible text and the accessible name are NOT the same string (#453). The
967
+ // sentence "Add at the top level" was cut to "Add at the top le…" in a 240px sidebar —
968
+ // a truncated sentence says less than a short word. The word is what one reads; the sentence stays
969
+ // as the name, because this row is the only way to start a tree and the only thing on screen when
970
+ // the tree is empty. Shorter must not mean that nobody is told where to begin.
971
+ type AddMenuProps =
972
+ | { label: string; description: string; variant: "row"; onSelect(kind: MenuChoice): void }
973
+ | { label: string; variant?: "icon"; onSelect(kind: MenuChoice): void };
974
+
975
+ // What the plus can answer with: one of the kinds it makes, or the one word for everything that
976
+ // comes in from outside.
977
+ type MenuChoice = NewKind | "import";
978
+
979
+ function AddMenu(props: AddMenuProps) {
980
+ const { label, onSelect } = props;
981
+ const variant = props.variant ?? "icon";
982
+ const i18n = useI18n();
983
+ // ⚠️ Two groups, and the separator is the answer to what the plus promises: five kinds are MADE
984
+ // here, and everything below the line comes IN from somewhere else (#346, #822). The icons come
985
+ // from the one kind-icon map rather than being named again — a second `Folder` beside it is how
986
+ // the tree row and this menu start disagreeing.
987
+ const kinds: Array<{ kind: NewKind; labelKey: string; Icon: typeof Plus }> = [
988
+ { kind: "folder", labelKey: "tree.new.folder", Icon: kindIcons.folder },
989
+ { kind: "document", labelKey: "tree.new.document", Icon: kindIcons.document },
990
+ { kind: "table", labelKey: "tree.new.table", Icon: kindIcons.table },
991
+ // ⚠️ A board needs nothing else to be usable: an unconfigured one answers with three default
992
+ // columns (`DEFAULT_COLUMNS`, `packages/intel/src/boards/boards.ts`), so unlike a table it has no
993
+ // second call here. A board with no columns would be a screen with nowhere to put a card.
994
+ { kind: "board", labelKey: "tree.new.board", Icon: kindIcons.board },
995
+ { kind: "flow", labelKey: "tree.new.flow", Icon: kindIcons.flow },
996
+ ];
997
+ return (
998
+ <DropdownMenu>
999
+ <DropdownMenuTrigger
1000
+ aria-label={props.variant === "row" ? props.description : label}
1001
+ className={
1002
+ variant === "row"
1003
+ ? "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-sidebar-foreground/70 outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-sidebar-accent"
1004
+ : "shrink-0 rounded-md p-1 text-sidebar-foreground outline-none transition-opacity hover:bg-sidebar-accent-foreground/10 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
1005
+ }
1006
+ >
1007
+ <Plus aria-hidden="true" className="size-4 shrink-0" />
1008
+ {variant === "row" ? <span className="truncate">{label}</span> : null}
1009
+ </DropdownMenuTrigger>
1010
+ <DropdownMenuContent align="start" className="w-44">
1011
+ {kinds.map((item) => (
1012
+ <DropdownMenuItem key={item.kind} onSelect={() => onSelect(item.kind)}>
1013
+ <item.Icon aria-hidden="true" />
1014
+ {i18n.t(item.labelKey)}
1015
+ </DropdownMenuItem>
1016
+ ))}
1017
+ <DropdownMenuSeparator />
1018
+ {/* ⚠️ One entry for three sources, and the window behind it is the same one the folder's
1019
+ three-dot menu opens (#822). Three entries stood here — a file, a folder, a zip — and
1020
+ two of them only at the root, which is how a folder ended up able to take a zip but not
1021
+ a file. */}
1022
+ <DropdownMenuItem onSelect={() => onSelect("import")}>
1023
+ <Upload aria-hidden="true" />
1024
+ {i18n.t("resource.import")}
1025
+ </DropdownMenuItem>
1026
+ </DropdownMenuContent>
1027
+ </DropdownMenu>
1028
+ );
1029
+ }
1030
+
1031
+ // The column names of a new table, as one line. A comma-separated list is what a CSV header is,
1032
+ // so the field looks like the thing it produces rather than like a form that hides it.
1033
+ function columnsOf(value: string): string[] {
1034
+ return value
1035
+ .split(",")
1036
+ .map((column) => column.trim())
1037
+ .filter(Boolean);
1038
+ }
1039
+
1040
+ /**
1041
+ * One row of the tree, while its name is being typed (#823).
1042
+ *
1043
+ * ⚠️ It renames through the same seam the three-dot menu uses — `useResourceRename` — and that is
1044
+ * the whole reason the seam exists. Both have to send `baseUpdatedAt` and both have to refresh the
1045
+ * same four places; two implementations of that are two chances for one of them to be short one.
1046
+ *
1047
+ * ⚠️ **Escape cancels and blur cancels too.** Committing on blur would write a name nobody pressed
1048
+ * a key to confirm — a click meant for another row would rename this one, and a rename that
1049
+ * happened by accident is one nobody goes looking for. What blur costs is the typing, which is one
1050
+ * word.
1051
+ *
1052
+ * ⚠️ An empty name is refused rather than saved, and so is an unchanged one — the second silently:
1053
+ * there is nothing to write, so the row simply closes rather than spending a request and a version
1054
+ * on the name it already had.
1055
+ */
1056
+ function RenameRow({ entry, close }: { entry: TreeEntry; close(): void }) {
1057
+ const i18n = useI18n();
1058
+ const [next, setNext] = useState(entry.title);
1059
+ const rename = useResourceRename(
1060
+ entry.type === "flow" ? { type: "flow", flow: entry.flow } : { type: "node", node: entry.node },
1061
+ close,
1062
+ );
1063
+ const trimmed = next.trim();
1064
+ return (
1065
+ <form
1066
+ className="flex items-center gap-0.5 rounded-md px-2 py-1"
1067
+ onSubmit={(event) => {
1068
+ event.preventDefault();
1069
+ if (rename.isPending || trimmed.length === 0) return;
1070
+ if (trimmed === entry.title) {
1071
+ close();
1072
+ return;
1073
+ }
1074
+ rename.mutate(trimmed);
1075
+ }}
1076
+ >
1077
+ <div className="min-w-0 flex-1">
1078
+ <input
1079
+ // biome-ignore lint/a11y/noAutofocus: the row turned into this field to take this value
1080
+ autoFocus
1081
+ value={next}
1082
+ disabled={rename.isPending}
1083
+ aria-label={i18n.t("resource.renameTitle", { title: entry.title })}
1084
+ onChange={(event) => setNext(event.target.value)}
1085
+ /**
1086
+ * ⚠️ **Not while the write is in flight.** The field is `disabled` for as long as the
1087
+ * request runs, and disabling a FOCUSED element makes a real browser blur it — Chromium
1088
+ * and Firefox both run the focus fixup of the standard. An unguarded `onBlur` would then
1089
+ * close the row in the middle of its own write, and a `409` coming back a moment later
1090
+ * would have nowhere left to be shown: the conflict guard this ticket is about would be
1091
+ * silent on screen while refusing correctly on the wire (found in review of #837).
1092
+ *
1093
+ * ⚠️ jsdom does not do the fixup, so no test in this package can see the bug — only the
1094
+ * guard. The test beside it drives the blur by hand for exactly that reason.
1095
+ */
1096
+ onBlur={() => {
1097
+ if (!rename.isPending) close();
1098
+ }}
1099
+ onKeyDown={(event) => {
1100
+ if (event.key === "Escape") {
1101
+ event.preventDefault();
1102
+ close();
1103
+ }
1104
+ }}
1105
+ className="w-full rounded-md border bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
1106
+ />
1107
+ {rename.isError ? (
1108
+ <p role="alert" className="px-1 py-1 text-xs text-destructive">
1109
+ {i18n.t(resourceErrorKey(rename.error))}
1110
+ </p>
1111
+ ) : null}
1112
+ </div>
1113
+ </form>
1114
+ );
1115
+ }
1116
+
1117
+ function CreateForm({
1118
+ kind,
1119
+ pending,
1120
+ submit,
1121
+ }: {
1122
+ kind: NewKind;
1123
+ pending: boolean;
1124
+ submit(title: string, columns: string[]): void;
1125
+ }) {
1126
+ const i18n = useI18n();
1127
+ const [title, setTitle] = useState("");
1128
+ const [columns, setColumns] = useState("");
1129
+ const parsedColumns = columnsOf(columns);
1130
+ const incomplete = title.trim().length === 0 || (kind === "table" && parsedColumns.length === 0);
1131
+ return (
1132
+ <form
1133
+ className="space-y-4"
1134
+ onSubmit={(event) => {
1135
+ event.preventDefault();
1136
+ if (!pending && !incomplete) submit(title.trim(), parsedColumns);
1137
+ }}
1138
+ >
1139
+ <label className="block text-sm font-medium">
1140
+ {i18n.t("common.title")}
1141
+ <input
1142
+ required
1143
+ value={title}
1144
+ onChange={(event) => setTitle(event.target.value)}
1145
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
1146
+ />
1147
+ </label>
1148
+ {kind === "table" ? (
1149
+ <label className="block text-sm font-medium">
1150
+ {i18n.t("tree.table.columns")}
1151
+ <input
1152
+ required
1153
+ value={columns}
1154
+ onChange={(event) => setColumns(event.target.value)}
1155
+ aria-describedby="create-table-columns-hint"
1156
+ className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
1157
+ />
1158
+ <span
1159
+ id="create-table-columns-hint"
1160
+ className="mt-1 block text-xs font-normal text-muted-foreground"
1161
+ >
1162
+ {i18n.t("tree.table.columnsHint")}
1163
+ </span>
1164
+ </label>
1165
+ ) : null}
1166
+ <button
1167
+ type="submit"
1168
+ disabled={pending || incomplete}
1169
+ 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"
1170
+ >
1171
+ {pending ? i18n.t("common.saving") : i18n.t("common.create")}
1172
+ </button>
1173
+ </form>
1174
+ );
1175
+ }