@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,610 @@
1
+ import type { BoardColumn, BoardTaskFilter } from "@contract/board.ts";
2
+ import {
3
+ type ColumnVisibilityState,
4
+ columnGroupingFeature,
5
+ columnVisibilityFeature,
6
+ createColumnHelper,
7
+ createExpandedRowModel,
8
+ createGroupedRowModel,
9
+ createSortedRowModel,
10
+ type ExpandedState,
11
+ type GroupingState,
12
+ type Row as LibRow,
13
+ rowExpandingFeature,
14
+ rowSortingFeature,
15
+ type SortingState,
16
+ sortFn_alphanumeric,
17
+ sortFn_basic,
18
+ tableFeatures,
19
+ useTable,
20
+ } from "@tanstack/react-table";
21
+ import { ArrowUpDown, ChevronDown, ChevronRight, Columns3, Filter, LayoutList } from "lucide-react";
22
+ import { useMemo, useState } from "react";
23
+ import { useAssigneeLabel } from "@/board/board-assignee/board-assignee.ts";
24
+ import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
25
+ import { BoardChip } from "@/board/board-chip/board-chip.tsx";
26
+ import { isFiltering } from "@/board/board-data/board-data.ts";
27
+ import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
28
+ import { StatusChip } from "@/board/board-status/board-status.tsx";
29
+ import {
30
+ DropdownMenu,
31
+ DropdownMenuCheckboxItem,
32
+ DropdownMenuContent,
33
+ DropdownMenuRadioGroup,
34
+ DropdownMenuRadioItem,
35
+ DropdownMenuTrigger,
36
+ } from "@/components/ui/dropdown-menu";
37
+ import { useI18n } from "@/i18n/i18n-context.tsx";
38
+ import { type Row, rowsOf } from "./board-table.ts";
39
+
40
+ /**
41
+ * ⚠️ Registered one by one rather than through `stockFeatures`. Every feature this table does not
42
+ * use — pagination, selection, pinning, resizing, faceting — would otherwise sit in the bundle of a
43
+ * screen that never calls it, and the list below is also the honest answer to "what can this table
44
+ * do".
45
+ */
46
+ const features = tableFeatures({
47
+ columnGroupingFeature,
48
+ columnVisibilityFeature,
49
+ rowExpandingFeature,
50
+ rowSortingFeature,
51
+ // ⚠️ A row-model slot has to stand AFTER the feature it needs: sorting, then grouping, then
52
+ // expanding — the order the rows actually pass through.
53
+ sortedRowModel: createSortedRowModel(),
54
+ groupedRowModel: createGroupedRowModel(),
55
+ expandedRowModel: createExpandedRowModel(),
56
+ sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
57
+ });
58
+
59
+ /** One row of THIS table — the library's row type, bound to these features and this data. */
60
+ type TableRow = LibRow<typeof features, Row>;
61
+
62
+ const helper = createColumnHelper<typeof features, Row>();
63
+
64
+ // The one place a grouping is offered. Not free-form: a board has fixed fields, and grouping by
65
+ // anything else would be grouping by something nobody can create.
66
+ /**
67
+ * ⚠️ **Empty becomes `undefined`, never `null`.** `sortUndefined: "last"` fires on `undefined`
68
+ * only; with `null` the row falls through to the comparator, where `null < "2026-08-19"` and
69
+ * `null > "2026-08-19"` are BOTH false. The comparator then claims an equality that is not there,
70
+ * and the order depends on where the empty rows happened to sit. A card without a due date is not
71
+ * the earliest one — it is one whose date nobody knows.
72
+ */
73
+ const orNothing = (value: string | null): string | undefined => value ?? undefined;
74
+
75
+ export function BoardTable({
76
+ board,
77
+ filter,
78
+ onFilter,
79
+ hidden,
80
+ onHidden,
81
+ onOpen,
82
+ }: {
83
+ board: BoardHandle;
84
+ filter: Partial<BoardTaskFilter>;
85
+ onFilter(next: Partial<BoardTaskFilter>): void;
86
+ /**
87
+ * ⚠️ Held ABOVE the view switch, like the filter since #671. This component is unmounted the
88
+ * moment the kanban is shown OR a card is opened, so state kept here would be gone on the way
89
+ * back, and the choice would silently undo itself. Sorting and grouping do stay here: they are
90
+ * about the rows on screen and have no meaning once the rows are gone.
91
+ */
92
+ hidden: ColumnVisibilityState;
93
+ onHidden(next: ColumnVisibilityState): void;
94
+ /** ⚠️ The SAME surface a kanban card opens — one screen for a task, not two that agree today. */
95
+ onOpen(taskId: string): void;
96
+ }) {
97
+ const i18n = useI18n();
98
+ const [sorting, setSorting] = useState<SortingState>([]);
99
+ const [grouping, setGrouping] = useState<GroupingState>([]);
100
+ const [expanded, setExpanded] = useState<ExpandedState>(true);
101
+
102
+ const data = useMemo(() => rowsOf(board.columns), [board.columns]);
103
+
104
+ /**
105
+ * ⚠️ `helper.columns([...])` rather than a bare array. The accessors carry different value types
106
+ * (`string`, `string | undefined`), and a plain array makes TypeScript compare their column
107
+ * definitions structurally — where the `header` and `footer` templates are contravariant and the
108
+ * error reads "two different types with this name exist". The helper is the API's own answer to
109
+ * that; it is not a cast, and it keeps every accessor's own type.
110
+ */
111
+ const columns = useMemo(
112
+ () =>
113
+ helper.columns([
114
+ helper.accessor((row) => row.task.title, {
115
+ id: "title",
116
+ header: i18n.t("board.column.title"),
117
+ // ⚠️ Not hideable. It IS the row: an entry in the chooser that changes nothing when
118
+ // pressed is a control that lies about what it does.
119
+ enableHiding: false,
120
+ sortFn: "alphanumeric",
121
+ // Grouping by the title would make one group per row, which is the table again with an
122
+ // extra line above every entry.
123
+ enableGrouping: false,
124
+ }),
125
+ helper.accessor((row) => row.columnTitle, {
126
+ id: "status",
127
+ header: i18n.t("board.column.status"),
128
+ sortFn: "alphanumeric",
129
+ }),
130
+ helper.accessor((row) => orNothing(row.task.assigneeId), {
131
+ id: "assignee",
132
+ header: i18n.t("board.column.assignee"),
133
+ sortFn: "alphanumeric",
134
+ sortUndefined: "last",
135
+ // ⚠️ Grouped by the RAW id, drawn as a name. Grouping by what is drawn would put everybody
136
+ // this browser cannot name into one group called "a user account".
137
+ }),
138
+ helper.accessor((row) => orNothing(row.task.startDate), {
139
+ id: "start",
140
+ header: i18n.t("board.column.start"),
141
+ // An ISO date sorts correctly as a string, so `basic` is the whole of the comparison.
142
+ sortFn: "basic",
143
+ sortUndefined: "last",
144
+ enableGrouping: false,
145
+ }),
146
+ helper.accessor((row) => orNothing(row.task.dueDate), {
147
+ id: "due",
148
+ header: i18n.t("board.column.due"),
149
+ // An ISO date sorts correctly as a string, so `basic` is the whole of the comparison.
150
+ sortFn: "basic",
151
+ sortUndefined: "last",
152
+ enableGrouping: false,
153
+ }),
154
+ helper.accessor((row) => orNothing(row.blocking), {
155
+ id: "dependsOn",
156
+ header: i18n.t("board.column.dependsOn"),
157
+ sortFn: "alphanumeric",
158
+ sortUndefined: "last",
159
+ // Grouping by what a card waits for is a real question: everything blocked by the same
160
+ // card, in one place.
161
+ }),
162
+ helper.accessor((row) => row.task.labels.join(", "), {
163
+ id: "labels",
164
+ header: i18n.t("board.column.labels"),
165
+ enableSorting: false,
166
+ // ⚠️ NOT groupable. A card carries several labels, and grouping a joined string groups by
167
+ // the accident of their order: `a, b` becomes a group of its own rather than appearing
168
+ // under `a` and under `b`. That is not "grouped by label", and it looks close enough to
169
+ // it that nobody checks.
170
+ enableGrouping: false,
171
+ }),
172
+ ]),
173
+ [i18n],
174
+ );
175
+
176
+ const table = useTable({
177
+ features,
178
+ columns,
179
+ data,
180
+ // The hierarchy the ticket asks for, in one option. Everything else about depth — the indent,
181
+ // the chevron, which rows exist at all — follows from it.
182
+ getSubRows: (row: Row) => row.subRows,
183
+ getRowId: (row: Row) => row.task.id,
184
+ state: { sorting, columnVisibility: hidden, grouping, expanded },
185
+ onSortingChange: setSorting,
186
+ onColumnVisibilityChange: (next) => onHidden(typeof next === "function" ? next(hidden) : next),
187
+ onGroupingChange: setGrouping,
188
+ onExpandedChange: setExpanded,
189
+ /**
190
+ * ⚠️ **Off, and it has to be off.** TanStack resets the expanded state whenever the row
191
+ * structure changes, and on a board that happens constantly: every card edit, every drag and
192
+ * every archive flip hands `data` a new array. With the reset on, opening an epic and then
193
+ * renaming anything on the board closes it again — and switching grouping on collapsed every
194
+ * group the moment it appeared.
195
+ */
196
+ autoResetExpanded: false,
197
+ });
198
+
199
+ if (board.isPending) {
200
+ return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
201
+ }
202
+ if (board.isError) {
203
+ return (
204
+ <p role="alert" className="p-6 text-sm text-destructive">
205
+ {i18n.t("board.failed")}
206
+ </p>
207
+ );
208
+ }
209
+
210
+ const grouped = grouping[0] ?? "none";
211
+ const groupable = table.getAllLeafColumns().filter((column) => column.getCanGroup());
212
+ const sortable = table.getAllLeafColumns().filter((column) => column.getCanSort());
213
+ const configured: BoardColumn[] = board.columns
214
+ .filter((entry) => entry.unknown !== true)
215
+ .map((entry) => entry.column);
216
+ const rows = table.getRowModel().rows;
217
+ // Which chips a row may draw. The column chooser therefore still decides what is on screen, it
218
+ // just decides it per chip instead of per column.
219
+ const visible = new Set(table.getVisibleLeafColumns().map((column) => column.id));
220
+ // ⚠️ Measured on the ANSWER, not on the rows on screen. The filter travels to the server, so an
221
+ // empty answer under a filter means "nothing matches" while an empty answer without one means
222
+ // "this board has no cards" — two different sentences for the same zero.
223
+ const empty = data.length === 0;
224
+
225
+ return (
226
+ <div className="flex min-h-0 flex-1 flex-col gap-3 p-4" aria-busy={board.isWriting}>
227
+ {/* ⚠️ Icons only — what each one offers is in its popup. But the ACTIVE state has to stay
228
+ readable without opening anything: the icon fills, and the quiet line beside it names what
229
+ is set. An icon trio that says nothing about its own state forces a click to find out. */}
230
+ <div className="flex flex-wrap items-center gap-1 text-sm">
231
+ <DropdownMenu>
232
+ <DropdownMenuTrigger
233
+ aria-label={i18n.t("board.table.columns")}
234
+ className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-muted"
235
+ >
236
+ <Columns3 aria-hidden="true" className="size-4" />
237
+ </DropdownMenuTrigger>
238
+ <DropdownMenuContent align="start">
239
+ {table.getAllLeafColumns().map((column) => (
240
+ <DropdownMenuCheckboxItem
241
+ key={column.id}
242
+ checked={column.getIsVisible()}
243
+ onCheckedChange={(next) => column.toggleVisibility(next)}
244
+ >
245
+ {String(column.columnDef.header)}
246
+ </DropdownMenuCheckboxItem>
247
+ ))}
248
+ </DropdownMenuContent>
249
+ </DropdownMenu>
250
+
251
+ <DropdownMenu>
252
+ <DropdownMenuTrigger
253
+ aria-label={i18n.t("board.groupBy")}
254
+ className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-muted ${grouped === "none" ? "" : "bg-accent text-accent-foreground"}`}
255
+ >
256
+ <LayoutList aria-hidden="true" className="size-4" />
257
+ </DropdownMenuTrigger>
258
+ <DropdownMenuContent align="start">
259
+ <DropdownMenuRadioGroup
260
+ value={grouped}
261
+ onValueChange={(next) => setGrouping(next === "none" ? [] : [next])}
262
+ >
263
+ <DropdownMenuRadioItem value="none">
264
+ {i18n.t("board.group.none")}
265
+ </DropdownMenuRadioItem>
266
+ {/* ⚠️ Read off the COLUMNS, not a list written here. A hand-kept list is a second
267
+ truth about which columns can group: add a column and the menu stays silent about
268
+ it, and nothing fails — `enableGrouping` on the column itself is the one answer. */}
269
+ {groupable.map((column) => (
270
+ <DropdownMenuRadioItem key={column.id} value={column.id}>
271
+ {String(column.columnDef.header)}
272
+ </DropdownMenuRadioItem>
273
+ ))}
274
+ </DropdownMenuRadioGroup>
275
+ </DropdownMenuContent>
276
+ </DropdownMenu>
277
+
278
+ <DropdownMenu>
279
+ <DropdownMenuTrigger
280
+ aria-label={i18n.t("board.filter.status")}
281
+ className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-muted ${filter.status === undefined ? "" : "bg-accent text-accent-foreground"}`}
282
+ >
283
+ <Filter aria-hidden="true" className="size-4" />
284
+ </DropdownMenuTrigger>
285
+ {/* ⚠️ This one TRAVELS. `useBoard` carries it in the query key and sends it, so the server
286
+ answers with the cards that match — a view that fetched everything and hid rows in the
287
+ browser would look identical on screen and be a different thing over the wire. */}
288
+ <DropdownMenuContent align="start">
289
+ <DropdownMenuRadioGroup
290
+ value={filter.status ?? ""}
291
+ onValueChange={(next) =>
292
+ onFilter(
293
+ next === "" ? { ...filter, status: undefined } : { ...filter, status: next },
294
+ )
295
+ }
296
+ >
297
+ <DropdownMenuRadioItem value="">{i18n.t("board.filter.all")}</DropdownMenuRadioItem>
298
+ {configured.map((column) => (
299
+ <DropdownMenuRadioItem key={column.id} value={column.id}>
300
+ {column.title}
301
+ </DropdownMenuRadioItem>
302
+ ))}
303
+ </DropdownMenuRadioGroup>
304
+ </DropdownMenuContent>
305
+ </DropdownMenu>
306
+
307
+ <DropdownMenu>
308
+ {/* ⚠️ **Sorting needs its own place now.** It used to live on the column headings, and
309
+ those are gone: one heading is left, and hanging four sort orders off it would be a
310
+ control nobody finds. The toolbar is where the other three view settings already are. */}
311
+ <DropdownMenuTrigger
312
+ aria-label={i18n.t("board.sortBy")}
313
+ className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-muted ${sorting.length === 0 ? "" : "bg-accent text-accent-foreground"}`}
314
+ >
315
+ <ArrowUpDown aria-hidden="true" className="size-4" />
316
+ </DropdownMenuTrigger>
317
+ <DropdownMenuContent align="start">
318
+ <DropdownMenuRadioGroup
319
+ value={
320
+ sorting[0] === undefined
321
+ ? ""
322
+ : `${sorting[0].id}:${sorting[0].desc ? "desc" : "asc"}`
323
+ }
324
+ onValueChange={(next) =>
325
+ setSorting(
326
+ next === ""
327
+ ? []
328
+ : [{ id: next.split(":")[0] ?? "", desc: next.endsWith(":desc") }],
329
+ )
330
+ }
331
+ >
332
+ <DropdownMenuRadioItem value="">{i18n.t("board.sort.none")}</DropdownMenuRadioItem>
333
+ {/* ⚠️ Read off the COLUMNS like the grouping menu, not from a list written here: a
334
+ column that stops being sortable disappears from the menu on its own. */}
335
+ {sortable.flatMap((column) => [
336
+ <DropdownMenuRadioItem key={`${column.id}:asc`} value={`${column.id}:asc`}>
337
+ {i18n.t("board.sort.asc", { column: String(column.columnDef.header) })}
338
+ </DropdownMenuRadioItem>,
339
+ <DropdownMenuRadioItem key={`${column.id}:desc`} value={`${column.id}:desc`}>
340
+ {i18n.t("board.sort.desc", { column: String(column.columnDef.header) })}
341
+ </DropdownMenuRadioItem>,
342
+ ])}
343
+ </DropdownMenuRadioGroup>
344
+ </DropdownMenuContent>
345
+ </DropdownMenu>
346
+
347
+ <span className="ml-2 text-xs text-muted-foreground">
348
+ {[
349
+ sorting[0] === undefined
350
+ ? null
351
+ : i18n.t(sorting[0].desc ? "board.sort.desc" : "board.sort.asc", {
352
+ column: String(
353
+ sortable.find((column) => column.id === sorting[0]?.id)?.columnDef.header ??
354
+ sorting[0].id,
355
+ ),
356
+ }),
357
+ grouped === "none"
358
+ ? null
359
+ : i18n.t("board.table.groupedBy", {
360
+ column: String(
361
+ groupable.find((column) => column.id === grouped)?.columnDef.header ?? grouped,
362
+ ),
363
+ }),
364
+ filter.status === undefined
365
+ ? null
366
+ : i18n.t("board.table.filteredBy", {
367
+ column: configured.find((c) => c.id === filter.status)?.title ?? filter.status,
368
+ }),
369
+ ]
370
+ .filter(Boolean)
371
+ .join(" · ")}
372
+ </span>
373
+ </div>
374
+
375
+ <div className="min-h-0 flex-1 overflow-auto">
376
+ <table className="w-full border-collapse text-left text-sm">
377
+ {/* ⚠️ ONE heading. The other five said what their column was called and nothing about the
378
+ row; a card without a due date filled a cell with emptiness, and a table half made of
379
+ emptiness is one people stop reading. What a value IS, its chip says. */}
380
+ <thead>
381
+ <tr className="border-b">
382
+ {/* ⚠️ `aria-sort` survives the loss of the other headings. It is the only place the
383
+ TABLE itself says it is ordered; the quiet line in the toolbar says it to whoever
384
+ reads the toolbar, and a screen reader announcing a table does not. It reads
385
+ `none` while another column orders the rows, because that is what is true of this
386
+ one. */}
387
+ <th
388
+ scope="col"
389
+ aria-sort={
390
+ sorting[0]?.id !== "title" ? "none" : sorting[0].desc ? "descending" : "ascending"
391
+ }
392
+ className="px-2 py-3 font-medium"
393
+ >
394
+ {i18n.t("board.column.title")}
395
+ </th>
396
+ </tr>
397
+ </thead>
398
+ {/* ⚠️ One `tbody` PER GROUP, and the heading scopes to it. That is what actually
399
+ associates a heading with the rows under it: `scope="row"` binds to the heading's own
400
+ row and says nothing about the ones below, so a single `tbody` with headings sprinkled
401
+ through it reads to a screen reader as one long list with odd rows in it. */}
402
+ {groupsOf(rows).map((group) => (
403
+ <tbody key={group.key}>
404
+ {group.heading === undefined ? null : <GroupRow row={group.heading} span={1} />}
405
+ {group.rows.map((row) => (
406
+ <tr
407
+ key={row.id}
408
+ // ⚠️ A very light grey, and on the whole row. It says which row the pointer is on,
409
+ // which is the only thing a table this wide cannot say by itself.
410
+ className="border-b last:border-0 hover:bg-muted/40"
411
+ >
412
+ <td className="px-2 py-3">
413
+ <RowContent
414
+ row={row}
415
+ visible={visible}
416
+ onOpen={onOpen}
417
+ board={board}
418
+ columns={configured}
419
+ />
420
+ </td>
421
+ </tr>
422
+ ))}
423
+ </tbody>
424
+ ))}
425
+ </table>
426
+ {empty ? (
427
+ <p className="p-6 text-sm text-muted-foreground">
428
+ {i18n.t(isFiltering(filter) ? "board.filterEmpty" : "board.tableEmpty")}
429
+ </p>
430
+ ) : null}
431
+ </div>
432
+ </div>
433
+ );
434
+ }
435
+
436
+ /**
437
+ * The rows split into groups, each with the heading that belongs to it.
438
+ *
439
+ * With no grouping there is exactly one group and no heading, so the view keeps ONE code path
440
+ * rather than a branch that only the grouped case exercises.
441
+ */
442
+ function groupsOf(rows: TableRow[]): { key: string; heading?: TableRow; rows: TableRow[] }[] {
443
+ const groups: { key: string; heading?: TableRow; rows: TableRow[] }[] = [];
444
+ for (const row of rows) {
445
+ if (row.getIsGrouped()) groups.push({ key: row.id, heading: row, rows: [] });
446
+ else if (groups.length === 0) groups.push({ key: "all", rows: [row] });
447
+ else groups[groups.length - 1]?.rows.push(row);
448
+ }
449
+ return groups;
450
+ }
451
+
452
+ /**
453
+ * The heading of one group.
454
+ *
455
+ * ⚠️ A component rather than a branch in the loop, because the label needs a hook: grouping by who
456
+ * a card belongs to groups by the raw Gate id, and that id must not reach the screen — nor an
457
+ * `aria-label`, which is screen for everybody using one (`user-name.ts`, #258).
458
+ */
459
+ function GroupRow({ row, span }: { row: TableRow; span: number }) {
460
+ const i18n = useI18n();
461
+ const column = row.groupingColumnId;
462
+ const value = column === undefined ? undefined : row.getGroupingValue(column);
463
+ const assignee = column === "assignee";
464
+ const resolved = useAssigneeLabel(assignee ? ((value as string | null) ?? null) : null);
465
+ const label = assignee
466
+ ? resolved
467
+ : value === undefined || value === null || value === ""
468
+ ? i18n.t("board.group.empty")
469
+ : String(value);
470
+ // ⚠️ Every row of the group, not `row.subRows`. A grouped row's `subRows` holds the TOP level
471
+ // only, so an epic with three subtasks under it counts as one — a heading that says `2` above
472
+ // four visible rows.
473
+ const count = row.getLeafRows().length;
474
+
475
+ return (
476
+ <tr className="border-b bg-muted/40">
477
+ <th scope="rowgroup" colSpan={span} className="px-2 py-2 text-left font-medium">
478
+ <button
479
+ type="button"
480
+ onClick={row.getToggleExpandedHandler()}
481
+ aria-label={i18n.t(row.getIsExpanded() ? "board.table.collapse" : "board.table.expand", {
482
+ row: label,
483
+ })}
484
+ className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
485
+ >
486
+ {row.getIsExpanded() ? (
487
+ <ChevronDown aria-hidden="true" className="size-4" />
488
+ ) : (
489
+ <ChevronRight aria-hidden="true" className="size-4" />
490
+ )}
491
+ {label}
492
+ <span className="text-xs text-muted-foreground tabular-nums">{count}</span>
493
+ </button>
494
+ </th>
495
+ </tr>
496
+ );
497
+ }
498
+
499
+ /**
500
+ * One row: the title, and beside it only the values that are set.
501
+ *
502
+ * ⚠️ **Always the same order** — column, dates, labels, what it waits for, then who it is for. The
503
+ * rows carry different numbers of chips, so without a fixed order the eye has no line to follow and
504
+ * every row has to be read from the start.
505
+ *
506
+ * ⚠️ **The circle is last, always.** It is the only round thing in the row; between the chips it
507
+ * breaks the line, and a row nobody is on simply ends earlier.
508
+ *
509
+ * ⚠️ **A chip is drawn only when its column is visible AND its value is set.** The column chooser
510
+ * therefore still works, it just hides a chip rather than an empty cell.
511
+ */
512
+ function RowContent({
513
+ row,
514
+ visible,
515
+ onOpen,
516
+ board,
517
+ columns,
518
+ }: {
519
+ row: TableRow;
520
+ visible: Set<string>;
521
+ onOpen(taskId: string): void;
522
+ board: BoardHandle;
523
+ columns: BoardColumn[];
524
+ }) {
525
+ const i18n = useI18n();
526
+ const { task, columnTitle, blocking } = row.original;
527
+
528
+ const write = (input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>) => {
529
+ // ⚠️ Refused while a write is in flight, like every other surface of this board (#651).
530
+ if (board.isWriting) return;
531
+ void board.updateTask({ taskId: task.id, ...input, idempotencyKey: crypto.randomUUID() });
532
+ };
533
+
534
+ return (
535
+ <span className="flex items-center gap-2" style={{ paddingLeft: `${row.depth * 20}px` }}>
536
+ {row.getCanExpand() ? (
537
+ <button
538
+ type="button"
539
+ aria-label={i18n.t(row.getIsExpanded() ? "board.table.collapse" : "board.table.expand", {
540
+ row: task.title,
541
+ })}
542
+ onClick={row.getToggleExpandedHandler()}
543
+ className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
544
+ >
545
+ {row.getIsExpanded() ? (
546
+ <ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
547
+ ) : (
548
+ <ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
549
+ )}
550
+ </button>
551
+ ) : (
552
+ <span className="size-4" />
553
+ )}
554
+
555
+ <button
556
+ type="button"
557
+ onClick={() => onOpen(task.id)}
558
+ className="min-w-0 flex-1 truncate rounded-sm text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
559
+ >
560
+ {task.title}
561
+ </button>
562
+
563
+ {visible.has("status") ? (
564
+ <StatusChip status={task.status} title={columnTitle} columns={columns} write={write} />
565
+ ) : null}
566
+
567
+ {visible.has("start") && task.startDate !== null ? (
568
+ <BoardChip
569
+ label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
570
+ action={i18n.t("board.clearStart")}
571
+ onClick={() => write({ startDate: null })}
572
+ />
573
+ ) : null}
574
+ {visible.has("due") && task.dueDate !== null ? (
575
+ <BoardChip
576
+ label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
577
+ action={i18n.t("board.clearDue")}
578
+ onClick={() => write({ dueDate: null })}
579
+ />
580
+ ) : null}
581
+
582
+ {visible.has("labels")
583
+ ? task.labels.map((label) => (
584
+ <BoardChip
585
+ key={label}
586
+ label={label}
587
+ action={i18n.t("board.removeLabel")}
588
+ onClick={() => write({ labels: task.labels.filter((keep) => keep !== label) })}
589
+ />
590
+ ))
591
+ : null}
592
+
593
+ {visible.has("dependsOn") && blocking !== null ? (
594
+ <BoardChip
595
+ label={blocking}
596
+ action={i18n.t("board.clearDependsOn")}
597
+ onClick={() => write({ dependsOn: null })}
598
+ />
599
+ ) : null}
600
+
601
+ {visible.has("assignee") && task.assigneeId !== null ? (
602
+ <AssigneeChip
603
+ id={task.assigneeId}
604
+ boardId={board.boardId}
605
+ onPick={(assigneeId) => write({ assigneeId })}
606
+ />
607
+ ) : null}
608
+ </span>
609
+ );
610
+ }