@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,417 @@
1
+ import type { Flow } from "@contract/flow.ts";
2
+ import type { Node } from "@contract/node.ts";
3
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
5
+ import { useState } from "react";
6
+ import { allTreeLevelsKey } from "@/app/tree-move/tree-move.tsx";
7
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
8
+ import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
9
+ import { useI18n } from "@/i18n/i18n-context.tsx";
10
+ import { kindIcons } from "@/kind-icon.ts";
11
+ import { cn } from "@/lib/utils.ts";
12
+ import { Modal } from "@/modal/modal.tsx";
13
+ import { useIntelRouterContext } from "@/router/router-context.ts";
14
+ import { RelativeTime } from "@/time/relative-time.tsx";
15
+
16
+ // One archived thing, whichever side of the tree it came from. The two records stay apart
17
+ // everywhere else (ADR-0004); here they are one list because "what did I throw away" is one
18
+ // question, and an archive split in two would make somebody ask it twice.
19
+ interface ArchivedEntry {
20
+ id: string;
21
+ title: string;
22
+ kind: Node["kind"] | "flow";
23
+ archivedAt: string;
24
+ updatedAt: string;
25
+ restore(): Promise<unknown>;
26
+ // Both sides of the tree can be purged (#457) — each through its own door, because node and flow
27
+ // stay separate everywhere else (ADR-0004).
28
+ purge(): Promise<{ purged: true; title: string }>;
29
+ previewPurge?(): Promise<{
30
+ inboundLinks: number;
31
+ totalItems: number;
32
+ items: Array<{ id: string; title: string }>;
33
+ }>;
34
+ }
35
+
36
+ /**
37
+ * The refusal of a permanent deletion — and the one place the reader can actually reach it.
38
+ *
39
+ * ⚠️ #601. It used to stand on the page only, and the confirmation dialog that produces it **stays
40
+ * open** on a refusal while marking everything outside itself `aria-hidden` (`Modal` → react-aria's
41
+ * `ModalOverlay`). Behind that the sentence was not merely awkward to get at: for a screen reader it
42
+ * was not there at all, and it announced nothing. Since #593 this is the sentence carrying the NAMES
43
+ * of the flows that still call the flow — the one thing that makes the refusal actionable, and the
44
+ * reason the ticket was built.
45
+ *
46
+ * ⚠️ It is rendered in exactly ONE of two places, never both: inside the dialog while that is open,
47
+ * on the page once it is closed. The second half is not a leftover — the reader closes the dialog to
48
+ * go and change the callers, and the sentence has to still be there when they come back. Two copies
49
+ * would be two announcements of one answer, which is the `#445` mistake in a new costume.
50
+ *
51
+ * ⚠️ This is NOT the `#430` class and does not go through `RefusalNotice`. That one answers a
52
+ * refused QUERY — a `403`/`404` that leaves a whole pane with nothing to show, told as a centered
53
+ * sentence without a retry. This is a refused mutation that carries data (`409` with `callers`),
54
+ * belongs beside the button that caused it, and its problem was never the wording.
55
+ */
56
+ function PurgeRefusal({ error, className }: { error: unknown; className?: string }) {
57
+ const i18n = useI18n();
58
+ /**
59
+ * ⚠️ Deleting a flow for good is refused with `409 flow_in_use_by_flow` while published flows
60
+ * still call it (`flows.ts`, `purge`), and the CALLERS are what makes that refusal actionable —
61
+ * "reload and try again" is advice for a flow nobody calls, and this one is called.
62
+ *
63
+ * ⚠️ Since #593 they arrive as data — the flows this reader may see by name, the rest as a count
64
+ * — so the sentence is written here, in the reader's language, exactly as the share dialog writes
65
+ * the folder's half of the same rule (#448). The server still decides WHICH may be named; that is
66
+ * an authorization answer and nothing on this side recomputes it.
67
+ */
68
+ const requestError = error instanceof IntelRequestError ? error : null;
69
+ const inUse = requestError?.code === "flow_in_use_by_flow" ? requestError : null;
70
+ return (
71
+ <div role="alert" className={cn("space-y-1 text-sm text-destructive", className)}>
72
+ {inUse ? (
73
+ <>
74
+ <p>{i18n.t("archive.purgeInUse")}</p>
75
+ {/* An API older than #593 sends no `callers`, and then its own sentence is the only place
76
+ the titles exist at all. Showing it beats showing nothing. */}
77
+ {inUse.callers === null ? (
78
+ <p className="text-xs">{inUse.message}</p>
79
+ ) : (
80
+ <p className="text-xs">
81
+ {inUse.callers.titles.length > 0
82
+ ? i18n.t("archive.purgeInUseCallers", {
83
+ titles: inUse.callers.titles.join(", "),
84
+ })
85
+ : i18n.t("archive.purgeInUseCallersHidden", { count: inUse.callers.hidden })}
86
+ {inUse.callers.titles.length > 0 && inUse.callers.hidden > 0
87
+ ? ` ${i18n.t("archive.purgeInUseCallersMore", { count: inUse.callers.hidden })}`
88
+ : ""}
89
+ {` ${i18n.t("archive.purgeInUseHint")}`}
90
+ </p>
91
+ )}
92
+ </>
93
+ ) : (
94
+ <p>{i18n.t("archive.purgeFailed")}</p>
95
+ )}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ export function Archive() {
101
+ const { data } = useIntelRouterContext();
102
+ const i18n = useI18n();
103
+ const queryClient = useQueryClient();
104
+
105
+ const archived = useQuery({
106
+ queryKey: ["archive"],
107
+ queryFn: async (): Promise<ArchivedEntry[]> => {
108
+ const [nodes, flows] = await Promise.all([
109
+ data.listNodes({ archivedOnly: true }),
110
+ data.listFlows({ archivedOnly: true }),
111
+ ]);
112
+ const entries: ArchivedEntry[] = [
113
+ ...nodes.items.flatMap((node) => {
114
+ // Stable across reloads: after D1 commits, only this key can reach an R2 cleanup receipt.
115
+ const purgeKey = `purge-${node.id}`;
116
+ return node.archivedAt
117
+ ? [
118
+ {
119
+ id: node.id,
120
+ title: node.title,
121
+ kind: node.kind,
122
+ archivedAt: node.archivedAt,
123
+ updatedAt: node.updatedAt,
124
+ restore: () =>
125
+ data.archiveNode({
126
+ nodeId: node.id,
127
+ baseUpdatedAt: node.updatedAt,
128
+ archived: false,
129
+ idempotencyKey: crypto.randomUUID(),
130
+ }),
131
+ purge: () => data.purgeNode({ nodeId: node.id, idempotencyKey: purgeKey }),
132
+ previewPurge: () => data.previewNodePurge({ nodeId: node.id }),
133
+ } satisfies ArchivedEntry,
134
+ ]
135
+ : [];
136
+ }),
137
+ ...flows.items.flatMap((flow: Flow) =>
138
+ flow.archivedAt
139
+ ? [
140
+ {
141
+ id: flow.id,
142
+ title: flow.title,
143
+ kind: "flow" as const,
144
+ archivedAt: flow.archivedAt,
145
+ updatedAt: flow.updatedAt,
146
+ restore: () =>
147
+ data.archiveFlow({
148
+ flowId: flow.id,
149
+ baseUpdatedAt: flow.updatedAt,
150
+ archived: false,
151
+ idempotencyKey: crypto.randomUUID(),
152
+ }),
153
+ purge: () => data.purgeFlow({ flowId: flow.id }),
154
+ } satisfies ArchivedEntry,
155
+ ]
156
+ : [],
157
+ ),
158
+ ];
159
+ // Newest first, across both kinds: the thing somebody is looking for is almost always the
160
+ // thing they just archived.
161
+ return entries.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt));
162
+ },
163
+ });
164
+
165
+ // ⚠️ `baseUpdatedAt` from the row that is on screen, exactly as archiving used it. Restoring is
166
+ // a change like any other and takes the same conflict route — without it, two people tidying up
167
+ // at once would silently overwrite each other.
168
+ const restore = useMutation({
169
+ mutationFn: async (entry: ArchivedEntry) => await entry.restore(),
170
+ onSuccess: async () => {
171
+ await Promise.all([
172
+ queryClient.invalidateQueries({ queryKey: ["archive"] }),
173
+ // The tree gets it back, so every level has to be asked again — the entry may sit
174
+ // anywhere, and the archive does not know where.
175
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
176
+ queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
177
+ queryClient.invalidateQueries({ queryKey: ["flows"] }),
178
+ ]);
179
+ },
180
+ });
181
+
182
+ // ⚠️ The confirmation is a real dialog, not a `window.confirm` — it has to name the thing and say
183
+ // that it does not come back. The state lives here because the row underneath disappears the
184
+ // moment the purge goes through.
185
+ const [confirming, setConfirming] = useState<ArchivedEntry | null>(null);
186
+ // Whether anything has scrolled away under the header. See the header markup for why.
187
+ const [scrolled, setScrolled] = useState(false);
188
+ const preview = useQuery({
189
+ queryKey: ["purge-preview", confirming?.id],
190
+ queryFn: async () => await confirming?.previewPurge?.(),
191
+ enabled: Boolean(confirming?.previewPurge),
192
+ retry: false,
193
+ });
194
+ const purge = useMutation({
195
+ mutationFn: async (entry: ArchivedEntry) => await entry.purge(),
196
+ onSuccess: async () => {
197
+ setConfirming(null);
198
+ await Promise.all([
199
+ queryClient.invalidateQueries({ queryKey: ["archive"] }),
200
+ // The tree and the graph did not show the node, but its DISAPPEARANCE can be visible
201
+ // elsewhere: a reference to it no longer resolves afterwards.
202
+ queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
203
+ queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
204
+ queryClient.invalidateQueries({ queryKey: ["flows"] }),
205
+ ]);
206
+ },
207
+ });
208
+
209
+ return (
210
+ <div className="flex min-h-0 flex-1 flex-col">
211
+ {/* ⚠️ The rule below the header belongs to the SCROLL STATE, not to the header. While nothing
212
+ has scrolled away, it separates a heading from the content that heading is about, which is
213
+ a line drawn for no reason. It earns its place the moment content slides underneath.
214
+
215
+ The width is set from the start and only the COLOUR changes. Adding the border on scroll
216
+ would grow the header by one pixel at that moment and shove the whole list down — visible,
217
+ and exactly at the point where the eye is already following movement (#741).
218
+
219
+ ⚠️ `motion-reduce:transition-none` is not decoration. Nothing in this repository disarms a
220
+ colour transition for a reader who asked for less movement — there is no global
221
+ `prefers-reduced-motion` rule in the stylesheets — so a fade added here would run for them
222
+ too unless it says otherwise. The line still appears either way; only the fade is dropped. */}
223
+ <div
224
+ data-slot="pane-header"
225
+ className={cn(
226
+ "border-b border-transparent px-6 py-4 transition-colors motion-reduce:transition-none",
227
+ scrolled && "border-border",
228
+ )}
229
+ >
230
+ <h1 className="text-lg font-medium text-foreground">{i18n.t("archive.title")}</h1>
231
+ <p className="mt-1 max-w-prose text-sm text-muted-foreground">
232
+ {i18n.t("archive.description")}
233
+ </p>
234
+ </div>
235
+ <div
236
+ data-slot="pane-body"
237
+ // Reading the position off the event rather than holding a ref: React skips the re-render
238
+ // when the boolean has not changed, so a scroll of 400 pixels still costs one render.
239
+ onScroll={(event) => setScrolled(event.currentTarget.scrollTop > 0)}
240
+ className="min-h-0 flex-1 overflow-y-auto px-6 py-4"
241
+ >
242
+ {archived.isError ? (
243
+ <p role="alert" className="text-sm text-destructive">
244
+ {i18n.t("archive.failed")}
245
+ </p>
246
+ ) : null}
247
+ {restore.isError ? (
248
+ <p role="alert" className="mb-4 text-sm text-destructive">
249
+ {i18n.t("archive.restoreFailed")}
250
+ </p>
251
+ ) : null}
252
+ {/* ⚠️ On the page only while no dialog is open (#601). The refusal carries the reason — a
253
+ flow still calls this one — and it has to survive the dialog it came from; but as long as
254
+ that dialog stands, the page is `aria-hidden` and this is the copy nobody can reach. */}
255
+ {purge.isError && !confirming ? (
256
+ <PurgeRefusal error={purge.error} className="mb-4" />
257
+ ) : null}
258
+ {archived.data?.length === 0 ? (
259
+ <p className="text-sm text-muted-foreground">{i18n.t("archive.empty")}</p>
260
+ ) : null}
261
+ <ul className="grid gap-1">
262
+ {(archived.data ?? []).map((entry) => (
263
+ <li
264
+ key={`${entry.kind}-${entry.id}`}
265
+ className="flex items-center gap-3 rounded-md border px-3 py-2"
266
+ >
267
+ {(() => {
268
+ // The same map the tree and the folder table draw from — a second one here would
269
+ // drift the first time a kind is added.
270
+ // ⚠️ The plain kind icon, deliberately. An archived document is in nobody's
271
+ // `prompts/list` — the catalogue skips it — so drawing it as an offered command
272
+ // here would be the one screen claiming something the surface does not do (#775).
273
+ const Icon = kindIcons[entry.kind];
274
+ return (
275
+ <Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
276
+ );
277
+ })()}
278
+ <span className="sr-only">{i18n.t(`node.kind.${entry.kind}`)}</span>
279
+ <span className="grid min-w-0 flex-1 leading-tight">
280
+ <span className="truncate text-sm font-medium">{entry.title}</span>
281
+ <span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
282
+ {i18n.t("archive.archived")}
283
+ <RelativeTime value={entry.archivedAt} />
284
+ </span>
285
+ </span>
286
+ {/* Both actions stay permanently visible: the archive is the one place where getting
287
+ an item back must never depend on discovering a hover-only control. */}
288
+ <TooltipProvider delayDuration={300}>
289
+ <Tooltip>
290
+ {/* The wrapper keeps the tooltip available while the button is disabled. */}
291
+ <TooltipTrigger asChild>
292
+ <span className="inline-flex shrink-0">
293
+ <button
294
+ type="button"
295
+ disabled={restore.isPending}
296
+ onClick={() => restore.mutate(entry)}
297
+ aria-label={i18n.t(
298
+ restore.isPending && restore.variables?.id === entry.id
299
+ ? "archive.restoring"
300
+ : "archive.restore",
301
+ { title: entry.title },
302
+ )}
303
+ aria-busy={restore.isPending && restore.variables?.id === entry.id}
304
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
305
+ >
306
+ {restore.isPending && restore.variables?.id === entry.id ? (
307
+ <LoaderCircle aria-hidden="true" className="size-4 animate-spin" />
308
+ ) : (
309
+ <ArchiveRestore aria-hidden="true" className="size-4" />
310
+ )}
311
+ </button>
312
+ </span>
313
+ </TooltipTrigger>
314
+ <TooltipContent>
315
+ {i18n.t(
316
+ restore.isPending && restore.variables?.id === entry.id
317
+ ? "archive.restoringAction"
318
+ : "archive.restoreAction",
319
+ )}
320
+ </TooltipContent>
321
+ </Tooltip>
322
+ </TooltipProvider>
323
+ <button
324
+ type="button"
325
+ disabled={purge.isPending}
326
+ onClick={() => {
327
+ // ⚠️ The last refusal is cleared as the NEXT dialog opens, not when one closes:
328
+ // the sentence has to outlive the dialog it came from (#601), and it must not be
329
+ // standing in a dialog about a different entry — that would answer a question
330
+ // nobody asked, about the wrong thing.
331
+ purge.reset();
332
+ setConfirming(entry);
333
+ }}
334
+ aria-label={i18n.t("archive.purge", { title: entry.title })}
335
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-destructive outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
336
+ >
337
+ <Trash2 aria-hidden="true" className="size-4" />
338
+ </button>
339
+ </li>
340
+ ))}
341
+ </ul>
342
+ </div>
343
+ {confirming ? (
344
+ <Modal title={i18n.t("archive.purge.title")} close={() => setConfirming(null)}>
345
+ <p className="text-sm text-muted-foreground">
346
+ {i18n.t("archive.purge.body", { title: confirming.title })}
347
+ </p>
348
+ {confirming.previewPurge && preview.data ? (
349
+ <div className="mt-2 space-y-2 text-sm text-muted-foreground">
350
+ <p>{i18n.t("archive.purge.items", { count: preview.data.totalItems })}</p>
351
+ {/* ⚠️ The names, not only the number (D71, #733): what is confirmed here cannot be
352
+ undone, and "82 items" is not something a person can decide about. The list the
353
+ API sends is capped, so whatever it does not carry is counted out loud. */}
354
+ {preview.data.items.length > 0 ? (
355
+ <ul className="max-h-40 list-disc overflow-y-auto pl-5">
356
+ {preview.data.items.map((entry) => (
357
+ <li key={entry.id}>{entry.title}</li>
358
+ ))}
359
+ </ul>
360
+ ) : null}
361
+ {preview.data.totalItems > preview.data.items.length ? (
362
+ <p>
363
+ {i18n.t("archive.purge.items.more", {
364
+ count: preview.data.totalItems - preview.data.items.length,
365
+ })}
366
+ </p>
367
+ ) : null}
368
+ <p>
369
+ {preview.data.inboundLinks === 0
370
+ ? i18n.t("archive.purge.links.none")
371
+ : preview.data.inboundLinks === 1
372
+ ? i18n.t("archive.purge.links.one")
373
+ : i18n.t("archive.purge.links.many", { count: preview.data.inboundLinks })}
374
+ </p>
375
+ </div>
376
+ ) : null}
377
+ {confirming.previewPurge && preview.isPending ? (
378
+ <p className="mt-2 text-sm text-muted-foreground">
379
+ {i18n.t("archive.purge.previewLoading")}
380
+ </p>
381
+ ) : null}
382
+ {confirming.previewPurge && preview.isError ? (
383
+ <p role="alert" className="mt-2 text-sm text-destructive">
384
+ {i18n.t("archive.purge.previewFailed")}
385
+ </p>
386
+ ) : null}
387
+ <p className="mt-2 text-sm text-muted-foreground">{i18n.t("archive.purge.grants")}</p>
388
+ {/* ⚠️ Inside the dialog, because the dialog stays open on a refusal and hides the page
389
+ behind it from the accessibility tree entirely (#601). Above the buttons rather than
390
+ below them: the answer belongs between the reason and the button that asks again. */}
391
+ {purge.isError ? <PurgeRefusal error={purge.error} className="mt-4" /> : null}
392
+ <div className="mt-6 flex justify-end gap-2">
393
+ <button
394
+ type="button"
395
+ onClick={() => setConfirming(null)}
396
+ className="inline-flex h-8 items-center rounded-md border px-3 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
397
+ >
398
+ {i18n.t("archive.purge.cancel")}
399
+ </button>
400
+ <button
401
+ type="button"
402
+ disabled={
403
+ purge.isPending ||
404
+ (Boolean(confirming.previewPurge) && (preview.isPending || preview.isError))
405
+ }
406
+ onClick={() => purge.mutate(confirming)}
407
+ className="inline-flex h-8 items-center gap-2 rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground outline-none hover:bg-destructive/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
408
+ >
409
+ <Trash2 aria-hidden="true" className="size-4" />
410
+ {i18n.t("archive.purge.confirm")}
411
+ </button>
412
+ </div>
413
+ </Modal>
414
+ ) : null}
415
+ </div>
416
+ );
417
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What a file IS, and how big it is, without opening it.
3
+ *
4
+ * ⚠️ **Here rather than beside the viewer, and the reason is the bundle** (#819). Since the details
5
+ * field of every kind is drawn from the title line, these two are on the main path — while
6
+ * `attachmentFormatDetail`, which unpacks the file itself, stays with the viewer and keeps `fflate`
7
+ * in the viewer's lazy chunk. Two modules, drawn along the line between "cheap and always needed"
8
+ * and "expensive and only for a file that is open".
9
+ */
10
+ // ⚠️ `audio` and `video` carry no preview (nothing under `file-preview/` renders either), but they
11
+ // are still their OWN kind here rather than folding into `unsupported` (#815, Jack's decision of
12
+ // 2026-08-29). What decides membership in this type is what a reader should be able to TELL APART,
13
+ // not what this package can render — an audio file and a spreadsheet are just as unrelated as a PDF
14
+ // and a spreadsheet, and drawing both of the first two as the same generic file would hide exactly
15
+ // the distinction a reader came here for.
16
+ export type AttachmentPreviewKind =
17
+ | "image"
18
+ | "pdf"
19
+ | "word"
20
+ | "spreadsheet"
21
+ | "presentation"
22
+ | "audio"
23
+ | "video"
24
+ | "unsupported";
25
+
26
+ const extensions: Record<Exclude<AttachmentPreviewKind, "unsupported">, readonly string[]> = {
27
+ image: ["avif", "bmp", "gif", "jpeg", "jpg", "png", "svg", "webp"],
28
+ pdf: ["pdf"],
29
+ word: ["docx"],
30
+ spreadsheet: ["xlsx"],
31
+ presentation: ["pptx"],
32
+ audio: ["aac", "flac", "m4a", "mp3", "ogg", "wav"],
33
+ video: ["avi", "mkv", "mov", "mp4", "webm"],
34
+ };
35
+
36
+ export function attachmentPreviewKind(title: string, mediaType: string): AttachmentPreviewKind {
37
+ if (mediaType.startsWith("image/")) return "image";
38
+ if (mediaType === "application/pdf") return "pdf";
39
+ if (mediaType.startsWith("audio/")) return "audio";
40
+ if (mediaType.startsWith("video/")) return "video";
41
+ const extension = title.toLowerCase().split(".").at(-1) ?? "";
42
+ for (const [kind, supported] of Object.entries(extensions)) {
43
+ if (supported.includes(extension)) return kind as AttachmentPreviewKind;
44
+ }
45
+ return "unsupported";
46
+ }
47
+
48
+ export function formatFileSize(bytes: number): string {
49
+ if (bytes < 1_000) return `${bytes} B`;
50
+ if (bytes < 1_000_000) return `${(bytes / 1_000).toFixed(bytes < 10_000 ? 1 : 0)} KB`;
51
+ return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`;
52
+ }
@@ -0,0 +1,166 @@
1
+ import type { NodeDocument } from "@contract/node.ts";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { unzipSync } from "fflate";
4
+ import { FileQuestion } from "lucide-react";
5
+ import { Component, lazy, type ReactNode, Suspense, useEffect, useMemo, useState } from "react";
6
+ import {
7
+ type AttachmentPreviewKind,
8
+ attachmentPreviewKind,
9
+ } from "@/attachment-detail/attachment-detail.ts";
10
+ import { useI18n } from "@/i18n/i18n-context.tsx";
11
+ import { cn } from "@/lib/utils";
12
+ import { useIntelRouterContext } from "@/router/router-context.ts";
13
+
14
+ const FilePreview = lazy(async () => ({
15
+ default: (await import("@/file-preview/file-preview.tsx")).FilePreview,
16
+ }));
17
+
18
+ type FormatDetail = {
19
+ label: "dimensions" | "pages" | "sections" | "slides" | "worksheets";
20
+ value: string;
21
+ };
22
+
23
+ export async function attachmentFormatDetail(
24
+ file: File,
25
+ kind: AttachmentPreviewKind,
26
+ ): Promise<FormatDetail | null> {
27
+ if (kind === "image") {
28
+ const bitmap = await createImageBitmap(file);
29
+ const value = `${bitmap.width} × ${bitmap.height} px`;
30
+ bitmap.close();
31
+ return { label: "dimensions", value };
32
+ }
33
+ const bytes = new Uint8Array(await file.arrayBuffer());
34
+ if (kind === "pdf") {
35
+ const source = new TextDecoder("latin1").decode(bytes);
36
+ const pages = source.match(/\/Type\s*\/Page\b/g)?.length ?? 0;
37
+ return pages > 0 ? { label: "pages", value: String(pages) } : null;
38
+ }
39
+ // Neither carries a zip archive to look inside, and neither has anything else this function
40
+ // could read out of the bytes (#815) — same as `unsupported`.
41
+ if (kind === "unsupported" || kind === "audio" || kind === "video") return null;
42
+ const archive = unzipSync(bytes);
43
+ if (kind === "presentation") {
44
+ const slides = Object.keys(archive).filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name));
45
+ return { label: "slides", value: String(slides.length) };
46
+ }
47
+ const decoder = new TextDecoder();
48
+ if (kind === "spreadsheet") {
49
+ const workbook = archive["xl/workbook.xml"];
50
+ if (!workbook) return null;
51
+ const worksheets = decoder.decode(workbook).match(/<sheet\b/g)?.length ?? 0;
52
+ return { label: "worksheets", value: String(worksheets) };
53
+ }
54
+ const document = archive["word/document.xml"];
55
+ if (!document) return null;
56
+ const sections = Math.max(1, decoder.decode(document).match(/<w:sectPr\b/g)?.length ?? 0);
57
+ return { label: "sections", value: String(sections) };
58
+ }
59
+
60
+ export function AttachmentViewer({ document }: { document: NodeDocument }) {
61
+ const { data } = useIntelRouterContext();
62
+ const i18n = useI18n();
63
+ const attachment = useQuery({
64
+ queryKey: ["attachment", document.node.id, document.version?.id],
65
+ queryFn: () => data.getNodeAttachment(document.node.id),
66
+ enabled: document.version !== null,
67
+ retry: false,
68
+ });
69
+ const file = useMemo(
70
+ () =>
71
+ attachment.data && document.version
72
+ ? new File([attachment.data], document.node.title, { type: document.version.mediaType })
73
+ : null,
74
+ [attachment.data, document.node.title, document.version],
75
+ );
76
+ const kind = document.version
77
+ ? attachmentPreviewKind(document.node.title, document.version.mediaType)
78
+ : "unsupported";
79
+ return (
80
+ // ⚠️ **No details button of its own since #819.** What stood behind it is the details field
81
+ // every kind now has, in the title line — a file was the one kind that had one, and a second
82
+ // one here would be the exception the ticket exists to end.
83
+ <div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
84
+ <div
85
+ data-preview-kind={kind}
86
+ className={cn(
87
+ "relative flex min-h-0 flex-1 overflow-hidden rounded-xl",
88
+ kind === "image" ? "bg-muted/40" : "border bg-background",
89
+ )}
90
+ >
91
+ <div className="relative min-h-0 min-w-0 flex-1 bg-muted/40">
92
+ {document.version === null ? (
93
+ <PreviewMessage message={i18n.t("attachment.empty")} />
94
+ ) : attachment.isPending ? (
95
+ <PreviewMessage message={i18n.t("common.loading")} />
96
+ ) : attachment.isError || !file ? (
97
+ <PreviewMessage error message={i18n.t("attachment.loadFailed")} />
98
+ ) : kind === "unsupported" || kind === "audio" || kind === "video" ? (
99
+ // ⚠️ Audio and video are told apart by their OWN icon everywhere else in the
100
+ // application (#815), but nothing under `file-preview/` renders either — the message
101
+ // here is the same one an unrecognised file gets, not a claim of a player this screen
102
+ // does not have.
103
+ <PreviewMessage message={i18n.t("attachment.unsupported")} />
104
+ ) : kind === "image" ? (
105
+ <ImagePreview file={file} title={document.node.title} />
106
+ ) : (
107
+ <ViewerErrorBoundary
108
+ key={`${document.node.id}:${document.version.id}`}
109
+ fallback={<PreviewMessage error message={i18n.t("attachment.loadFailed")} />}
110
+ >
111
+ <Suspense fallback={<PreviewMessage message={i18n.t("common.loading")} />}>
112
+ <FilePreview file={file} kind={kind} />
113
+ </Suspense>
114
+ </ViewerErrorBoundary>
115
+ )}
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ class ViewerErrorBoundary extends Component<
123
+ { children: ReactNode; fallback: ReactNode },
124
+ { failed: boolean }
125
+ > {
126
+ override state = { failed: false };
127
+
128
+ static getDerivedStateFromError() {
129
+ return { failed: true };
130
+ }
131
+
132
+ override render() {
133
+ return this.state.failed ? this.props.fallback : this.props.children;
134
+ }
135
+ }
136
+
137
+ function PreviewMessage({ message, error = false }: { message: string; error?: boolean }) {
138
+ return (
139
+ <div
140
+ role={error ? "alert" : undefined}
141
+ className={cn(
142
+ "grid h-full min-h-64 place-items-center p-8 text-center text-sm text-muted-foreground",
143
+ error && "text-destructive",
144
+ )}
145
+ >
146
+ <div className="max-w-sm space-y-3">
147
+ <FileQuestion aria-hidden="true" className="mx-auto size-9" />
148
+ <p>{message}</p>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ function ImagePreview({ file, title }: { file: File; title: string }) {
155
+ const [url, setUrl] = useState("");
156
+ useEffect(() => {
157
+ const next = URL.createObjectURL(file);
158
+ setUrl(next);
159
+ return () => URL.revokeObjectURL(next);
160
+ }, [file]);
161
+ return url ? (
162
+ <div className="grid h-full min-h-64 place-items-center overflow-auto">
163
+ <img src={url} alt={title} className="h-auto w-auto max-h-full max-w-full object-contain" />
164
+ </div>
165
+ ) : null;
166
+ }
@@ -0,0 +1,30 @@
1
+ import { type DefaultReactSuggestionItem, getDefaultReactSlashMenuItems } from "@blocknote/react";
2
+ import { BlockNoteView as LibraryBlockNoteView } from "@blocknote/shadcn";
3
+ import type { ComponentType, ReactNode } from "react";
4
+
5
+ // BlockNote 0.52's generic view declaration conflicts with exactOptionalPropertyTypes even though
6
+ // useCreateBlockNote returns its matching default editor. Keep that upstream type seam in one place.
7
+ const CompatibleBlockNoteView = LibraryBlockNoteView as unknown as ComponentType<{
8
+ editor: unknown;
9
+ onChange(): void;
10
+ children?: ReactNode;
11
+ }>;
12
+
13
+ // `children` is how BlockNote mounts its own controllers — the slash menu among them — inside the
14
+ // editor's context. It travels through this seam rather than around it, so there is still one place
15
+ // that knows about the library's types.
16
+ export function BlockNoteView(props: { editor: unknown; onChange(): void; children?: ReactNode }) {
17
+ return <CompatibleBlockNoteView {...props} />;
18
+ }
19
+
20
+ // The same seam once more: the parameter type of BlockNote's own slash-menu helper does not accept
21
+ // an editor built from a schema of ours, because `heading`'s optional `isToggleable` prop fails the
22
+ // library's own `PropSchema` constraint under `exactOptionalPropertyTypes`. The editor is exactly
23
+ // what the helper wants at run time — it is the declaration that cannot say so.
24
+ const compatibleDefaultSlashMenuItems = getDefaultReactSlashMenuItems as unknown as (
25
+ editor: unknown,
26
+ ) => DefaultReactSuggestionItem[];
27
+
28
+ export function defaultSlashMenuItems(editor: unknown): DefaultReactSuggestionItem[] {
29
+ return compatibleDefaultSlashMenuItems(editor);
30
+ }