hazo_collab_forms 3.1.7 → 5.0.2

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 (284) hide show
  1. package/CHANGE_LOG.md +207 -0
  2. package/README.md +3 -0
  3. package/dist/components/clarification/clarification_item_body.d.ts +19 -1
  4. package/dist/components/clarification/clarification_item_body.d.ts.map +1 -1
  5. package/dist/components/clarification/clarification_item_body.js +114 -6
  6. package/dist/components/clarification/clarification_item_body.js.map +1 -1
  7. package/dist/components/clarification/clarification_thread.js +1 -1
  8. package/dist/components/clarification/clarification_thread.js.map +1 -1
  9. package/dist/components/clarification/index.d.ts +2 -0
  10. package/dist/components/clarification/index.d.ts.map +1 -1
  11. package/dist/components/clarification/index.js +1 -0
  12. package/dist/components/clarification/index.js.map +1 -1
  13. package/dist/components/clarification/resolution_status_strip.d.ts +18 -0
  14. package/dist/components/clarification/resolution_status_strip.d.ts.map +1 -0
  15. package/dist/components/clarification/resolution_status_strip.js +20 -0
  16. package/dist/components/clarification/resolution_status_strip.js.map +1 -0
  17. package/dist/components/hazo_fb_form/context.d.ts +1 -1
  18. package/dist/components/hazo_fb_form/context.d.ts.map +1 -1
  19. package/dist/components/hazo_fb_form/hazo_fb_form.d.ts.map +1 -1
  20. package/dist/components/hazo_fb_form/hazo_fb_form.js +330 -113
  21. package/dist/components/hazo_fb_form/hazo_fb_form.js.map +1 -1
  22. package/dist/components/hazo_fb_form/hooks/use_fb_form_state.d.ts +3 -3
  23. package/dist/components/hazo_fb_form/hooks/use_fb_form_state.d.ts.map +1 -1
  24. package/dist/components/hazo_fb_form/hooks/use_fb_form_state.js +339 -46
  25. package/dist/components/hazo_fb_form/hooks/use_fb_form_state.js.map +1 -1
  26. package/dist/components/hazo_fb_form/hooks/use_llm_run.d.ts +3 -1
  27. package/dist/components/hazo_fb_form/hooks/use_llm_run.d.ts.map +1 -1
  28. package/dist/components/hazo_fb_form/hooks/use_llm_run.js +89 -11
  29. package/dist/components/hazo_fb_form/hooks/use_llm_run.js.map +1 -1
  30. package/dist/components/hazo_fb_form/shared/agent_stepper.js +1 -1
  31. package/dist/components/hazo_fb_form/shared/agent_stepper.js.map +1 -1
  32. package/dist/components/hazo_fb_form/shared/file_status_accordion.d.ts +9 -0
  33. package/dist/components/hazo_fb_form/shared/file_status_accordion.d.ts.map +1 -0
  34. package/dist/components/hazo_fb_form/shared/file_status_accordion.js +39 -0
  35. package/dist/components/hazo_fb_form/shared/file_status_accordion.js.map +1 -0
  36. package/dist/components/hazo_fb_form/shared/format.d.ts.map +1 -1
  37. package/dist/components/hazo_fb_form/shared/format.js +8 -3
  38. package/dist/components/hazo_fb_form/shared/format.js.map +1 -1
  39. package/dist/components/hazo_fb_form/shared/send_back_item_card.d.ts +7 -1
  40. package/dist/components/hazo_fb_form/shared/send_back_item_card.d.ts.map +1 -1
  41. package/dist/components/hazo_fb_form/shared/send_back_item_card.js +6 -3
  42. package/dist/components/hazo_fb_form/shared/send_back_item_card.js.map +1 -1
  43. package/dist/components/hazo_fb_form/types.d.ts +3 -1
  44. package/dist/components/hazo_fb_form/types.d.ts.map +1 -1
  45. package/dist/components/hazo_fb_form/views/back_office_view.js +1 -1
  46. package/dist/components/hazo_fb_form/views/back_office_view.js.map +1 -1
  47. package/dist/components/hazo_fb_form/views/clarifications_view.js +2 -2
  48. package/dist/components/hazo_fb_form/views/clarifications_view.js.map +1 -1
  49. package/dist/components/hazo_fb_form/views/front_office_view.d.ts.map +1 -1
  50. package/dist/components/hazo_fb_form/views/front_office_view.js +62 -41
  51. package/dist/components/hazo_fb_form/views/front_office_view.js.map +1 -1
  52. package/dist/components/hazo_fb_form/views/interim_view.js +3 -3
  53. package/dist/components/hazo_fb_form/views/interim_view.js.map +1 -1
  54. package/dist/components/hazo_fb_form/views/review_queue_view.d.ts.map +1 -1
  55. package/dist/components/hazo_fb_form/views/review_queue_view.js +22 -9
  56. package/dist/components/hazo_fb_form/views/review_queue_view.js.map +1 -1
  57. package/dist/components/hazo_validation_rule_editor/components/rule_editor.d.ts.map +1 -1
  58. package/dist/components/hazo_validation_rule_editor/components/rule_editor.js +32 -3
  59. package/dist/components/hazo_validation_rule_editor/components/rule_editor.js.map +1 -1
  60. package/dist/components/hazo_validation_rule_editor/components/variable_chain_input.d.ts +20 -0
  61. package/dist/components/hazo_validation_rule_editor/components/variable_chain_input.d.ts.map +1 -0
  62. package/dist/components/hazo_validation_rule_editor/components/variable_chain_input.js +34 -0
  63. package/dist/components/hazo_validation_rule_editor/components/variable_chain_input.js.map +1 -0
  64. package/dist/components/hazo_validation_rule_editor/context.d.ts +3 -2
  65. package/dist/components/hazo_validation_rule_editor/context.d.ts.map +1 -1
  66. package/dist/components/hazo_validation_rule_editor/context.js +15 -3
  67. package/dist/components/hazo_validation_rule_editor/context.js.map +1 -1
  68. package/dist/components/hazo_validation_rule_editor/types.d.ts +7 -1
  69. package/dist/components/hazo_validation_rule_editor/types.d.ts.map +1 -1
  70. package/dist/components/hazo_validation_rule_editor/validation_rule_editor.d.ts +1 -1
  71. package/dist/components/hazo_validation_rule_editor/validation_rule_editor.d.ts.map +1 -1
  72. package/dist/components/hazo_validation_rule_editor/validation_rule_editor.js +2 -2
  73. package/dist/components/hazo_validation_rule_editor/validation_rule_editor.js.map +1 -1
  74. package/dist/components/index.d.ts +2 -0
  75. package/dist/components/index.d.ts.map +1 -1
  76. package/dist/components/index.js +2 -0
  77. package/dist/components/index.js.map +1 -1
  78. package/dist/components/shared/document_type_editor.d.ts +31 -0
  79. package/dist/components/shared/document_type_editor.d.ts.map +1 -0
  80. package/dist/components/shared/document_type_editor.js +60 -0
  81. package/dist/components/shared/document_type_editor.js.map +1 -0
  82. package/dist/components/shared/file_bar/file_bar.d.ts +7 -1
  83. package/dist/components/shared/file_bar/file_bar.d.ts.map +1 -1
  84. package/dist/components/shared/file_bar/file_bar.js +5 -3
  85. package/dist/components/shared/file_bar/file_bar.js.map +1 -1
  86. package/dist/components/shared/file_bar/file_bar_validation_dialog.js +4 -4
  87. package/dist/components/shared/file_bar/file_bar_validation_dialog.js.map +1 -1
  88. package/dist/components/shared/file_status_icon.d.ts +23 -0
  89. package/dist/components/shared/file_status_icon.d.ts.map +1 -0
  90. package/dist/components/shared/file_status_icon.js +38 -0
  91. package/dist/components/shared/file_status_icon.js.map +1 -0
  92. package/dist/components/shared/json_data_panel/json_data_panel.d.ts +1 -1
  93. package/dist/components/shared/json_data_panel/json_data_panel.d.ts.map +1 -1
  94. package/dist/components/shared/json_data_panel/json_data_panel.js +27 -2
  95. package/dist/components/shared/json_data_panel/json_data_panel.js.map +1 -1
  96. package/dist/components/shared/rule_result_card.d.ts.map +1 -1
  97. package/dist/components/shared/rule_result_card.js +5 -4
  98. package/dist/components/shared/rule_result_card.js.map +1 -1
  99. package/dist/components/thread_form/components/add_question_dialog.d.ts +12 -0
  100. package/dist/components/thread_form/components/add_question_dialog.d.ts.map +1 -0
  101. package/dist/components/thread_form/components/add_question_dialog.js +36 -0
  102. package/dist/components/thread_form/components/add_question_dialog.js.map +1 -0
  103. package/dist/components/thread_form/components/agent_compose_dialog.d.ts +30 -0
  104. package/dist/components/thread_form/components/agent_compose_dialog.d.ts.map +1 -0
  105. package/dist/components/thread_form/components/agent_compose_dialog.js +45 -0
  106. package/dist/components/thread_form/components/agent_compose_dialog.js.map +1 -0
  107. package/dist/components/thread_form/components/clarification.d.ts +14 -0
  108. package/dist/components/thread_form/components/clarification.d.ts.map +1 -0
  109. package/dist/components/thread_form/components/clarification.js +12 -0
  110. package/dist/components/thread_form/components/clarification.js.map +1 -0
  111. package/dist/components/thread_form/components/collected_data_view.d.ts +15 -0
  112. package/dist/components/thread_form/components/collected_data_view.d.ts.map +1 -0
  113. package/dist/components/thread_form/components/collected_data_view.js +121 -0
  114. package/dist/components/thread_form/components/collected_data_view.js.map +1 -0
  115. package/dist/components/thread_form/components/coverage_card.d.ts +11 -0
  116. package/dist/components/thread_form/components/coverage_card.d.ts.map +1 -0
  117. package/dist/components/thread_form/components/coverage_card.js +60 -0
  118. package/dist/components/thread_form/components/coverage_card.js.map +1 -0
  119. package/dist/components/thread_form/components/file_bar.d.ts +93 -0
  120. package/dist/components/thread_form/components/file_bar.d.ts.map +1 -0
  121. package/dist/components/thread_form/components/file_bar.js +251 -0
  122. package/dist/components/thread_form/components/file_bar.js.map +1 -0
  123. package/dist/components/thread_form/components/file_info_dialog.d.ts +15 -0
  124. package/dist/components/thread_form/components/file_info_dialog.d.ts.map +1 -0
  125. package/dist/components/thread_form/components/file_info_dialog.js +64 -0
  126. package/dist/components/thread_form/components/file_info_dialog.js.map +1 -0
  127. package/dist/components/thread_form/components/issue_group_tree.d.ts +20 -0
  128. package/dist/components/thread_form/components/issue_group_tree.d.ts.map +1 -0
  129. package/dist/components/thread_form/components/issue_group_tree.js +164 -0
  130. package/dist/components/thread_form/components/issue_group_tree.js.map +1 -0
  131. package/dist/components/thread_form/components/pdf_side_panel.d.ts +20 -0
  132. package/dist/components/thread_form/components/pdf_side_panel.d.ts.map +1 -0
  133. package/dist/components/thread_form/components/pdf_side_panel.js +63 -0
  134. package/dist/components/thread_form/components/pdf_side_panel.js.map +1 -0
  135. package/dist/components/thread_form/components/rule_decision_row.d.ts +31 -0
  136. package/dist/components/thread_form/components/rule_decision_row.d.ts.map +1 -0
  137. package/dist/components/thread_form/components/rule_decision_row.js +20 -0
  138. package/dist/components/thread_form/components/rule_decision_row.js.map +1 -0
  139. package/dist/components/thread_form/components/send_back_message.d.ts +32 -0
  140. package/dist/components/thread_form/components/send_back_message.d.ts.map +1 -0
  141. package/dist/components/thread_form/components/send_back_message.js +82 -0
  142. package/dist/components/thread_form/components/send_back_message.js.map +1 -0
  143. package/dist/components/thread_form/components/shared.d.ts +54 -0
  144. package/dist/components/thread_form/components/shared.d.ts.map +1 -0
  145. package/dist/components/thread_form/components/shared.js +136 -0
  146. package/dist/components/thread_form/components/shared.js.map +1 -0
  147. package/dist/components/thread_form/components/task_card.d.ts +90 -0
  148. package/dist/components/thread_form/components/task_card.d.ts.map +1 -0
  149. package/dist/components/thread_form/components/task_card.js +63 -0
  150. package/dist/components/thread_form/components/task_card.js.map +1 -0
  151. package/dist/components/thread_form/components/text_doc_check.d.ts +15 -0
  152. package/dist/components/thread_form/components/text_doc_check.d.ts.map +1 -0
  153. package/dist/components/thread_form/components/text_doc_check.js +16 -0
  154. package/dist/components/thread_form/components/text_doc_check.js.map +1 -0
  155. package/dist/components/thread_form/components/text_extraction.d.ts +14 -0
  156. package/dist/components/thread_form/components/text_extraction.d.ts.map +1 -0
  157. package/dist/components/thread_form/components/text_extraction.js +16 -0
  158. package/dist/components/thread_form/components/text_extraction.js.map +1 -0
  159. package/dist/components/thread_form/components/thread_composer.d.ts +15 -0
  160. package/dist/components/thread_form/components/thread_composer.d.ts.map +1 -0
  161. package/dist/components/thread_form/components/thread_composer.js +93 -0
  162. package/dist/components/thread_form/components/thread_composer.js.map +1 -0
  163. package/dist/components/thread_form/components/thread_timeline.d.ts +65 -0
  164. package/dist/components/thread_form/components/thread_timeline.d.ts.map +1 -0
  165. package/dist/components/thread_form/components/thread_timeline.js +225 -0
  166. package/dist/components/thread_form/components/thread_timeline.js.map +1 -0
  167. package/dist/components/thread_form/hooks/use_file_pipeline.d.ts +126 -0
  168. package/dist/components/thread_form/hooks/use_file_pipeline.d.ts.map +1 -0
  169. package/dist/components/thread_form/hooks/use_file_pipeline.js +760 -0
  170. package/dist/components/thread_form/hooks/use_file_pipeline.js.map +1 -0
  171. package/dist/components/thread_form/hooks/use_thread_form.d.ts +36 -0
  172. package/dist/components/thread_form/hooks/use_thread_form.d.ts.map +1 -0
  173. package/dist/components/thread_form/hooks/use_thread_form.js +126 -0
  174. package/dist/components/thread_form/hooks/use_thread_form.js.map +1 -0
  175. package/dist/components/thread_form/index.d.ts +33 -0
  176. package/dist/components/thread_form/index.d.ts.map +1 -0
  177. package/dist/components/thread_form/index.js +30 -0
  178. package/dist/components/thread_form/index.js.map +1 -0
  179. package/dist/components/thread_form/sample_data.d.ts +8 -0
  180. package/dist/components/thread_form/sample_data.d.ts.map +1 -0
  181. package/dist/components/thread_form/sample_data.js +658 -0
  182. package/dist/components/thread_form/sample_data.js.map +1 -0
  183. package/dist/components/thread_form/thread_form.d.ts +7 -0
  184. package/dist/components/thread_form/thread_form.d.ts.map +1 -0
  185. package/dist/components/thread_form/thread_form.js +1385 -0
  186. package/dist/components/thread_form/thread_form.js.map +1 -0
  187. package/dist/components/thread_form/types.d.ts +402 -0
  188. package/dist/components/thread_form/types.d.ts.map +1 -0
  189. package/dist/components/thread_form/types.js +23 -0
  190. package/dist/components/thread_form/types.js.map +1 -0
  191. package/dist/components/thread_form/utils/file_decision_state.d.ts +22 -0
  192. package/dist/components/thread_form/utils/file_decision_state.d.ts.map +1 -0
  193. package/dist/components/thread_form/utils/file_decision_state.js +37 -0
  194. package/dist/components/thread_form/utils/file_decision_state.js.map +1 -0
  195. package/dist/components/thread_form/utils/merge_send_back.d.ts +13 -0
  196. package/dist/components/thread_form/utils/merge_send_back.d.ts.map +1 -0
  197. package/dist/components/thread_form/utils/merge_send_back.js +23 -0
  198. package/dist/components/thread_form/utils/merge_send_back.js.map +1 -0
  199. package/dist/lib/autofill_handler.d.ts.map +1 -1
  200. package/dist/lib/autofill_handler.js +5 -44
  201. package/dist/lib/autofill_handler.js.map +1 -1
  202. package/dist/lib/classification_handler.d.ts +105 -0
  203. package/dist/lib/classification_handler.d.ts.map +1 -0
  204. package/dist/lib/classification_handler.js +342 -0
  205. package/dist/lib/classification_handler.js.map +1 -0
  206. package/dist/lib/content_gate_handler.d.ts +37 -0
  207. package/dist/lib/content_gate_handler.d.ts.map +1 -0
  208. package/dist/lib/content_gate_handler.js +126 -0
  209. package/dist/lib/content_gate_handler.js.map +1 -0
  210. package/dist/lib/index.d.ts +10 -0
  211. package/dist/lib/index.d.ts.map +1 -1
  212. package/dist/lib/index.js +5 -0
  213. package/dist/lib/index.js.map +1 -1
  214. package/dist/lib/periodic_coverage_runner.d.ts +24 -0
  215. package/dist/lib/periodic_coverage_runner.d.ts.map +1 -0
  216. package/dist/lib/periodic_coverage_runner.js +121 -0
  217. package/dist/lib/periodic_coverage_runner.js.map +1 -0
  218. package/dist/lib/resolution_handler.d.ts +150 -0
  219. package/dist/lib/resolution_handler.d.ts.map +1 -0
  220. package/dist/lib/resolution_handler.js +597 -0
  221. package/dist/lib/resolution_handler.js.map +1 -0
  222. package/dist/lib/resolve_variable.d.ts +25 -0
  223. package/dist/lib/resolve_variable.d.ts.map +1 -0
  224. package/dist/lib/resolve_variable.js +77 -0
  225. package/dist/lib/resolve_variable.js.map +1 -0
  226. package/dist/lib/validation_handler.d.ts +27 -3
  227. package/dist/lib/validation_handler.d.ts.map +1 -1
  228. package/dist/lib/validation_handler.js +338 -288
  229. package/dist/lib/validation_handler.js.map +1 -1
  230. package/dist/types/clarification.d.ts +54 -0
  231. package/dist/types/clarification.d.ts.map +1 -1
  232. package/dist/types/fb_form_data.d.ts +273 -123
  233. package/dist/types/fb_form_data.d.ts.map +1 -1
  234. package/dist/types/fb_form_data.js +44 -58
  235. package/dist/types/fb_form_data.js.map +1 -1
  236. package/dist/types/fb_form_data_v1.d.ts +250 -0
  237. package/dist/types/fb_form_data_v1.d.ts.map +1 -0
  238. package/dist/types/fb_form_data_v1.js +117 -0
  239. package/dist/types/fb_form_data_v1.js.map +1 -0
  240. package/dist/types/fb_form_instance.d.ts +1 -1
  241. package/dist/types/fb_form_instance.d.ts.map +1 -1
  242. package/dist/types/index.d.ts +5 -3
  243. package/dist/types/index.d.ts.map +1 -1
  244. package/dist/types/index.js +2 -1
  245. package/dist/types/index.js.map +1 -1
  246. package/dist/types/validation.d.ts +134 -12
  247. package/dist/types/validation.d.ts.map +1 -1
  248. package/dist/utils/expectation_extractor.d.ts +31 -0
  249. package/dist/utils/expectation_extractor.d.ts.map +1 -0
  250. package/dist/utils/expectation_extractor.js +142 -0
  251. package/dist/utils/expectation_extractor.js.map +1 -0
  252. package/dist/utils/fb_data_adapter.d.ts +7 -2
  253. package/dist/utils/fb_data_adapter.d.ts.map +1 -1
  254. package/dist/utils/fb_data_adapter.js +58 -7
  255. package/dist/utils/fb_data_adapter.js.map +1 -1
  256. package/dist/utils/fb_data_adapter_v2.d.ts +17 -0
  257. package/dist/utils/fb_data_adapter_v2.d.ts.map +1 -0
  258. package/dist/utils/fb_data_adapter_v2.js +483 -0
  259. package/dist/utils/fb_data_adapter_v2.js.map +1 -0
  260. package/dist/utils/fb_data_helpers.d.ts +1 -1
  261. package/dist/utils/fb_data_helpers.d.ts.map +1 -1
  262. package/dist/utils/fb_data_mutations.d.ts +1 -1
  263. package/dist/utils/fb_data_mutations.d.ts.map +1 -1
  264. package/dist/utils/fb_data_mutations_v2.d.ts +46 -0
  265. package/dist/utils/fb_data_mutations_v2.d.ts.map +1 -0
  266. package/dist/utils/fb_data_mutations_v2.js +341 -0
  267. package/dist/utils/fb_data_mutations_v2.js.map +1 -0
  268. package/dist/utils/fb_data_queries.d.ts +81 -0
  269. package/dist/utils/fb_data_queries.d.ts.map +1 -0
  270. package/dist/utils/fb_data_queries.js +354 -0
  271. package/dist/utils/fb_data_queries.js.map +1 -0
  272. package/dist/utils/index.d.ts +4 -0
  273. package/dist/utils/index.d.ts.map +1 -1
  274. package/dist/utils/index.js +6 -0
  275. package/dist/utils/index.js.map +1 -1
  276. package/dist/utils/issue_bucketing.d.ts +36 -0
  277. package/dist/utils/issue_bucketing.d.ts.map +1 -0
  278. package/dist/utils/issue_bucketing.js +107 -0
  279. package/dist/utils/issue_bucketing.js.map +1 -0
  280. package/dist/utils/validation_result.d.ts +32 -0
  281. package/dist/utils/validation_result.d.ts.map +1 -0
  282. package/dist/utils/validation_result.js +55 -0
  283. package/dist/utils/validation_result.js.map +1 -0
  284. package/package.json +16 -4
@@ -0,0 +1,1385 @@
1
+ /**
2
+ * ThreadForm — main component.
3
+ * Conversational task-based form with agent/client views.
4
+ */
5
+ 'use client';
6
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
+ import { useState, useCallback, useRef, useMemo } from 'react';
8
+ import { cn } from './components/shared.js';
9
+ import { TaskCard } from './components/task_card.js';
10
+ import { AddQuestionDialog } from './components/add_question_dialog.js';
11
+ import { AgentComposeDialog, build_compose_from_check, build_compose_from_field } from './components/agent_compose_dialog.js';
12
+ import { CollectedDataView } from './components/collected_data_view.js';
13
+ import { ThreadPdfSidePanel } from './components/pdf_side_panel.js';
14
+ import { use_file_pipeline } from './hooks/use_file_pipeline.js';
15
+ import { extract_expectation } from '../../utils/expectation_extractor.js';
16
+ import { infer_mime_type } from '../hazo_fb_form/shared/format.js';
17
+ import { merge_send_back_message } from './utils/merge_send_back.js';
18
+ import { compute_file_decision_state } from './utils/file_decision_state.js';
19
+ /** Pick the best MIME type we can. Browsers can hand back empty string OR
20
+ * application/octet-stream for files dragged from email/Finder/etc., and the
21
+ * classification API (Gemini) rejects octet-stream outright. Sniff the
22
+ * extension as a fallback. */
23
+ function normalize_file_mime(file_type, file_name) {
24
+ if (file_type && file_type !== 'application/octet-stream')
25
+ return file_type;
26
+ return infer_mime_type(file_name);
27
+ }
28
+ export function ThreadForm({ data, role, current_user, on_form_change, on_task_update, on_message_add, on_message_edit, on_message_delete, on_send_form, on_close_form, on_export_data, on_request_analysis, on_request_reanalysis, file_manager, pdf_viewer, show_json_viewer, classification_api_url, validation_api_url, validation_rules_api_url, content_gate_api_url, resolution_api_url, response_extraction_api_url, available_document_types, available_tags, on_update_content_item, on_log, view_button_variant, show_info_icon, }) {
29
+ const [expanded_tasks, set_expanded_tasks] = useState(() => {
30
+ const first_open = data.tasks.find(t => t.status !== 'completed')?.task_id;
31
+ return new Set(first_open ? [first_open] : []);
32
+ });
33
+ const [active_tab, set_active_tab] = useState('tasks');
34
+ const [backoffice_running, set_backoffice_running] = useState(false);
35
+ /**
36
+ * Set of task_ids that currently have an aggregate resolver in-flight.
37
+ * Drives the spinner badge inside SendBackMessage's Resolution Status panel
38
+ * so the user sees that bullets may be stale while a recompute is running.
39
+ */
40
+ const [resolution_running, set_resolution_running] = useState(new Set());
41
+ const [show_json, set_show_json] = useState(false);
42
+ const [show_add_question, set_show_add_question] = useState(false);
43
+ const [compose_context, set_compose_context] = useState(null);
44
+ const [viewed_item, set_viewed_item] = useState(null);
45
+ // Keep a ref to data for callbacks that need the latest state
46
+ const data_ref = useRef(data);
47
+ data_ref.current = data;
48
+ const pipeline = use_file_pipeline({
49
+ classification_api_url,
50
+ validation_api_url,
51
+ validation_rules_api_url,
52
+ content_gate_api_url,
53
+ response_extraction_api_url,
54
+ document_types: available_document_types,
55
+ available_tags,
56
+ file_manager: file_manager?.callbacks,
57
+ on_log,
58
+ });
59
+ // ── Derived state ──
60
+ const pending_count = data.tasks.reduce((acc, task) => acc + task.thread.filter(m => m.type === 'ai_analysis' && m.ai_analysis?.items.some(i => i.review_status === 'pending')).length, 0);
61
+ const review_tasks = data.tasks.filter(task => task.thread.some(m => m.type === 'ai_analysis' && m.ai_analysis?.items.some(i => i.review_status === 'pending')));
62
+ // 1-based question numbers keyed on position in the full task list, so they
63
+ // remain stable across tab filters (tasks vs review).
64
+ const question_numbers = useMemo(() => new Map(data.tasks.map((t, i) => [t.task_id, i + 1])), [data.tasks]);
65
+ /**
66
+ * Set of source_content_ids whose spawned send-back task is now resolved
67
+ * (status === 'completed'). Used to suppress the parent file's "Sent Back"
68
+ * pill once the issue has been addressed in the child question.
69
+ */
70
+ const sent_back_resolved_ids = useMemo(() => {
71
+ const ids = new Set();
72
+ for (const t of data.tasks) {
73
+ if (t.status !== 'completed')
74
+ continue;
75
+ const first = t.thread[0];
76
+ if (first?.type === 'send_back' && first.send_back?.source_content_id) {
77
+ ids.add(first.send_back.source_content_id);
78
+ }
79
+ }
80
+ return ids;
81
+ }, [data.tasks]);
82
+ /**
83
+ * Resolve a spawned task's back-reference to the file+issue it came from.
84
+ * A spawned task's first message is a `send_back`; we look up the file's
85
+ * originating task in the current form data.
86
+ */
87
+ const get_parent_ref = useCallback((task) => {
88
+ const first = task.thread[0];
89
+ if (!first || first.type !== 'send_back' || !first.send_back)
90
+ return null;
91
+ const { source_content_id, source_file, issues } = first.send_back;
92
+ const parent_task = data.tasks.find(t => t.thread.some(m => m.content_items?.some(ci => ci.content_id === source_content_id)));
93
+ if (!parent_task)
94
+ return null;
95
+ const rule_names = Array.from(new Set(issues.map(i => i.rule_name).filter((n) => !!n)));
96
+ return {
97
+ parent_task_id: parent_task.task_id,
98
+ parent_question_number: question_numbers.get(parent_task.task_id) ?? 0,
99
+ file_name: source_file?.file_name,
100
+ rule_names,
101
+ };
102
+ }, [data.tasks, question_numbers]);
103
+ /** Expand the target task and scroll to it with a brief highlight. */
104
+ const handle_navigate_to_task = useCallback((task_id) => {
105
+ set_expanded_tasks(prev => {
106
+ if (prev.has(task_id))
107
+ return prev;
108
+ const next = new Set(prev);
109
+ next.add(task_id);
110
+ return next;
111
+ });
112
+ // Defer scroll so the expansion has rendered.
113
+ setTimeout(() => {
114
+ const el = document.getElementById(`task-${task_id}`);
115
+ if (!el)
116
+ return;
117
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
118
+ el.classList.add('ring-2', 'ring-amber-300');
119
+ setTimeout(() => el.classList.remove('ring-2', 'ring-amber-300'), 1500);
120
+ }, 50);
121
+ }, []);
122
+ // ── Helpers ──
123
+ /** Read a File as base64 (data portion only, no prefix). */
124
+ const file_to_base64 = useCallback((file) => {
125
+ return new Promise((resolve, reject) => {
126
+ const reader = new FileReader();
127
+ reader.onload = () => {
128
+ const result = reader.result;
129
+ // Strip "data:...;base64," prefix
130
+ resolve(result.split(',')[1] || '');
131
+ };
132
+ reader.onerror = reject;
133
+ reader.readAsDataURL(file);
134
+ });
135
+ }, []);
136
+ const format_file_size = useCallback((bytes) => {
137
+ if (bytes < 1024)
138
+ return `${bytes}B`;
139
+ if (bytes < 1048576)
140
+ return `${Math.round(bytes / 1024)}KB`;
141
+ return `${(bytes / 1048576).toFixed(1)}MB`;
142
+ }, []);
143
+ // ── Derived state for agent's backoffice action ──
144
+ const pending_backoffice_items = useMemo(() => {
145
+ const out = [];
146
+ for (const task of data.tasks) {
147
+ // Skip tasks the agent has already approved — files inside show "Resolved"
148
+ // and there's no need to re-validate them.
149
+ if (task.status === 'completed')
150
+ continue;
151
+ for (const msg of task.thread) {
152
+ for (const ci of msg.content_items || []) {
153
+ if (ci.type !== 'file')
154
+ continue;
155
+ if (ci.backoffice_validated_at)
156
+ continue;
157
+ if (ci.replaced_by_content_id)
158
+ continue; // skip superseded files
159
+ if (ci.accepted)
160
+ continue; // skip files the agent already accepted
161
+ out.push(ci);
162
+ }
163
+ }
164
+ }
165
+ return out;
166
+ }, [data.tasks]);
167
+ // ── Callbacks ──
168
+ /** Agent action: run backoffice validation on every file that hasn't had it yet. */
169
+ const handle_run_backoffice = useCallback(async () => {
170
+ if (backoffice_running || pending_backoffice_items.length === 0)
171
+ return;
172
+ set_backoffice_running(true);
173
+ try {
174
+ const results = await pipeline.run_backoffice_validation(pending_backoffice_items);
175
+ const now = new Date().toISOString();
176
+ let processed = 0;
177
+ for (const item of pending_backoffice_items) {
178
+ const r = results.get(item.content_id);
179
+ // Only stamp backoffice_validated_at when the pipeline actually returned
180
+ // results for this item — otherwise we'd silently mark failed/skipped
181
+ // items as done and the agent couldn't retry.
182
+ if (!r)
183
+ continue;
184
+ const prev = (item.validation_rule_results ?? []);
185
+ const new_backoffice = r.validation_results ?? [];
186
+ const new_rule_ids = new Set(new_backoffice.map(n => n.rule_id));
187
+ const kept = prev.filter(p => !(p.check_type === 'backoffice' && new_rule_ids.has(p.rule_id)));
188
+ on_update_content_item?.(item.content_id, {
189
+ validation_rule_results: [...kept, ...new_backoffice],
190
+ backoffice_validated_at: now,
191
+ });
192
+ processed++;
193
+ }
194
+ const skipped = pending_backoffice_items.length - processed;
195
+ on_log?.(`${current_user.name} (${current_user.id}): Ran backoffice validation on ${processed} file(s)${skipped > 0 ? ` (${skipped} skipped)` : ''}`);
196
+ // Per-group periodic_coverage pass — runs after per-file backoffice so each
197
+ // file's extracted_data is available. Run scoped to each task so the
198
+ // resulting CoverageCard renders inside its owning task.
199
+ // FIXME(periodic-coverage): session vars hardcoded to AU FY 2024-25 to match
200
+ // the test-app's rental statement fixtures. Consumers should source these
201
+ // from config, form fields, or a tax-year selector before production use.
202
+ const coverage_ctx = {
203
+ form_data: null,
204
+ session: {
205
+ Beginning_Of_Tax_Year: '2024-07-01',
206
+ End_Of_Tax_Year: '2025-06-30',
207
+ },
208
+ };
209
+ let any_coverage_results = false;
210
+ for (const task of data_ref.current.tasks) {
211
+ const task_file_items = task.thread
212
+ .flatMap(m => m.content_items ?? [])
213
+ .filter(i => i.type === 'file');
214
+ if (task_file_items.length === 0)
215
+ continue;
216
+ const cov = await pipeline.run_periodic_coverage_pass(task_file_items, coverage_ctx);
217
+ // Persist on the task (overwrites any prior results — re-running
218
+ // backoffice fully recomputes coverage for that task).
219
+ on_task_update?.({ ...task, coverage_results: cov });
220
+ if (cov.length === 0)
221
+ continue;
222
+ any_coverage_results = true;
223
+ for (const c of cov) {
224
+ if (c.skipped) {
225
+ on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage [${c.rule_name}] on group "${c.group_key}" — skipped (period bounds did not resolve)`);
226
+ }
227
+ else if (c.gaps.length === 0) {
228
+ on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage [${c.rule_name}] on group "${c.group_key}" — full coverage, no gaps`);
229
+ }
230
+ else {
231
+ const gap_summary = c.gaps.map(g => `${g.start}…${g.end}`).join(', ');
232
+ on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage [${c.rule_name}] on group "${c.group_key}" — ${c.gaps.length} gap(s): ${gap_summary}`);
233
+ }
234
+ }
235
+ }
236
+ if (!any_coverage_results) {
237
+ on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage check — no periodic_coverage rules matched any group's document_type`);
238
+ }
239
+ }
240
+ catch (err) {
241
+ console.error('[thread-form] Backoffice validation failed:', err);
242
+ }
243
+ finally {
244
+ set_backoffice_running(false);
245
+ }
246
+ }, [backoffice_running, pending_backoffice_items, pipeline, on_update_content_item, on_log, current_user]);
247
+ /**
248
+ * Run the aggregate resolver (sum_match) for a clarification task.
249
+ * Walks every client-uploaded file in the task, gathers extracted_fields
250
+ * + classification, and posts to the resolution API. Emits one
251
+ * resolution_verdict message + per-file doc_check messages, and updates
252
+ * task status to completed/needs_attention based on the verdict.
253
+ *
254
+ * Called from:
255
+ * - handle_send (after a file-bearing send) — passes fresh classifications
256
+ * from the just-uploaded files via fresh_cls_map + the b64 cache.
257
+ * - handle_replacement_files (after a Replace Document upload finishes).
258
+ * - handle_remove_file (after the user deletes a file from the task).
259
+ *
260
+ * Pass `data_snapshot` when the caller has a fresher view of the data
261
+ * than data_ref.current (e.g. immediately after on_form_change).
262
+ */
263
+ const run_resolution = useCallback(async (task_id, opts = {}) => {
264
+ if (!resolution_api_url)
265
+ return;
266
+ const source = opts.data_snapshot ?? data_ref.current;
267
+ const parent_task = source.tasks.find(t => t.task_id === task_id);
268
+ const send_back_msg = parent_task?.thread.find(m => m.type === 'send_back' && m.send_back);
269
+ const issues_with_amount = send_back_msg?.send_back?.issues ?? [];
270
+ const sb_issue = issues_with_amount.find(i => i.amount) ??
271
+ issues_with_amount.find(i => /\$\s*\d+(?:[.,]\d+)?/.test(i.issue_description));
272
+ if (!sb_issue)
273
+ return;
274
+ const text_amount = sb_issue.issue_description.match(/\$\s*\d+(?:[.,]\d+)?/);
275
+ const text_date = sb_issue.issue_description.match(/\b\d{1,2}\s+[A-Za-z]+\s+\d{4}\s+(?:-|–|—|to)\s+\d{1,2}\s+[A-Za-z]+\s+\d{4}\b|\b\d{4}-\d{2}-\d{2}\s+(?:-|–|—|to)\s+\d{4}-\d{2}-\d{2}\b/);
276
+ const issue_for_extract = {
277
+ issue_id: sb_issue.issue_id ?? sb_issue.rule_id,
278
+ issue_description: sb_issue.issue_description,
279
+ amount: sb_issue.amount ?? text_amount?.[0],
280
+ date: sb_issue.date ?? text_date?.[0],
281
+ };
282
+ const expectation = extract_expectation(issue_for_extract, { default_currency: 'AUD' });
283
+ if (!expectation)
284
+ return;
285
+ // Walk the task thread and gather every client-uploaded file. Gate on
286
+ // having at least one file — running with zero files would always fail
287
+ // and isn't useful (the panel will keep its prior verdict, which gets
288
+ // cleared by the bookkeeping below if the task is now empty of files).
289
+ const messages = parent_task?.thread ?? [];
290
+ const send_back_idx = messages.findIndex(m => m.type === 'send_back');
291
+ const seen_file_ids = new Set();
292
+ const aggregated = [];
293
+ for (let i = 0; i < messages.length; i++) {
294
+ if (i <= send_back_idx)
295
+ continue;
296
+ const m = messages[i];
297
+ if (m.type !== 'content')
298
+ continue;
299
+ for (const ci of m.content_items ?? []) {
300
+ if (ci.type !== 'file' || !ci.file?.file_id)
301
+ continue;
302
+ if (seen_file_ids.has(ci.file.file_id))
303
+ continue;
304
+ seen_file_ids.add(ci.file.file_id);
305
+ const fresh = opts.fresh_cls_map?.get(ci.content_id);
306
+ const cls = fresh ?? ci.classification_result;
307
+ const ef = cls?.extracted_fields;
308
+ aggregated.push({
309
+ content_id: ci.content_id,
310
+ file_id: ci.file.file_id,
311
+ file_name: ci.file.file_name,
312
+ mime_type: ci.file.mime_type,
313
+ ...(cls?.document_type ? { document_type: cls.document_type } : {}),
314
+ ...(cls?.document_nature ? { document_nature: cls.document_nature } : {}),
315
+ ...(ef ? { extracted_fields: ef } : {}),
316
+ });
317
+ }
318
+ }
319
+ if (aggregated.length === 0)
320
+ return;
321
+ on_log?.(`Aggregate resolver: running sum_match for "${sb_issue.issue_description}" (${aggregated.length} file${aggregated.length === 1 ? '' : 's'})`);
322
+ set_resolution_running(prev => {
323
+ const next = new Set(prev);
324
+ next.add(task_id);
325
+ return next;
326
+ });
327
+ try {
328
+ // Resolve base64 for each file. Current-send files are in the
329
+ // caller-supplied b64 cache (handle_send); prior-send files come
330
+ // from file_manager.get_download_url + fetch.
331
+ const fm = file_manager?.callbacks;
332
+ const file_b64_resolved = new Map();
333
+ await Promise.all(aggregated.map(async (f) => {
334
+ const cached = opts.current_send_b64_by_content_id?.get(f.content_id);
335
+ if (cached) {
336
+ file_b64_resolved.set(f.file_id, cached);
337
+ return;
338
+ }
339
+ if (!fm?.get_download_url)
340
+ return;
341
+ try {
342
+ const url = fm.get_download_url(f.file_id, 'public');
343
+ if (!url)
344
+ return;
345
+ const blob_res = await fetch(url);
346
+ if (!blob_res.ok)
347
+ return;
348
+ const buf = await blob_res.arrayBuffer();
349
+ const bytes = new Uint8Array(buf);
350
+ let binary = '';
351
+ const chunk = 0x8000;
352
+ for (let i = 0; i < bytes.length; i += chunk) {
353
+ binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
354
+ }
355
+ file_b64_resolved.set(f.file_id, btoa(binary));
356
+ }
357
+ catch (err) {
358
+ console.warn('[ThreadForm] Resolver: failed to fetch base64 for', f.file_name, err);
359
+ }
360
+ }));
361
+ on_log?.(`Aggregate resolver: ${file_b64_resolved.size}/${aggregated.length} file(s) have file_b64 — sending to LLM`);
362
+ const response_files = aggregated.map(f => ({
363
+ file_id: f.file_id,
364
+ file_name: f.file_name,
365
+ ...(f.mime_type ? { mime_type: f.mime_type } : {}),
366
+ ...(f.document_type ? { document_type: f.document_type } : {}),
367
+ ...(f.document_nature ? { document_nature: f.document_nature } : {}),
368
+ ...(f.extracted_fields ? { extracted_fields: f.extracted_fields } : {}),
369
+ ...(file_b64_resolved.has(f.file_id) ? { file_b64: file_b64_resolved.get(f.file_id) } : {}),
370
+ }));
371
+ const res = await fetch(resolution_api_url, {
372
+ method: 'POST',
373
+ headers: { 'Content-Type': 'application/json' },
374
+ body: JSON.stringify({ expectation, response_files }),
375
+ });
376
+ const data_resp = await res.json();
377
+ if (!res.ok || data_resp?.success === false) {
378
+ on_log?.(`Aggregate resolver: API error — skipping verdict`);
379
+ return;
380
+ }
381
+ const check = data_resp.check;
382
+ if (!check)
383
+ return;
384
+ on_log?.(`Aggregate resolver: ${check.passed ? 'passed' : 'failed'} — ${check.summary}`);
385
+ on_message_add?.(task_id, {
386
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
387
+ role: 'system',
388
+ type: 'resolution_verdict',
389
+ visibility: 'all',
390
+ resolution_verdict: check,
391
+ time: new Date().toISOString(),
392
+ });
393
+ const file_rel = check.file_relevance ?? {};
394
+ for (const f of aggregated) {
395
+ const r = file_rel[f.file_id];
396
+ if (!r)
397
+ continue;
398
+ const status = r.verdict === 'matches' ? 'passed' : 'issues';
399
+ on_message_add?.(task_id, {
400
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
401
+ role: 'system',
402
+ type: 'doc_check',
403
+ visibility: 'all',
404
+ doc_check: {
405
+ content_ref: f.content_id,
406
+ source_type: 'file',
407
+ status,
408
+ summary: r.reason,
409
+ relevance: r.verdict,
410
+ relevance_reason: r.reason,
411
+ },
412
+ time: new Date().toISOString(),
413
+ });
414
+ }
415
+ // Re-read the task from data_ref so on_task_update receives the
416
+ // most recent thread (verdict messages we just emitted may not yet
417
+ // be reflected here, which is fine — task_update only mutates status).
418
+ const latest_task = data_ref.current.tasks.find(t => t.task_id === task_id);
419
+ if (latest_task) {
420
+ const next_status = check.passed ? 'completed' : 'needs_attention';
421
+ if (latest_task.status !== next_status) {
422
+ on_task_update?.({
423
+ ...latest_task,
424
+ status: next_status,
425
+ completed_at: check.passed ? new Date().toISOString() : undefined,
426
+ });
427
+ }
428
+ }
429
+ }
430
+ catch (err) {
431
+ console.warn('[ThreadForm] resolution call failed', err);
432
+ }
433
+ finally {
434
+ set_resolution_running(prev => {
435
+ if (!prev.has(task_id))
436
+ return prev;
437
+ const next = new Set(prev);
438
+ next.delete(task_id);
439
+ return next;
440
+ });
441
+ }
442
+ }, [resolution_api_url, file_manager, on_message_add, on_task_update, on_log]);
443
+ /** Combined send: text + files. Uploads files via file_manager, then triggers classification pipeline. */
444
+ const handle_send = useCallback(async (task_id, text, files) => {
445
+ const has_text = text.trim().length > 0;
446
+ const has_files = files.length > 0;
447
+ if (!has_text && !has_files)
448
+ return;
449
+ // Upload files via file_manager if available, otherwise create local-only references
450
+ const content_items = [];
451
+ const file_b64_map = new Map(); // content_id → base64
452
+ for (const file of files) {
453
+ const content_id = `ct_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
454
+ let file_id;
455
+ let file_size_str;
456
+ if (file_manager?.callbacks?.upload) {
457
+ try {
458
+ const attachment = await file_manager.callbacks.upload(file, {
459
+ entity_id: task_id,
460
+ entity_type: 'thread_task',
461
+ visibility: 'public',
462
+ uploaded_by: current_user.id,
463
+ });
464
+ file_id = attachment.file_id;
465
+ file_size_str = format_file_size(attachment.file_size);
466
+ }
467
+ catch (err) {
468
+ console.error('[ThreadForm] File upload failed:', err);
469
+ // Fall back to local ID
470
+ file_id = `f_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
471
+ file_size_str = format_file_size(file.size);
472
+ }
473
+ }
474
+ else {
475
+ file_id = `f_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
476
+ file_size_str = format_file_size(file.size);
477
+ }
478
+ content_items.push({
479
+ content_id,
480
+ type: 'file',
481
+ file: {
482
+ file_id,
483
+ file_name: file.name,
484
+ file_size: file_size_str,
485
+ mime_type: normalize_file_mime(file.type, file.name),
486
+ },
487
+ });
488
+ // Read base64 for pipeline (classification + validation)
489
+ if (classification_api_url) {
490
+ try {
491
+ const b64 = await file_to_base64(file);
492
+ file_b64_map.set(content_id, b64);
493
+ }
494
+ catch {
495
+ console.error('[ThreadForm] Failed to read file as base64:', file.name);
496
+ }
497
+ }
498
+ }
499
+ const msg = {
500
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
501
+ role: role,
502
+ type: has_files ? 'content' : 'comment',
503
+ visibility: 'all',
504
+ content_text: has_text ? text.trim() : undefined,
505
+ content_items: has_files ? content_items : undefined,
506
+ user_name: current_user.name,
507
+ user_id: current_user.id,
508
+ time: new Date().toISOString(),
509
+ };
510
+ on_message_add?.(task_id, msg);
511
+ // Build a post-add snapshot so the resolver definitely sees the just-uploaded
512
+ // files, even if React hasn't re-rendered the parent (and refreshed data_ref)
513
+ // by the time the pipeline finishes. Earlier this caused a race where new
514
+ // files were missing from the resolver's file list — verdict reflected only
515
+ // the prior batch and the new total looked off (e.g. $137.50 of $437.47 when
516
+ // the new file should have brought it to $437.47 exactly).
517
+ const post_add_snapshot = {
518
+ ...data_ref.current,
519
+ updated_at: msg.time,
520
+ tasks: data_ref.current.tasks.map(t => t.task_id === task_id ? { ...t, thread: [...t.thread, msg] } : t),
521
+ };
522
+ // Trigger classification + validation pipeline for each file (async, non-blocking).
523
+ // Capture each promise so the aggregate resolver below can await them and
524
+ // read the classification result directly — closure-captured pipeline.classification_results
525
+ // is stale by the time it runs.
526
+ const pipeline_promises = [];
527
+ if (classification_api_url) {
528
+ on_log?.(`Upload path (compose): ${content_items.length} file(s) → triggering pipeline`);
529
+ for (const item of content_items) {
530
+ const b64 = file_b64_map.get(item.content_id);
531
+ if (b64 && item.type === 'file') {
532
+ const p = pipeline.process_file(item, b64).then(result => {
533
+ // Inject doc_check and ai_analysis messages into the thread
534
+ if (result.doc_check) {
535
+ on_message_add?.(task_id, {
536
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
537
+ role: 'system', type: 'doc_check', visibility: 'all',
538
+ doc_check: result.doc_check, time: new Date().toISOString(),
539
+ });
540
+ }
541
+ if (result.ai_result) {
542
+ on_message_add?.(task_id, {
543
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
544
+ role: 'system', type: 'ai_analysis', visibility: 'agent_only',
545
+ ai_analysis: { items: [result.ai_result] }, time: new Date().toISOString(),
546
+ });
547
+ }
548
+ // Persist classification + validation results on the ContentItem.
549
+ // Keep the full ValidationRuleResult shape (issues, check_type, …)
550
+ // so UI-side normalizers work the same after a refresh.
551
+ if (result.classification || result.validation_results.length > 0) {
552
+ setTimeout(() => {
553
+ handle_update_content_item(item.content_id, {
554
+ classification_result: result.classification || undefined,
555
+ validation_rule_results: result.validation_results,
556
+ });
557
+ }, 100);
558
+ }
559
+ return { content_id: item.content_id, classification: result.classification };
560
+ }).catch(err => {
561
+ console.error('[ThreadForm] Pipeline error for', item.file?.file_name, err);
562
+ return { content_id: item.content_id, classification: null };
563
+ });
564
+ pipeline_promises.push(p);
565
+ }
566
+ }
567
+ }
568
+ // ── Aggregate resolver (sum_match) ──
569
+ // Fire after pipeline_promises resolve so the just-uploaded files'
570
+ // classifications are available for the resolver. Prior-send files
571
+ // are read from data_ref via run_resolution.
572
+ if (has_files) {
573
+ (async () => {
574
+ const fresh_results = await Promise.all(pipeline_promises);
575
+ const fresh_cls_map = new Map();
576
+ for (const r of fresh_results)
577
+ fresh_cls_map.set(r.content_id, r.classification);
578
+ await run_resolution(task_id, {
579
+ fresh_cls_map,
580
+ current_send_b64_by_content_id: file_b64_map,
581
+ data_snapshot: post_add_snapshot,
582
+ });
583
+ })();
584
+ }
585
+ // Trigger text validation pipeline for text-only messages (async, non-blocking).
586
+ // Run the content gate first so conversational replies never trigger validation
587
+ // and never produce a client-visible "N issues found" banner. Client-authored
588
+ // doc_checks go out as agent_only so any AI language stays on the agent's side.
589
+ if (validation_api_url && has_text && !has_files && text.trim().length > 20) {
590
+ pipeline.gate_text_content(text.trim()).then(gate => {
591
+ on_log?.(`Content gate (${msg.role}): has_content=${gate.has_content}${gate.reason ? ` — ${gate.reason}` : ''}`);
592
+ if (!gate.has_content)
593
+ return; // Skip — nothing worth validating
594
+ pipeline.process_text(msg.message_id, text.trim()).then(result => {
595
+ if (result.doc_check) {
596
+ on_message_add?.(task_id, {
597
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
598
+ role: 'system', type: 'doc_check',
599
+ visibility: msg.role === 'client' ? 'agent_only' : 'all',
600
+ doc_check: result.doc_check, time: new Date().toISOString(),
601
+ });
602
+ }
603
+ }).catch(err => {
604
+ console.error('[ThreadForm] Text pipeline error:', err);
605
+ });
606
+ }).catch(err => {
607
+ console.error('[ThreadForm] Content gate error:', err);
608
+ });
609
+ }
610
+ }, [role, current_user, on_message_add, file_manager, classification_api_url, validation_api_url, resolution_api_url, pipeline, file_to_base64, format_file_size, on_log]);
611
+ const handle_send_comment = useCallback((task_id, text) => {
612
+ handle_send(task_id, text, []);
613
+ }, [handle_send]);
614
+ const handle_toggle_complete = useCallback((task_id) => {
615
+ const task = data.tasks.find(t => t.task_id === task_id);
616
+ if (!task)
617
+ return;
618
+ const was_completed = task.status === 'completed';
619
+ const updated = {
620
+ ...task,
621
+ status: was_completed ? 'in_progress' : 'completed',
622
+ completed_by: was_completed ? undefined : current_user.id,
623
+ completed_at: was_completed ? undefined : new Date().toISOString(),
624
+ };
625
+ // Add status change message
626
+ const status_msg = {
627
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
628
+ role: 'system',
629
+ type: 'status_change',
630
+ visibility: 'all',
631
+ status_change: { from: task.status, to: updated.status },
632
+ user_name: current_user.name,
633
+ user_id: current_user.id,
634
+ time: new Date().toISOString(),
635
+ };
636
+ const with_msg = {
637
+ ...updated,
638
+ thread: [...updated.thread, status_msg],
639
+ };
640
+ on_task_update?.(with_msg);
641
+ on_log?.(`${current_user.name} (${current_user.id}): Task "${task.title}" → ${updated.status}`);
642
+ }, [data.tasks, current_user, on_task_update, on_log]);
643
+ const handle_add_task = useCallback((task) => {
644
+ // Fire form change with new task added
645
+ const updated = {
646
+ ...data,
647
+ updated_at: new Date().toISOString(),
648
+ tasks: [...data.tasks, task],
649
+ };
650
+ on_form_change?.(updated);
651
+ }, [data, on_form_change]);
652
+ const handle_view_file = useCallback((item) => {
653
+ set_viewed_item(item);
654
+ }, []);
655
+ const handle_close_pdf = useCallback(() => {
656
+ set_viewed_item(null);
657
+ }, []);
658
+ const viewed_file_url = viewed_item?.file
659
+ ? file_manager?.callbacks?.get_download_url?.(viewed_item.file.file_id, 'public') || null
660
+ : null;
661
+ /** Upload a single file via file_manager, return a ContentItem. Used by MessageEditor. */
662
+ const handle_upload_file = useCallback(async (file) => {
663
+ const content_id = `ct_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
664
+ let file_id;
665
+ let file_size_str;
666
+ if (file_manager?.callbacks?.upload) {
667
+ const attachment = await file_manager.callbacks.upload(file, {
668
+ entity_id: 'thread_edit',
669
+ entity_type: 'thread_task',
670
+ visibility: 'public',
671
+ uploaded_by: current_user.id,
672
+ });
673
+ file_id = attachment.file_id;
674
+ file_size_str = format_file_size(attachment.file_size);
675
+ }
676
+ else {
677
+ file_id = `f_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
678
+ file_size_str = format_file_size(file.size);
679
+ }
680
+ const item = {
681
+ content_id,
682
+ type: 'file',
683
+ file: {
684
+ file_id,
685
+ file_name: file.name,
686
+ file_size: file_size_str,
687
+ mime_type: file.type || 'application/octet-stream',
688
+ },
689
+ };
690
+ // Trigger pipeline for this file (fire-and-forget)
691
+ if (classification_api_url) {
692
+ on_log?.(`Upload path (edit): ${file.name} → triggering pipeline`);
693
+ file_to_base64(file).then(b64 => {
694
+ pipeline.process_file(item, b64).then(result => {
695
+ // Find the task containing this item and inject messages
696
+ for (const task of data.tasks) {
697
+ const has_item = task.thread.some(m => m.content_items?.some(ci => ci.content_id === content_id));
698
+ if (!has_item)
699
+ continue;
700
+ if (result.doc_check) {
701
+ on_message_add?.(task.task_id, {
702
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
703
+ role: 'system', type: 'doc_check', visibility: 'all',
704
+ doc_check: result.doc_check, time: new Date().toISOString(),
705
+ });
706
+ }
707
+ if (result.ai_result) {
708
+ on_message_add?.(task.task_id, {
709
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
710
+ role: 'system', type: 'ai_analysis', visibility: 'agent_only',
711
+ ai_analysis: { items: [result.ai_result] }, time: new Date().toISOString(),
712
+ });
713
+ }
714
+ break;
715
+ }
716
+ // Persist classification + validation on the item so results survive refresh.
717
+ if (result.classification || result.validation_results.length > 0) {
718
+ setTimeout(() => {
719
+ on_update_content_item?.(content_id, {
720
+ classification_result: result.classification || undefined,
721
+ validation_rule_results: result.validation_results,
722
+ });
723
+ }, 100);
724
+ }
725
+ }).catch(err => console.error('[ThreadForm] Pipeline error:', err));
726
+ }).catch(err => console.error('[ThreadForm] Base64 read error:', err));
727
+ }
728
+ return item;
729
+ }, [file_manager, current_user, classification_api_url, pipeline, data.tasks, on_message_add, file_to_base64, format_file_size]);
730
+ /** Remove a file from its parent message's content_items */
731
+ const handle_remove_file = useCallback((item) => {
732
+ // Close PDF panel if we're removing the viewed file
733
+ if (viewed_item?.content_id === item.content_id) {
734
+ set_viewed_item(null);
735
+ }
736
+ // Remove from file_manager if available
737
+ if (item.file && file_manager?.callbacks?.remove) {
738
+ file_manager.callbacks.remove(item.file.file_id, `ref_${item.file.file_id}`).catch(err => {
739
+ console.error('[ThreadForm] Failed to remove file:', err);
740
+ });
741
+ }
742
+ // Track which task held the removed file so we can re-run the resolver
743
+ // for that task once the data update propagates.
744
+ let owning_task_id;
745
+ // Remove the content item from the message
746
+ const updated = {
747
+ ...data,
748
+ updated_at: new Date().toISOString(),
749
+ tasks: data.tasks.map(task => ({
750
+ ...task,
751
+ thread: task.thread.map(msg => {
752
+ if (!msg.content_items)
753
+ return msg;
754
+ const filtered = msg.content_items.filter(ci => ci.content_id !== item.content_id);
755
+ if (filtered.length === msg.content_items.length)
756
+ return msg; // not in this message
757
+ owning_task_id = task.task_id;
758
+ return {
759
+ ...msg,
760
+ content_items: filtered.length > 0 ? filtered : undefined,
761
+ // If no files left and no text, keep as comment type
762
+ type: filtered.length === 0 && msg.content_text ? 'comment' : msg.type,
763
+ };
764
+ }),
765
+ })),
766
+ };
767
+ on_form_change?.(updated);
768
+ // Re-run the resolver for the owning clarification task so the panel
769
+ // reflects the new file set. Pass the post-deletion snapshot directly
770
+ // because data_ref hasn't been refreshed by the parent re-render yet.
771
+ if (owning_task_id) {
772
+ run_resolution(owning_task_id, { data_snapshot: updated });
773
+ }
774
+ }, [data, on_form_change, file_manager, viewed_item, run_resolution]);
775
+ const handle_accept = useCallback((content_ref) => {
776
+ // Update review_status in the data
777
+ const updated = {
778
+ ...data,
779
+ updated_at: new Date().toISOString(),
780
+ tasks: data.tasks.map(task => ({
781
+ ...task,
782
+ thread: task.thread.map(msg => {
783
+ if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
784
+ return msg;
785
+ return {
786
+ ...msg,
787
+ ai_analysis: {
788
+ ...msg.ai_analysis,
789
+ items: msg.ai_analysis.items.map(item => item.content_ref === content_ref ? { ...item, review_status: 'accepted' } : item),
790
+ },
791
+ };
792
+ }),
793
+ })),
794
+ };
795
+ on_form_change?.(updated);
796
+ on_log?.(`${current_user.name} (${current_user.id}): Accepted AI analysis for ${content_ref.slice(-5)}`);
797
+ }, [data, on_form_change, current_user, on_log]);
798
+ const handle_dismiss = useCallback((content_ref) => {
799
+ const updated = {
800
+ ...data,
801
+ updated_at: new Date().toISOString(),
802
+ tasks: data.tasks.map(task => ({
803
+ ...task,
804
+ thread: task.thread.map(msg => {
805
+ if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
806
+ return msg;
807
+ return {
808
+ ...msg,
809
+ ai_analysis: {
810
+ ...msg.ai_analysis,
811
+ items: msg.ai_analysis.items.map(item => item.content_ref === content_ref ? { ...item, review_status: 'dismissed' } : item),
812
+ },
813
+ };
814
+ }),
815
+ })),
816
+ };
817
+ on_form_change?.(updated);
818
+ on_log?.(`${current_user.name} (${current_user.id}): Dismissed AI analysis for ${content_ref.slice(-5)}`);
819
+ }, [data, on_form_change, current_user, on_log]);
820
+ const handle_send_to_client = useCallback((content_ref) => {
821
+ // Find the AI result to build compose context
822
+ for (const task of data.tasks) {
823
+ for (const msg of task.thread) {
824
+ if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
825
+ continue;
826
+ for (const item of msg.ai_analysis.items) {
827
+ if (item.content_ref !== content_ref)
828
+ continue;
829
+ // Build context from the first failing check or field issue
830
+ const failing_check = item.validation.checks.find(c => c.status !== 'passed');
831
+ if (failing_check) {
832
+ const file_name = task.thread
833
+ .flatMap(m => m.content_items || [])
834
+ .find(ci => ci.content_id === content_ref)?.file?.file_name || content_ref;
835
+ set_compose_context(build_compose_from_check(file_name, failing_check, content_ref, data.client.name));
836
+ return;
837
+ }
838
+ // Or from extracted data issue
839
+ for (const group of item.extracted_data || []) {
840
+ const field_with_issue = group.fields.find(f => f.issue);
841
+ if (field_with_issue) {
842
+ set_compose_context(build_compose_from_field(field_with_issue, group.category, content_ref, data.client.name));
843
+ return;
844
+ }
845
+ }
846
+ }
847
+ }
848
+ }
849
+ }, [data]);
850
+ const handle_compose_send = useCallback((message, content_ref) => {
851
+ // Find which task contains this content_ref and add an agent comment
852
+ for (const task of data.tasks) {
853
+ const has_ref = task.thread.some(m => m.ai_analysis?.items.some(i => i.content_ref === content_ref));
854
+ if (!has_ref)
855
+ continue;
856
+ const comment_msg = {
857
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
858
+ role: 'agent',
859
+ type: 'comment',
860
+ visibility: 'all',
861
+ content_text: message,
862
+ user_name: current_user.name,
863
+ user_id: current_user.id,
864
+ time: new Date().toISOString(),
865
+ };
866
+ on_message_add?.(task.task_id, comment_msg);
867
+ // Mark as sent_to_client
868
+ handle_accept(content_ref); // Reuse — sets review_status but we want 'sent_to_client'
869
+ const updated = {
870
+ ...data,
871
+ tasks: data.tasks.map(t => ({
872
+ ...t,
873
+ thread: t.thread.map(msg => {
874
+ if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
875
+ return msg;
876
+ return {
877
+ ...msg,
878
+ ai_analysis: {
879
+ ...msg.ai_analysis,
880
+ items: msg.ai_analysis.items.map(item => item.content_ref === content_ref
881
+ ? { ...item, review_status: 'sent_to_client', sent_message_id: comment_msg.message_id }
882
+ : item),
883
+ },
884
+ };
885
+ }),
886
+ })),
887
+ };
888
+ on_form_change?.(updated);
889
+ break;
890
+ }
891
+ set_compose_context(null);
892
+ }, [data, current_user, on_message_add, on_form_change]);
893
+ const handle_add_question = useCallback((_task_id) => {
894
+ set_show_add_question(true);
895
+ }, []);
896
+ /** Agent sends back selected issues as a new task (Approach F) */
897
+ const handle_send_back_issues = useCallback((source_item, issues, accepted_issue_keys) => {
898
+ // Accept-all path — issues=[] and accepted_issue_keys has all clarification IDs.
899
+ // Mark the source ContentItem as accepted so the Pending Review pill flips to
900
+ // "Accepted" and no new task is created.
901
+ if (issues.length === 0) {
902
+ if (accepted_issue_keys.length > 0) {
903
+ on_update_content_item?.(source_item.content_id, { accepted: true });
904
+ const file_name = source_item.file?.file_name || 'Document';
905
+ on_log?.(`${current_user.name} (${current_user.id}): Accepted client response for ${file_name}`);
906
+ }
907
+ return;
908
+ }
909
+ const make_id = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
910
+ const file_name = source_item.file?.file_name || 'Document';
911
+ // Find the parent task that originally contained this file — its title becomes
912
+ // the "original question" in the send-back title, and its task_id becomes the
913
+ // merge key for routing this Send Back into an existing open clarifications
914
+ // task (Y1 grouping — see docs/superpowers/specs/2026-04-30-send-back-merging-design.md).
915
+ const parent_task = data_ref.current.tasks.find(t => t.thread.some(m => m.content_items?.some(ci => ci.content_id === source_item.content_id)));
916
+ const parent_title = parent_task?.title || file_name;
917
+ const parent_task_id = parent_task?.task_id;
918
+ const send_back_data = {
919
+ source_content_id: source_item.content_id,
920
+ source_file: source_item.file,
921
+ issues,
922
+ history: [],
923
+ };
924
+ const send_back_msg = {
925
+ message_id: make_id('msg'),
926
+ role: 'agent',
927
+ type: 'send_back',
928
+ visibility: 'all',
929
+ send_back: send_back_data,
930
+ user_name: current_user.name,
931
+ user_id: current_user.id,
932
+ time: new Date().toISOString(),
933
+ };
934
+ // If we know the parent task, route through the merge helper so that
935
+ // subsequent Send Backs from the same parent land in one open
936
+ // clarifications task. Without a parent (legacy/orphan path), fall back to
937
+ // creating a new task as before.
938
+ let next_tasks;
939
+ if (parent_task_id) {
940
+ const build_new_task = (msg) => ({
941
+ task_id: make_id('task'),
942
+ title: `Clarifications regarding ${parent_title}`,
943
+ description: '',
944
+ status: 'needs_attention',
945
+ sort_order: data.tasks.length + 1,
946
+ created_at: new Date().toISOString(),
947
+ created_by: current_user.id,
948
+ thread: [msg],
949
+ parent_task_id,
950
+ });
951
+ next_tasks = merge_send_back_message(data.tasks, parent_task_id, send_back_msg, build_new_task);
952
+ }
953
+ else {
954
+ const new_task = {
955
+ task_id: make_id('task'),
956
+ title: `Clarifications regarding ${parent_title}`,
957
+ description: '',
958
+ status: 'needs_attention',
959
+ sort_order: data.tasks.length + 1,
960
+ created_at: new Date().toISOString(),
961
+ created_by: current_user.id,
962
+ thread: [send_back_msg],
963
+ };
964
+ next_tasks = [...data.tasks, new_task];
965
+ }
966
+ const updated = {
967
+ ...data,
968
+ updated_at: new Date().toISOString(),
969
+ tasks: next_tasks,
970
+ };
971
+ on_form_change?.(updated);
972
+ // Mark the source ContentItem as sent_back for pill display
973
+ setTimeout(() => {
974
+ on_update_content_item?.(source_item.content_id, { sent_back: true });
975
+ }, 50);
976
+ const total_child_count = issues.reduce((sum, iss) => sum + (iss.child_issues?.length ?? 1), 0);
977
+ const has_groups = issues.some(iss => iss.child_issues && iss.child_issues.length > 0);
978
+ const log_count_str = has_groups
979
+ ? `${issues.length} group(s) covering ${total_child_count} issue(s)`
980
+ : `${issues.length} issue(s)`;
981
+ on_log?.(`${current_user.name} (${current_user.id}): Sent back ${log_count_str} for ${file_name}`);
982
+ }, [data, current_user, on_form_change, on_update_content_item, on_log]);
983
+ /** Agent submits a batch of per-rule decisions from the Pending Review panel.
984
+ * - 'accepted' rules → stamp rule_actions[rule_id] = 'accepted'
985
+ * - 'sent_back' rules → build one send_back message per rule and route
986
+ * through merge_send_back_message so they all land in one open
987
+ * clarifications task for the parent question (Y1 grouping).
988
+ * Both side effects (form_change + content_item update) run in the same
989
+ * synchronous turn so React 18 auto-batches them into a single render. */
990
+ const handle_submit_decisions = useCallback((source_item, decisions) => {
991
+ if (decisions.length === 0)
992
+ return;
993
+ const make_id = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
994
+ const file_name = source_item.file?.file_name || 'Document';
995
+ const parent_task = data_ref.current.tasks.find(t => t.thread.some(m => m.content_items?.some(ci => ci.content_id === source_item.content_id)));
996
+ const parent_title = parent_task?.title || file_name;
997
+ const parent_task_id = parent_task?.task_id;
998
+ let next_tasks = data.tasks;
999
+ let send_back_index = 0;
1000
+ for (const d of decisions) {
1001
+ if (d.action !== 'sent_back')
1002
+ continue;
1003
+ send_back_index++;
1004
+ const child_issues = d.rule_result.issues.map(issue => ({
1005
+ issue_id: issue.issue_id,
1006
+ issue_description: issue.issue_description,
1007
+ date: issue.date,
1008
+ kind: issue.kind,
1009
+ amount: issue.amount,
1010
+ }));
1011
+ const send_back_payload = {
1012
+ rule_id: d.rule_result.rule_id,
1013
+ rule_name: d.rule_result.rule_name || d.rule_result.rule_id,
1014
+ issue_description: d.rule_result.summary || d.rule_result.rule_name || d.rule_result.rule_id,
1015
+ agent_feedback: d.comment ?? '',
1016
+ child_issues,
1017
+ };
1018
+ const send_back_data = {
1019
+ source_content_id: source_item.content_id,
1020
+ source_file: source_item.file,
1021
+ issues: [send_back_payload],
1022
+ history: [],
1023
+ };
1024
+ const send_back_msg = {
1025
+ message_id: make_id('msg'),
1026
+ role: 'agent',
1027
+ type: 'send_back',
1028
+ visibility: 'all',
1029
+ send_back: send_back_data,
1030
+ user_name: current_user.name,
1031
+ user_id: current_user.id,
1032
+ time: new Date().toISOString(),
1033
+ };
1034
+ if (parent_task_id) {
1035
+ const build_new_task = (msg) => ({
1036
+ task_id: make_id('task'),
1037
+ title: `Clarifications regarding ${parent_title}`,
1038
+ description: '',
1039
+ status: 'needs_attention',
1040
+ sort_order: next_tasks.length + 1,
1041
+ created_at: new Date().toISOString(),
1042
+ created_by: current_user.id,
1043
+ thread: [msg],
1044
+ parent_task_id,
1045
+ });
1046
+ next_tasks = merge_send_back_message(next_tasks, parent_task_id, send_back_msg, build_new_task);
1047
+ }
1048
+ else {
1049
+ const new_task = {
1050
+ task_id: make_id('task'),
1051
+ title: `Clarifications regarding ${parent_title}`,
1052
+ description: '',
1053
+ status: 'needs_attention',
1054
+ sort_order: next_tasks.length + 1,
1055
+ created_at: new Date().toISOString(),
1056
+ created_by: current_user.id,
1057
+ thread: [send_back_msg],
1058
+ };
1059
+ next_tasks = [...next_tasks, new_task];
1060
+ }
1061
+ }
1062
+ // Stamp rule_actions and recompute derived flags atomically with the tasks change.
1063
+ const merged_rule_actions = {
1064
+ ...(source_item.rule_actions ?? {}),
1065
+ };
1066
+ for (const d of decisions) {
1067
+ merged_rule_actions[d.rule_id] = d.action;
1068
+ }
1069
+ const all_rules_with_issues = (source_item.validation_rule_results ?? [])
1070
+ .filter(r => r.issues.length > 0);
1071
+ const decision_state = compute_file_decision_state(merged_rule_actions, all_rules_with_issues);
1072
+ // Atomic update: tasks + per-file fields together.
1073
+ if (next_tasks !== data.tasks) {
1074
+ const updated = {
1075
+ ...data,
1076
+ updated_at: new Date().toISOString(),
1077
+ tasks: next_tasks,
1078
+ };
1079
+ on_form_change?.(updated);
1080
+ }
1081
+ on_update_content_item?.(source_item.content_id, {
1082
+ rule_actions: merged_rule_actions,
1083
+ accepted: decision_state.accepted,
1084
+ sent_back: decision_state.sent_back,
1085
+ });
1086
+ on_log?.(`${current_user.name} (${current_user.id}): Submitted ${decisions.length} decision(s) for ${file_name} (${send_back_index} send-back, ${decisions.length - send_back_index} accept)`);
1087
+ }, [data, current_user, on_form_change, on_update_content_item, on_log]);
1088
+ const handle_edit_message = useCallback((task_id, message_id, updated) => {
1089
+ on_message_edit?.(task_id, message_id, updated);
1090
+ }, [on_message_edit]);
1091
+ const handle_delete_message = useCallback((task_id, message_id) => {
1092
+ on_message_delete?.(task_id, message_id);
1093
+ }, [on_message_delete]);
1094
+ /** Delete a task from the form */
1095
+ const handle_delete_task = useCallback((task_id) => {
1096
+ const updated = {
1097
+ ...data,
1098
+ updated_at: new Date().toISOString(),
1099
+ tasks: data.tasks.filter(t => t.task_id !== task_id),
1100
+ };
1101
+ on_form_change?.(updated);
1102
+ const task_title = data.tasks.find(t => t.task_id === task_id)?.title || task_id;
1103
+ on_log?.(`${current_user.name} (${current_user.id}): Deleted task "${task_title}"`);
1104
+ }, [data, on_form_change, current_user, on_log]);
1105
+ /** Update a ContentItem's fields — delegates to prop (functional update, safe from stale closures) */
1106
+ const handle_update_content_item = useCallback((content_id, updates) => {
1107
+ on_update_content_item?.(content_id, updates);
1108
+ }, [on_update_content_item]);
1109
+ /** Agent edits a file's document type via the FileBar pencil. Updates the
1110
+ * classification and re-runs backoffice validation against the rules of the
1111
+ * newly selected document_type. Stale validation results from the prior type
1112
+ * are cleared so the UI reflects the new rule set, not a mix. */
1113
+ const handle_change_document_type = useCallback(async (item, new_type_id) => {
1114
+ const old_classification = item.classification_result || pipeline.classification_results.get(item.content_id);
1115
+ const updated_classification = {
1116
+ // Sensible defaults if there was no classification yet (file uploaded as unknown).
1117
+ tags: [],
1118
+ confidence: 1,
1119
+ ...(old_classification ?? {}),
1120
+ document_type: new_type_id,
1121
+ };
1122
+ // 1. Persist the new doc type, clear stale validation results, and mark as
1123
+ // needing revalidation (clearing backoffice_validated_at puts it back in
1124
+ // the agent's "Run Backoffice (N)" pending pool while we re-run below).
1125
+ on_update_content_item?.(item.content_id, {
1126
+ classification_result: updated_classification,
1127
+ validation_rule_results: [],
1128
+ backoffice_validated_at: undefined,
1129
+ accepted: false,
1130
+ sent_back: false,
1131
+ });
1132
+ on_log?.(`${current_user.name} (${current_user.id}): Changed document type to "${new_type_id}" for ${item.file?.file_name ?? item.content_id}`);
1133
+ // 2. Re-run backoffice validation. Pass a synthetic item that carries the
1134
+ // new classification so run_backoffice_validation reads the right
1135
+ // document_type when looking up rules.
1136
+ const synthetic = { ...item, classification_result: updated_classification };
1137
+ try {
1138
+ const results_map = await pipeline.run_backoffice_validation([synthetic]);
1139
+ const r = results_map.get(item.content_id);
1140
+ if (r) {
1141
+ on_update_content_item?.(item.content_id, {
1142
+ validation_rule_results: r.validation_results,
1143
+ backoffice_validated_at: new Date().toISOString(),
1144
+ });
1145
+ on_log?.(`Re-ran ${r.validation_results.length} rule(s) for ${item.file?.file_name ?? item.content_id} under "${new_type_id}"`);
1146
+ }
1147
+ }
1148
+ catch (err) {
1149
+ console.error('[ThreadForm] Re-validation after doc-type change failed:', err);
1150
+ on_log?.(`Re-validation failed for ${item.file?.file_name ?? item.content_id} — see console`);
1151
+ }
1152
+ }, [pipeline, on_update_content_item, on_log, current_user]);
1153
+ /** Handle replacement files from clarification response — append to the original message and run pipeline */
1154
+ const handle_replacement_files = useCallback(async (source_content_id, files) => {
1155
+ if (files.length === 0)
1156
+ return;
1157
+ // Use ref for latest data to avoid stale closure issues with chained replacements
1158
+ const current_data = data_ref.current;
1159
+ // Find the task and message containing the source content item
1160
+ let found_task;
1161
+ let found_msg;
1162
+ for (const t of current_data.tasks) {
1163
+ const m = t.thread.find(m => m.content_items?.some(ci => ci.content_id === source_content_id));
1164
+ if (m) {
1165
+ found_task = t;
1166
+ found_msg = m;
1167
+ break;
1168
+ }
1169
+ }
1170
+ if (!found_task || !found_msg) {
1171
+ console.error('[ThreadForm] Could not find task/message for source content:', source_content_id);
1172
+ return;
1173
+ }
1174
+ // Convert FormFileAttachments to ContentItems, linking to the replaced file
1175
+ const new_items = files.map(f => ({
1176
+ content_id: `ct_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
1177
+ type: 'file',
1178
+ file: {
1179
+ file_id: f.file_id,
1180
+ file_name: f.file_name,
1181
+ file_size: format_file_size(f.file_size),
1182
+ mime_type: f.mime_type || 'application/octet-stream',
1183
+ },
1184
+ replaces_content_id: source_content_id,
1185
+ }));
1186
+ // Mark the original file as replaced, and append replacement files to the message
1187
+ const first_replacement_id = new_items[0].content_id;
1188
+ const updated_items = (found_msg.content_items || []).map(ci => ci.content_id === source_content_id
1189
+ ? { ...ci, replaced_by_content_id: first_replacement_id }
1190
+ : ci);
1191
+ updated_items.push(...new_items);
1192
+ on_message_edit?.(found_task.task_id, found_msg.message_id, {
1193
+ content_text: found_msg.content_text,
1194
+ content_items: updated_items,
1195
+ });
1196
+ on_log?.(`${current_user.name}: Uploaded ${files.length} replacement file${files.length > 1 ? 's' : ''}`);
1197
+ // Trigger classification + validation pipeline for each replacement file.
1198
+ // Collect each file's classification promise so we can run the aggregate
1199
+ // resolver once they all complete — replacement files participate in the
1200
+ // same sum_match check as fresh sends.
1201
+ const replacement_pipeline_promises = [];
1202
+ const replacement_b64_by_content_id = new Map();
1203
+ if (classification_api_url && file_manager?.callbacks?.get_download_url) {
1204
+ for (const item of new_items) {
1205
+ if (!item.file)
1206
+ continue;
1207
+ const url = file_manager.callbacks.get_download_url(item.file.file_id, 'public');
1208
+ if (!url)
1209
+ continue;
1210
+ try {
1211
+ const response = await fetch(url);
1212
+ const blob = await response.blob();
1213
+ const b64 = await file_to_base64(new File([blob], item.file.file_name, { type: item.file.mime_type }));
1214
+ replacement_b64_by_content_id.set(item.content_id, b64);
1215
+ const p = pipeline.process_file(item, b64).then(result => {
1216
+ if (result.doc_check) {
1217
+ on_message_add?.(found_task.task_id, {
1218
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
1219
+ role: 'system', type: 'doc_check', visibility: 'all',
1220
+ doc_check: result.doc_check, time: new Date().toISOString(),
1221
+ });
1222
+ }
1223
+ if (result.ai_result) {
1224
+ on_message_add?.(found_task.task_id, {
1225
+ message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
1226
+ role: 'system', type: 'ai_analysis', visibility: 'agent_only',
1227
+ ai_analysis: { items: [result.ai_result] }, time: new Date().toISOString(),
1228
+ });
1229
+ }
1230
+ if (result.classification || result.validation_results.length > 0) {
1231
+ setTimeout(() => {
1232
+ handle_update_content_item(item.content_id, {
1233
+ classification_result: result.classification || undefined,
1234
+ validation_rule_results: result.validation_results,
1235
+ });
1236
+ }, 100);
1237
+ }
1238
+ return { content_id: item.content_id, classification: result.classification };
1239
+ }).catch(err => {
1240
+ console.error('[ThreadForm] Pipeline error for replacement:', item.file?.file_name, err);
1241
+ return { content_id: item.content_id, classification: null };
1242
+ });
1243
+ replacement_pipeline_promises.push(p);
1244
+ }
1245
+ catch (err) {
1246
+ console.error('[ThreadForm] Failed to fetch replacement file for pipeline:', item.file.file_name, err);
1247
+ }
1248
+ }
1249
+ }
1250
+ // Once replacement classifications complete, re-run the aggregate
1251
+ // resolver so the panel reflects the new file set.
1252
+ if (replacement_pipeline_promises.length > 0) {
1253
+ (async () => {
1254
+ const fresh_results = await Promise.all(replacement_pipeline_promises);
1255
+ const fresh_cls_map = new Map();
1256
+ for (const r of fresh_results)
1257
+ fresh_cls_map.set(r.content_id, r.classification);
1258
+ await run_resolution(found_task.task_id, {
1259
+ fresh_cls_map,
1260
+ current_send_b64_by_content_id: replacement_b64_by_content_id,
1261
+ });
1262
+ })();
1263
+ }
1264
+ }, [current_user, on_message_edit, on_message_add, file_manager, classification_api_url, pipeline, file_to_base64, format_file_size, handle_update_content_item, on_log, run_resolution]);
1265
+ /** Agent approves a client-completed task */
1266
+ const handle_approve_task = useCallback((task_id) => {
1267
+ const updated = {
1268
+ ...data,
1269
+ updated_at: new Date().toISOString(),
1270
+ tasks: data.tasks.map(t => t.task_id === task_id ? { ...t, agent_review: 'approved' } : t),
1271
+ };
1272
+ on_form_change?.(updated);
1273
+ const task_title = data.tasks.find(t => t.task_id === task_id)?.title || task_id;
1274
+ on_log?.(`${current_user.name} (${current_user.id}): Approved task "${task_title}"`);
1275
+ }, [data, on_form_change, current_user, on_log]);
1276
+ /** Agent renames a task title */
1277
+ const handle_rename_task = useCallback((task_id, title) => {
1278
+ const task = data.tasks.find(t => t.task_id === task_id);
1279
+ if (!task || task.title === title)
1280
+ return;
1281
+ const updated = { ...task, title };
1282
+ on_task_update?.(updated);
1283
+ on_log?.(`${current_user.name} (${current_user.id}): Renamed task → "${title}"`);
1284
+ }, [data.tasks, on_task_update, current_user, on_log]);
1285
+ /** Agent reopens a client-completed task */
1286
+ const handle_reopen_task = useCallback((task_id) => {
1287
+ const updated = {
1288
+ ...data,
1289
+ updated_at: new Date().toISOString(),
1290
+ tasks: data.tasks.map(t => t.task_id === task_id
1291
+ ? { ...t, status: 'in_progress', agent_review: 'reopened', reopened_by: current_user.id, completed_at: undefined, completed_by: undefined }
1292
+ : t),
1293
+ };
1294
+ on_form_change?.(updated);
1295
+ const task_title = data.tasks.find(t => t.task_id === task_id)?.title || task_id;
1296
+ on_log?.(`${current_user.name} (${current_user.id}): Reopened task "${task_title}"`);
1297
+ }, [data, current_user, on_form_change, on_log]);
1298
+ const handle_selection_change = useCallback((selections) => {
1299
+ // Build a map of all file ContentItems so we can walk replacement chains.
1300
+ // Toggling Keep on either end of a chain (replaced or replacement) keeps
1301
+ // both ends in sync — they represent the same logical document.
1302
+ const items_by_id = new Map();
1303
+ for (const task of data.tasks) {
1304
+ for (const msg of task.thread) {
1305
+ for (const ci of msg.content_items ?? []) {
1306
+ if (ci.type === 'file')
1307
+ items_by_id.set(ci.content_id, ci);
1308
+ }
1309
+ }
1310
+ }
1311
+ const collect_chain = (start_id) => {
1312
+ const seen = new Set();
1313
+ const queue = [start_id];
1314
+ while (queue.length > 0) {
1315
+ const id = queue.shift();
1316
+ if (seen.has(id))
1317
+ continue;
1318
+ seen.add(id);
1319
+ const it = items_by_id.get(id);
1320
+ if (!it)
1321
+ continue;
1322
+ if (it.replaces_content_id && !seen.has(it.replaces_content_id))
1323
+ queue.push(it.replaces_content_id);
1324
+ if (it.replaced_by_content_id && !seen.has(it.replaced_by_content_id))
1325
+ queue.push(it.replaced_by_content_id);
1326
+ }
1327
+ return Array.from(seen);
1328
+ };
1329
+ const expanded = {};
1330
+ for (const [id, value] of Object.entries(selections)) {
1331
+ for (const chain_id of collect_chain(id)) {
1332
+ expanded[chain_id] = value;
1333
+ }
1334
+ }
1335
+ const updated = {
1336
+ ...data,
1337
+ collected_data_selections: { ...data.collected_data_selections, ...expanded },
1338
+ };
1339
+ on_form_change?.(updated);
1340
+ }, [data, on_form_change]);
1341
+ // Build effective Keep state for every file across all tasks. Default selection
1342
+ // mirrors CollectedDataView: replaced originals default to unchecked; everything
1343
+ // else defaults to checked. Explicit `data.collected_data_selections` overrides
1344
+ // the default. Used for the file_bar checkbox and the Collected Data tab count.
1345
+ const keep_selections_effective = useMemo(() => {
1346
+ const explicit = data.collected_data_selections ?? {};
1347
+ const out = {};
1348
+ for (const task of data.tasks) {
1349
+ for (const msg of task.thread) {
1350
+ for (const ci of msg.content_items ?? []) {
1351
+ if (ci.type !== 'file')
1352
+ continue;
1353
+ const default_selected = !ci.replaced_by_content_id;
1354
+ out[ci.content_id] = explicit[ci.content_id] ?? default_selected;
1355
+ }
1356
+ }
1357
+ }
1358
+ return out;
1359
+ }, [data]);
1360
+ const kept_file_count = useMemo(() => Object.values(keep_selections_effective).filter(Boolean).length, [keep_selections_effective]);
1361
+ // ── Render ──
1362
+ const tasks_to_show = active_tab === 'review' ? review_tasks : data.tasks;
1363
+ // Client view gets a subtle orange wash so it's instantly distinguishable
1364
+ // from the agent (back-office) view at a glance — same content, different
1365
+ // role context.
1366
+ const is_client = role === 'client';
1367
+ return (_jsxs("div", { className: cn('flex flex-col h-full', is_client ? 'bg-[#FFF7ED]' : 'bg-[#F8FAFC]'), children: [is_client && (_jsx("div", { className: "h-0.5 bg-[#F97316] flex-shrink-0" })), _jsxs("div", { className: cn('px-5 py-3 border-b', is_client ? 'border-[#FED7AA] bg-[#FFFBEB]' : 'border-[#E2E8F0] bg-white'), children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [is_client && (_jsx("span", { className: "inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase text-[#9A3412] bg-[#FED7AA]", title: "You are viewing this form as the client", children: "Client view" })), _jsxs("div", { children: [_jsx("h2", { className: "font-semibold text-[#0F172A]", children: data.title }), _jsxs("div", { className: "flex items-center gap-3 text-xs text-[#94A3B8] mt-0.5", children: [_jsx("span", { children: data.client.name }), _jsx("span", { children: "\u00B7" }), _jsx("span", { children: data.agents.map(a => a.name).join(', ') })] })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [role === 'agent' && (() => {
1368
+ const has_pending = pending_backoffice_items.length > 0 && !backoffice_running;
1369
+ return (_jsxs("button", { type: "button", onClick: handle_run_backoffice, disabled: backoffice_running || pending_backoffice_items.length === 0, className: cn('text-xs font-medium px-3 py-1.5 rounded cursor-pointer flex items-center gap-1.5 disabled:cursor-not-allowed', has_pending
1370
+ ? 'text-[#92400E] border border-[#F59E0B] bg-[#FEF3C7] hover:bg-[#FDE68A]'
1371
+ : 'text-[#334155] border border-[#CBD5E1] bg-white hover:bg-[#F8FAFC] disabled:text-[#CBD5E1] disabled:bg-[#F8FAFC]'), title: pending_backoffice_items.length === 0
1372
+ ? 'All files have had backoffice validation run'
1373
+ : `Run backoffice validation on ${pending_backoffice_items.length} file(s)`, children: [has_pending && (_jsxs("span", { className: "relative flex h-2 w-2 flex-shrink-0", children: [_jsx("span", { className: "absolute inline-flex h-full w-full rounded-full bg-[#F59E0B] opacity-75 animate-ping" }), _jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-[#F59E0B]" })] })), backoffice_running && (_jsxs("svg", { className: "w-3 h-3 animate-spin", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] })), backoffice_running ? 'Running…' : `Run Backoffice${pending_backoffice_items.length > 0 ? ` (${pending_backoffice_items.length})` : ''}`] }));
1374
+ })(), role === 'agent' && show_json_viewer && (_jsx("button", { onClick: () => set_show_json(!show_json), className: "text-xs text-[#94A3B8] hover:text-[#64748B] cursor-pointer", children: show_json ? 'Hide JSON' : 'JSON' })), role === 'agent' && data.status === 'draft' && (_jsx("button", { onClick: on_send_form, className: "text-xs font-medium text-white bg-[#2563EB] px-3 py-1.5 rounded hover:bg-[#1D4ED8] cursor-pointer", children: "Send to Client" })), role === 'agent' && data.status !== 'completed' && data.status !== 'draft' && (_jsx("button", { onClick: on_close_form, className: "text-xs text-[#64748B] border border-[#E2E8F0] rounded px-3 py-1 hover:bg-[#F1F5F9] cursor-pointer", children: "Close Form" }))] })] }), role === 'agent' && (_jsx("div", { className: "flex gap-4 mt-3 border-b border-[#E2E8F0] -mx-5 px-5", children: ['tasks', 'review', 'collected'].map(tab => (_jsx("button", { onClick: () => set_active_tab(tab), className: cn('text-[13px] pb-2 transition-colors cursor-pointer', active_tab === tab
1375
+ ? 'text-[#0F172A] font-medium border-b-2 border-[#2563EB]'
1376
+ : 'text-[#94A3B8] hover:text-[#64748B]'), children: tab === 'tasks' ? 'Tasks' : tab === 'review' ? `Review${pending_count > 0 ? ` (${pending_count})` : ''}` : `Collected Data${kept_file_count > 0 ? ` (${kept_file_count})` : ''}` }, tab))) }))] }), _jsxs("div", { className: "flex-1 flex overflow-hidden", children: [_jsxs("div", { className: "flex-1 overflow-y-auto px-5 py-2", children: [(active_tab === 'tasks' || active_tab === 'review') && (_jsxs("div", { children: [tasks_to_show.length === 0 && active_tab === 'review' && (_jsx("p", { className: "text-[13px] text-[#94A3B8] text-center py-8", children: "Nothing to review" })), tasks_to_show.map(task => (_jsx(TaskCard, { task: task, role: role, question_number: question_numbers.get(task.task_id), parent_ref: get_parent_ref(task), on_navigate_to_task: handle_navigate_to_task, user_name: current_user.name, user_id: current_user.id, expanded: active_tab === 'review' || expanded_tasks.has(task.task_id), on_toggle: () => set_expanded_tasks(prev => {
1377
+ const next = new Set(prev);
1378
+ if (next.has(task.task_id))
1379
+ next.delete(task.task_id);
1380
+ else
1381
+ next.add(task.task_id);
1382
+ return next;
1383
+ }), on_toggle_complete: handle_toggle_complete, on_delete_task: handle_delete_task, on_approve_task: handle_approve_task, on_reopen_task: handle_reopen_task, on_rename_task: handle_rename_task, on_send: handle_send, on_send_comment: handle_send_comment, on_add_question: handle_add_question, on_view_file: handle_view_file, on_remove_file: handle_remove_file, on_accept: handle_accept, on_edit_message: handle_edit_message, on_delete_message: handle_delete_message, upload_file: handle_upload_file, on_dismiss: handle_dismiss, on_send_to_client: handle_send_to_client, on_send_back_issues: handle_send_back_issues, on_submit_decisions: handle_submit_decisions, on_update_content_item: handle_update_content_item, file_statuses: pipeline.file_statuses, classification_results: pipeline.classification_results, validation_results: pipeline.validation_results, file_manager: file_manager?.callbacks, on_replacement_files: handle_replacement_files, view_button_variant: view_button_variant, show_info_icon: show_info_icon, sent_back_resolved_ids: sent_back_resolved_ids, resolving: resolution_running.has(task.task_id), available_document_types: available_document_types, on_change_document_type: handle_change_document_type, keep_selections: keep_selections_effective, on_keep_change: handle_selection_change }, task.task_id))), active_tab === 'review' && review_tasks.length > 0 && (_jsxs("div", { className: "flex items-center gap-3 pt-4", children: [_jsx("button", { className: "text-xs text-[#2563EB] hover:underline cursor-pointer", children: "Accept all passed" }), _jsx("button", { className: "text-xs text-[#2563EB] hover:underline cursor-pointer", children: "Send all issues" })] }))] })), active_tab === 'collected' && (_jsx(CollectedDataView, { data: data, classification_results: pipeline.classification_results, on_selection_change: handle_selection_change, on_export: on_export_data, on_view_file: handle_view_file }))] }), viewed_item && (_jsx(ThreadPdfSidePanel, { item: viewed_item, file_url: viewed_file_url, PdfViewer: pdf_viewer || null, on_close: handle_close_pdf }))] }), show_json && (_jsxs("div", { className: "fixed right-0 top-0 bottom-0 w-[500px] bg-white border-l shadow-lg z-50 flex flex-col", children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 border-b", children: [_jsx("span", { className: "text-sm font-medium", children: "JSON" }), _jsx("button", { onClick: () => set_show_json(false), className: "text-gray-400 hover:text-gray-600 cursor-pointer", children: "\u2715" })] }), _jsx("pre", { className: "flex-1 overflow-auto p-4 text-xs font-mono bg-gray-50", children: JSON.stringify(data, null, 2) })] })), _jsx(AddQuestionDialog, { open: show_add_question, on_close: () => set_show_add_question(false), on_add: handle_add_task, agent_id: current_user.id }), _jsx(AgentComposeDialog, { open: !!compose_context, context: compose_context || undefined, on_close: () => set_compose_context(null), on_send: handle_compose_send, agent_name: current_user.name })] }));
1384
+ }
1385
+ //# sourceMappingURL=thread_form.js.map