collavre 0.24.0 → 0.25.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 (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. data/config/locales/mobile.ko.yml +0 -16
@@ -4,6 +4,7 @@ import { wrapHtmlInCodeBlocks } from '../../lib/html_code_block_wrapper'
4
4
  import { refreshCsrfToken } from '../../lib/api/csrf_fetch'
5
5
  import ReviewQuotesStore from './review_quotes_store'
6
6
  import { alertDialog } from '../../lib/utils/dialog'
7
+ import chatDrafts from '../../lib/chat_drafts'
7
8
 
8
9
  // In-flight comment sends, keyed by creative id. This lives at module scope —
9
10
  // not on the controller instance — so the duplicate-submit guard survives a
@@ -36,14 +37,14 @@ export default class extends Controller {
36
37
 
37
38
  connect() {
38
39
  this.creativeId = null
39
- this.editingId = null
40
+ this.editingId ??= null
40
41
  this.sending = false
41
- this._reviewStore = new ReviewQuotesStore()
42
+ this._reviewStore ||= new ReviewQuotesStore()
42
43
  this.cachedImageFiles = null
43
44
 
44
45
  this.handleSubmit = this.handleSubmit.bind(this)
45
46
  this.handleSend = this.handleSend.bind(this)
46
- this.defaultSubmitHTML = this.submitTarget.innerHTML
47
+ this.defaultSubmitHTML ??= this.submitTarget.innerHTML
47
48
  this.handlePointerSend = this.handlePointerSend.bind(this)
48
49
  this.handleTouchSend = this.handleTouchSend.bind(this)
49
50
  this.handleCancel = this.handleCancel.bind(this)
@@ -89,11 +90,125 @@ export default class extends Controller {
89
90
  }
90
91
  this.textareaTarget.addEventListener('input', this._autoResize)
91
92
 
93
+ // Draft persistence: debounce-save unsent input per chat.
94
+ // _activeDraftKey always identifies the chat whose text is in the textarea.
95
+ this._activeDraftKey ??= null
96
+ this._activeDraftCreativeId ??= null
97
+ this._awaitingEffectiveDraftKeyFor ??= null
98
+ this._draftSaveTimer = null
99
+ this._draftSaveSuspendedForPermission ??= false
100
+ // A cross-tab logout permanently retires the old user's namespace for this
101
+ // controller lifetime, including Stimulus reconnects in the stale tab.
102
+ this._disabledDraftNamespaces ||= new Set()
103
+ this._draftBackupCleanupPendingNamespaces ||= new Set()
104
+ this._observedDraftClearNonces ||= new Map()
105
+ // A pending send survives a Stimulus reconnect on this controller instance,
106
+ // so its completion must keep comparing against the same draft history.
107
+ this._draftRevisions ||= new Map()
108
+ this._observedDrafts ||= new Map()
109
+ this._observedDisplayedDrafts ||= new Map()
110
+ this._observedDraftRevisions ||= new Map()
111
+ this._observedStoredDraftRevisions ||= new Map()
112
+ // A linked chat can replace its temporary raw key while its request is in
113
+ // flight. Keep mutable submission state across that migration/reconnect.
114
+ this._pendingDraftSubmissions ||= new Set()
115
+ const draftNamespace = chatDrafts.namespace()
116
+ const draftClearNonce = chatDrafts.clearNonce(draftNamespace)
117
+ const draftClearNoncePending = chatDrafts.clearNoncePending(draftNamespace)
118
+ const observedDraftClearNonce = this._observedDraftClearNonces.has(draftNamespace)
119
+ let canObserveDraftClearNonce = true
120
+ if (draftClearNonce && !observedDraftClearNonce) {
121
+ canObserveDraftClearNonce = chatDrafts.clearSubmissionBackupsForClear(
122
+ draftNamespace,
123
+ draftClearNonce,
124
+ )
125
+ if (canObserveDraftClearNonce && !draftClearNoncePending) {
126
+ this._draftBackupCleanupPendingNamespaces.delete(draftNamespace)
127
+ } else {
128
+ this._draftBackupCleanupPendingNamespaces.add(draftNamespace)
129
+ }
130
+ }
131
+ if (
132
+ draftClearNonce &&
133
+ observedDraftClearNonce &&
134
+ this._observedDraftClearNonces.get(draftNamespace) !== draftClearNonce
135
+ ) {
136
+ this._disableDraftNamespace(draftNamespace)
137
+ }
138
+ if (
139
+ draftClearNonce !== undefined &&
140
+ canObserveDraftClearNonce &&
141
+ !draftClearNoncePending
142
+ ) {
143
+ this._observedDraftClearNonces.set(draftNamespace, draftClearNonce)
144
+ }
145
+ this._handlePageHide = () => {
146
+ if (!this.element.isConnected) return
147
+
148
+ this._flushDraftSave()
149
+ }
150
+ this._handleDraftStorage = (event) => {
151
+ if (!this.element.isConnected || !chatDrafts.wasCleared(event)) return
152
+
153
+ const clearedNamespace = chatDrafts.namespace()
154
+ this._disableDraftNamespace(clearedNamespace)
155
+ }
156
+ this._handleCompositionEnd = () => {
157
+ this._imeCommitPending = true
158
+ }
159
+ // Chrome fires the commit `input` after `compositionend`, but Firefox has
160
+ // shipped the reverse order, where the commit input consumes the latch
161
+ // before it is even set. Nothing would clear the leftover latch, so the
162
+ // next ordinary edit would be misread as a commit. The commit input always
163
+ // arrives before the user can act again, so any fresh gesture expires it.
164
+ this._expireImeCommitLatch = (event) => {
165
+ if (event?.isComposing || event?.keyCode === 229) return
166
+
167
+ this._imeCommitPending = false
168
+ }
169
+ this._handleDraftInput = (event) => {
170
+ // Consume the composition flag before any early return, so it can never
171
+ // outlive the `input` event that the commit itself fired. Firefox has
172
+ // shipped both orderings, so accept isComposing on the input event too.
173
+ const isImeCommit = Boolean(event?.isComposing) || this._imeCommitPending === true
174
+ this._imeCommitPending = false
175
+ if (
176
+ this.editingId ||
177
+ this._draftPersistenceDisabled() ||
178
+ this._draftSaveSuspendedForPermission ||
179
+ this._shouldSuppressDraftSaveForStash() ||
180
+ !this._reviewStore.isEmpty ||
181
+ !this._activeDraftKey
182
+ ) return
183
+ // An IME commit fires `input` after the keydown that started a send, so
184
+ // the textarea still holds exactly what went to the server. Counting it
185
+ // as a revision would defeat _currentPendingSubmission and make the sent
186
+ // message look like a draft typed mid-flight, restoring it on success.
187
+ // Text equality alone is not enough to suppress: re-entering the same
188
+ // string mid-flight (select-all + paste to send it twice) is a real edit.
189
+ if (isImeCommit && this._currentTextIsPendingSubmission()) return
190
+ const revisionKey = `${chatDrafts.namespace()}:${this._activeDraftKey}`
191
+ this._draftRevisions.set(revisionKey, (this._draftRevisions.get(revisionKey) || 0) + 1)
192
+ clearTimeout(this._draftSaveTimer)
193
+ this._draftSaveTimer = setTimeout(() => this._saveDraftNow(), 500)
194
+ }
195
+ this.textareaTarget.addEventListener('compositionend', this._handleCompositionEnd)
196
+ this.textareaTarget.addEventListener('keydown', this._expireImeCommitLatch)
197
+ this.textareaTarget.addEventListener('paste', this._expireImeCommitLatch)
198
+ this.textareaTarget.addEventListener('drop', this._expireImeCommitLatch)
199
+ this.textareaTarget.addEventListener('input', this._handleDraftInput)
200
+ window.addEventListener('pagehide', this._handlePageHide)
201
+ window.addEventListener('storage', this._handleDraftStorage)
202
+
92
203
  this.textareaTarget.addEventListener('keydown', (event) => {
93
204
  if (event.key === 'Escape') {
94
205
  this.presenceController?.cancelAllAgentTasks()
95
206
  return
96
207
  }
208
+ // While an IME is composing (Korean, Japanese, Chinese), Enter confirms
209
+ // the pending syllable rather than submitting. keyCode 229 covers
210
+ // browsers that leave isComposing unset on the keydown itself.
211
+ if (event.isComposing || event.keyCode === 229) return
97
212
  if (event.key === 'Enter' && !event.shiftKey) {
98
213
  if (this.isMentionMenuVisible()) return
99
214
  this.handleSend(event)
@@ -107,9 +222,76 @@ export default class extends Controller {
107
222
 
108
223
  this.handleListLoaded = () => this._updateInboxReplyMode()
109
224
  this.element.addEventListener('comments--list:loaded', this.handleListLoaded)
225
+
226
+ // A slash command with an input schema takes over the textarea and submits
227
+ // itself (modules/command_menu.js), so a draft the user had already typed
228
+ // cannot go out with it. The menu hands the draft over here instead of
229
+ // discarding it, and we put it back once the send settles.
230
+ this.handleStashDraft = this.handleStashDraft.bind(this)
231
+ this.element.addEventListener('comments--form:stash-draft', this.handleStashDraft)
110
232
  }
111
233
 
112
234
  handleTopicChange(event) {
235
+ // This event fires before onPopupOpened during a chat switch, while the
236
+ // textarea still contains the outgoing chat's text. Flush before re-keying.
237
+ const draftPersistenceDisabled = this._draftPersistenceDisabled()
238
+ const nextDraftKey = draftPersistenceDisabled
239
+ ? null
240
+ : this.element.dataset.effectiveCreativeId || this.element.dataset.creativeId || null
241
+ const nextCreativeId = draftPersistenceDisabled
242
+ ? null
243
+ : this.element.dataset.creativeId || null
244
+ const draftKeyChanged = String(this._activeDraftKey || '') !== String(nextDraftKey || '')
245
+ const creativeChanged =
246
+ String(this._activeDraftCreativeId || '') !== String(nextCreativeId || '')
247
+ const resolvingIncomingDraftKey =
248
+ this._awaitingEffectiveDraftKeyFor &&
249
+ String(this._awaitingEffectiveDraftKeyFor) === String(nextCreativeId || '')
250
+ let keepAwaitingEffectiveDraftKey = false
251
+ if (draftKeyChanged || creativeChanged) {
252
+ const previousDraftKey = this._activeDraftKey
253
+ // onChatWillOpen already flushed the outgoing chat. While the effective
254
+ // key is loading, save only actual new input; rewriting a restored raw
255
+ // draft here would make stale text appear newer than the canonical draft.
256
+ if (!resolvingIncomingDraftKey || this._draftSaveTimer) this._flushDraftSave()
257
+ this._activeDraftKey = nextDraftKey ? String(nextDraftKey) : null
258
+ this._activeDraftCreativeId = nextCreativeId ? String(nextCreativeId) : null
259
+ if (
260
+ resolvingIncomingDraftKey &&
261
+ previousDraftKey &&
262
+ this._activeDraftKey
263
+ ) {
264
+ const sourceDraft = chatDrafts.snapshot(previousDraftKey)
265
+ const moveCompleted = chatDrafts.move(previousDraftKey, this._activeDraftKey)
266
+ const targetDraft = chatDrafts.snapshot(this._activeDraftKey)
267
+ const movedBackups = moveCompleted
268
+ ? chatDrafts.moveSubmissionBackups(previousDraftKey, this._activeDraftKey)
269
+ : new Map()
270
+ if (moveCompleted && (targetDraft.revision || !sourceDraft.revision)) {
271
+ this._rebindPendingDraftSubmissions(
272
+ previousDraftKey,
273
+ this._activeDraftKey,
274
+ sourceDraft,
275
+ targetDraft,
276
+ movedBackups,
277
+ )
278
+ } else if (!moveCompleted) {
279
+ this._trackPartialDraftMigration(
280
+ previousDraftKey,
281
+ this._activeDraftKey,
282
+ sourceDraft,
283
+ targetDraft,
284
+ )
285
+ if (chatDrafts.isNewer(previousDraftKey, this._activeDraftKey)) {
286
+ this._activeDraftKey = String(previousDraftKey)
287
+ keepAwaitingEffectiveDraftKey = true
288
+ }
289
+ }
290
+ }
291
+ }
292
+ if (resolvingIncomingDraftKey && !keepAwaitingEffectiveDraftKey) {
293
+ this._awaitingEffectiveDraftKeyFor = null
294
+ }
113
295
  this.currentTopicId = event.detail.topicId
114
296
  this._isInbox = event.detail.isInbox || false
115
297
  this._systemTopicId = event.detail.systemTopicId || null
@@ -125,6 +307,14 @@ export default class extends Controller {
125
307
  }
126
308
 
127
309
  disconnect() {
310
+ this._flushDraftSave()
311
+ this.textareaTarget.removeEventListener('compositionend', this._handleCompositionEnd)
312
+ this.textareaTarget.removeEventListener('keydown', this._expireImeCommitLatch)
313
+ this.textareaTarget.removeEventListener('paste', this._expireImeCommitLatch)
314
+ this.textareaTarget.removeEventListener('drop', this._expireImeCommitLatch)
315
+ this.textareaTarget.removeEventListener('input', this._handleDraftInput)
316
+ window.removeEventListener('pagehide', this._handlePageHide)
317
+ window.removeEventListener('storage', this._handleDraftStorage)
128
318
  this.formTarget.removeEventListener('submit', this.handleSubmit)
129
319
  this.submitTarget.removeEventListener('click', this.handleSend)
130
320
  this.submitTarget.removeEventListener('pointerup', this.handlePointerSend)
@@ -142,6 +332,7 @@ export default class extends Controller {
142
332
  this.textareaTarget.removeEventListener('paste', this.handlePaste)
143
333
  this.element.removeEventListener('comments--topics:change', this.handleTopicChange)
144
334
  this.element.removeEventListener('comments--list:loaded', this.handleListLoaded)
335
+ this.element.removeEventListener('comments--form:stash-draft', this.handleStashDraft)
145
336
  }
146
337
 
147
338
  get listController() {
@@ -155,31 +346,86 @@ export default class extends Controller {
155
346
  onPopupOpened({ creativeId, canComment }) {
156
347
  this.creativeId = creativeId
157
348
  this.element.dataset.creativeId = creativeId || ''
349
+ if (canComment) this._draftSaveSuspendedForPermission = false
158
350
  // Stale topic ids from the previous creative are cleared by the popup
159
351
  // controller BEFORE topics loadTopics() dispatches comments--topics:change,
160
352
  // so by the time we get here, currentTopicId already reflects the new
161
353
  // creative's restored topic. Do not re-clear it.
162
354
  this.formTarget.style.display = canComment ? '' : 'none'
355
+ // Capture input entered while topics were loading before reset clears it.
356
+ // Without a pending input timer, a blank textarea must not erase a draft
357
+ // that is waiting in storage to be restored below.
358
+ if (this._draftSaveTimer) this._flushDraftSave()
163
359
  this.resetForm()
360
+ this._draftSaveSuspendedForPermission = !canComment
164
361
  if (canComment && this.shouldAutoFocusOnOpen()) {
165
362
  requestAnimationFrame(() => this.textareaTarget.focus())
166
363
  }
364
+ this._restoreDraft()
365
+ }
366
+
367
+ onChatWillOpen({ creativeId }) {
368
+ const nextCreativeId = creativeId ? String(creativeId) : null
369
+ if (
370
+ String(this._activeDraftCreativeId || '') === String(nextCreativeId || '')
371
+ ) return
372
+
373
+ // The popup publishes its raw creative id before awaiting topics. Flush the
374
+ // outgoing chat now, then give any input typed during that await a key owned
375
+ // by the incoming chat. The topics event replaces it with the effective id.
376
+ this._flushDraftSave()
377
+ if (this._draftPersistenceDisabled()) {
378
+ this._activeDraftKey = null
379
+ this._activeDraftCreativeId = null
380
+ this._awaitingEffectiveDraftKeyFor = null
381
+ this.creativeId = creativeId
382
+ this.resetForm()
383
+ return
384
+ }
385
+ this._activeDraftKey = nextCreativeId
386
+ this._activeDraftCreativeId = nextCreativeId
387
+ this._awaitingEffectiveDraftKeyFor = nextCreativeId
388
+ this.creativeId = creativeId
389
+ this.resetForm()
390
+ this._restoreDraft()
167
391
  }
168
392
 
169
393
  onPopupClosed() {
394
+ this._flushDraftSave()
395
+ this._activeDraftKey = null
396
+ this._activeDraftCreativeId = null
397
+ this._awaitingEffectiveDraftKeyFor = null
170
398
  this.stopSpeechRecognition()
171
399
  this.resetForm()
172
400
  }
173
401
 
402
+ discardDraft() {
403
+ const namespace = chatDrafts.namespace()
404
+ this._pendingDraftSubmissions?.forEach((submission) => {
405
+ if (submission.namespace === namespace) submission.invalidated = true
406
+ })
407
+ clearTimeout(this._draftSaveTimer)
408
+ this._draftSaveTimer = null
409
+ this._activeDraftKey = null
410
+ this._activeDraftCreativeId = null
411
+ this._awaitingEffectiveDraftKeyFor = null
412
+ this._stashedDraft = null
413
+ this.resetForm()
414
+ }
415
+
174
416
  setCommentPermission(canComment) {
175
417
  this.formTarget.style.display = canComment ? '' : 'none'
176
418
 
177
419
  if (!canComment) {
420
+ this._flushDraftSave()
421
+ this._draftSaveSuspendedForPermission = true
178
422
  this.stopSpeechRecognition()
179
423
  this.resetForm()
180
424
  return
181
425
  }
182
426
 
427
+ this._draftSaveSuspendedForPermission = false
428
+ this._restoreDraft()
183
429
  if (this.shouldAutoFocusOnOpen()) {
184
430
  requestAnimationFrame(() => this.textareaTarget.focus())
185
431
  }
@@ -200,6 +446,7 @@ export default class extends Controller {
200
446
  }
201
447
 
202
448
  startEditing({ id, content, private: isPrivate }) {
449
+ this._flushDraftSave()
203
450
  this.editingId = id
204
451
  this.textareaTarget.value = content || ''
205
452
  if (this.privateCheckboxTarget) {
@@ -213,6 +460,58 @@ export default class extends Controller {
213
460
  this.focusTextarea()
214
461
  }
215
462
 
463
+ handleStashDraft(event) {
464
+ const draft = event.detail?.draft || null
465
+ if (draft) this._flushDraftSave()
466
+ // Tagged with the conversation it was typed in: the popup reuses this one
467
+ // controller for every creative, so a stash left over from another one must
468
+ // not be handed back here.
469
+ this._stashedDraft = draft ? { draft, creativeId: this.creativeId } : null
470
+ }
471
+
472
+ _stashedDraftBelongsToCurrentCreative() {
473
+ return Boolean(
474
+ this._stashedDraft &&
475
+ String(this._stashedDraft.creativeId) === String(this.creativeId),
476
+ )
477
+ }
478
+
479
+ _shouldSuppressDraftSaveForStash() {
480
+ if (!this._stashedDraftBelongsToCurrentCreative()) return false
481
+
482
+ // Before handleSend captures the command, the command-menu input event
483
+ // must not replace the stashed ordinary draft. Once the send starts, only
484
+ // the submitted command remains suppressed; later input is a new draft.
485
+ return !this._stashedDraft.submittedText ||
486
+ this.textareaTarget.value === this._stashedDraft.submittedText
487
+ }
488
+
489
+ _restoreStashedDraft(submittedText) {
490
+ const stashed = this._stashedDraft
491
+ this._stashedDraft = null
492
+ if (!stashed) return
493
+ // Switching creatives calls onPopupOpened on this same instance (there is
494
+ // no disconnect between conversations), so a send that settles after the
495
+ // switch would drop the previous conversation's draft into the new one.
496
+ // The draft belongs to a conversation that is no longer on screen; discard
497
+ // it rather than misfile it.
498
+ if (String(stashed.creativeId ?? '') !== String(this.creativeId ?? '')) return
499
+ const draft = stashed.draft
500
+ // Two boxes are safe to overwrite: an empty one (the success path runs
501
+ // resetForm) and one still holding exactly what we submitted (the failure
502
+ // path never clears it, so the command text is left sitting there). Any
503
+ // other content is text the user typed while the request was in flight, or
504
+ // a review quote the failure path restored, and must not be clobbered.
505
+ const current = this.textareaTarget.value
506
+ if (current.trim().length > 0 && current !== submittedText) return
507
+ this.textareaTarget.value = draft
508
+ // Resize and re-enable send directly rather than dispatching `input`, which
509
+ // would re-open the command menu for a draft that starts with "/".
510
+ this._autoResize()
511
+ this._updateSubmitButton()
512
+ this._saveDraftNow()
513
+ }
514
+
216
515
  resetForm() {
217
516
  this.formTarget.reset()
218
517
  this.editingId = null
@@ -224,11 +523,286 @@ export default class extends Controller {
224
523
  this.presenceController?.clearManualTypingMessage()
225
524
  this.clearImageAttachments()
226
525
  this.cancelQuote()
227
- this.textareaTarget.placeholder = ''
526
+ this.textareaTarget.placeholder = this._defaultPlaceholder()
228
527
  // Reset textarea height after clearing content
229
528
  this.textareaTarget.style.height = 'auto'
230
529
  }
231
530
 
531
+ _saveDraftNow() {
532
+ if (
533
+ !this._activeDraftKey ||
534
+ this._draftPersistenceDisabled() ||
535
+ this._draftSaveSuspendedForPermission ||
536
+ this.editingId ||
537
+ this._shouldSuppressDraftSaveForStash() ||
538
+ !this._reviewStore.isEmpty
539
+ ) return
540
+ if (this._currentTextIsPendingSubmission()) return
541
+ const draftKey = this._activeDraftKey
542
+ const text = this.textareaTarget.value
543
+ const blank = !text.trim()
544
+ const storedDraft = chatDrafts.snapshot(draftKey)
545
+ const storedText = storedDraft.text
546
+ const hasStoredEntry = chatDrafts.updatedAt(draftKey) !== null
547
+ const observationKey = `${chatDrafts.namespace()}:${draftKey}`
548
+ const hasObservedDraft = this._observedDrafts?.has(observationKey)
549
+ const observedText = this._observedDrafts?.get(observationKey)
550
+ const hasObservedDisplayedDraft =
551
+ this._observedDisplayedDrafts?.has(observationKey)
552
+ const observedDisplayedText = hasObservedDisplayedDraft
553
+ ? this._observedDisplayedDrafts.get(observationKey)
554
+ : observedText
555
+ const observedRevision = this._observedDraftRevisions?.get(observationKey) || 0
556
+ const observedStoredRevision =
557
+ this._observedStoredDraftRevisions?.get(observationKey) || null
558
+ const currentRevision = this._draftRevisions?.get(observationKey) || 0
559
+ const inputChangedLocally = currentRevision !== observedRevision
560
+ const preserveBlank =
561
+ blank && Boolean(this._awaitingEffectiveDraftKeyFor) && inputChangedLocally
562
+ const displayedDraftChanged =
563
+ (hasObservedDisplayedDraft || hasObservedDraft) &&
564
+ (observedDisplayedText || '') !== text
565
+ const draftChangedLocally =
566
+ inputChangedLocally || displayedDraftChanged
567
+ const storedDraftChangedOutsideController = hasObservedDraft && (
568
+ observedText !== storedText || observedStoredRevision !== storedDraft.revision
569
+ )
570
+ // An idle tab can retain stale restored text after another tab updates the
571
+ // same draft. Closing or switching that idle tab must not write it back.
572
+ if (!draftChangedLocally && storedDraftChangedOutsideController) return
573
+ if (preserveBlank) {
574
+ if (storedText !== null || !hasStoredEntry) {
575
+ chatDrafts.set(draftKey, '', { preserveBlank: true })
576
+ }
577
+ } else if (blank) {
578
+ if (hasStoredEntry) {
579
+ chatDrafts.clear(draftKey)
580
+ }
581
+ } else if (storedText !== text) {
582
+ chatDrafts.set(draftKey, text)
583
+ }
584
+ if (blank && draftChangedLocally) chatDrafts.clearSubmissionBackups(draftKey)
585
+ this._observeDraft(draftKey)
586
+ }
587
+
588
+ _flushDraftSave() {
589
+ clearTimeout(this._draftSaveTimer)
590
+ this._draftSaveTimer = null
591
+ this._saveDraftNow()
592
+ }
593
+
594
+ _currentTextIsPendingSubmission() {
595
+ return Boolean(this._currentPendingSubmission())
596
+ }
597
+
598
+ _currentPendingSubmission() {
599
+ const namespace = chatDrafts.namespace()
600
+ const key = String(this._activeDraftKey || '')
601
+
602
+ return [...(this._pendingDraftSubmissions || [])].find((submission) => (
603
+ !submission.invalidated &&
604
+ submission.namespace === namespace &&
605
+ String(submission.key || '') === key &&
606
+ !submission.hadStash &&
607
+ !submission.hadReview &&
608
+ !submission.editing &&
609
+ submission.text === this.textareaTarget.value &&
610
+ (this._draftRevisions?.get(submission.revisionKey) || 0) === submission.keyRevision
611
+ ))
612
+ }
613
+
614
+ _restoreDraft() {
615
+ if (
616
+ !this._activeDraftKey ||
617
+ this._draftPersistenceDisabled() ||
618
+ this.editingId
619
+ ) return
620
+ if (this.textareaTarget.value.trim()) return
621
+
622
+ const draft = chatDrafts.snapshot(this._activeDraftKey)
623
+ const backup = chatDrafts.latestSubmissionBackup(this._activeDraftKey)
624
+ const restoreBackup = Boolean(
625
+ backup?.text &&
626
+ (draft.updatedAt === null || backup.updatedAt > draft.updatedAt),
627
+ )
628
+ if (backup && !restoreBackup) chatDrafts.removeSubmissionBackup(backup.key)
629
+ const restoredText = restoreBackup ? backup.text : draft.text
630
+ this._observeDraft(
631
+ this._activeDraftKey,
632
+ draft.text,
633
+ chatDrafts.namespace(),
634
+ draft.revision,
635
+ restoredText,
636
+ )
637
+ if (restoredText) {
638
+ this.textareaTarget.value = restoredText
639
+ requestAnimationFrame(() => this._autoResize())
640
+ } else if (draft.updatedAt === null) {
641
+ this._restorePendingSubmittedDraft()
642
+ }
643
+ }
644
+
645
+ _restorePendingSubmittedDraft() {
646
+ const namespace = chatDrafts.namespace()
647
+ const pending = [...(this._pendingDraftSubmissions || [])].find((submission) => (
648
+ !submission.invalidated &&
649
+ submission.namespace === namespace &&
650
+ String(submission.key || '') === String(this._activeDraftKey || '') &&
651
+ !submission.hadStash &&
652
+ !submission.hadReview &&
653
+ !submission.editing
654
+ ))
655
+ if (!pending) return
656
+
657
+ this.textareaTarget.value = pending.text
658
+ requestAnimationFrame(() => this._autoResize())
659
+ this._updateSubmitButton()
660
+ }
661
+
662
+ _draftPersistenceDisabled(namespace = chatDrafts.namespace()) {
663
+ return this._disabledDraftNamespaces?.has(namespace) ||
664
+ this._draftBackupCleanupPendingNamespaces?.has(namespace) ||
665
+ false
666
+ }
667
+
668
+ _disableDraftNamespace(namespace) {
669
+ this._disabledDraftNamespaces.add(namespace)
670
+ this.discardDraft()
671
+ // A timer in this tab may have raced with the logout tab's first clear.
672
+ chatDrafts.clearAll({ broadcast: false })
673
+ }
674
+
675
+ _observeDraft(
676
+ draftKey,
677
+ text,
678
+ namespace = chatDrafts.namespace(),
679
+ storedRevision,
680
+ displayedText,
681
+ ) {
682
+ if (!draftKey) return
683
+ const storedDraft = storedRevision === undefined
684
+ ? chatDrafts.snapshot(draftKey)
685
+ : { text, revision: storedRevision }
686
+ const observationKey = `${namespace}:${draftKey}`
687
+ this._observedDrafts?.set(observationKey, storedDraft.text)
688
+ this._observedDisplayedDrafts?.set(
689
+ observationKey,
690
+ displayedText === undefined ? storedDraft.text : displayedText,
691
+ )
692
+ this._observedDraftRevisions?.set(
693
+ observationKey,
694
+ this._draftRevisions?.get(observationKey) || 0,
695
+ )
696
+ this._observedStoredDraftRevisions?.set(observationKey, storedDraft.revision)
697
+ }
698
+
699
+ _rebindPendingDraftSubmissions(
700
+ sourceKey,
701
+ targetKey,
702
+ sourceDraft,
703
+ targetDraft,
704
+ movedBackups = new Map(),
705
+ ) {
706
+ const namespace = chatDrafts.namespace()
707
+ const targetRevisionKey = `${namespace}:${targetKey}`
708
+
709
+ this._pendingDraftSubmissions?.forEach((submission) => {
710
+ if (
711
+ submission.namespace !== namespace ||
712
+ String(submission.key) !== String(sourceKey)
713
+ ) return
714
+
715
+ const targetMatchesStoredBaseline = submission.storedRevision && targetDraft.revision === submission.storedRevision
716
+ const sourceMatchesSubmission =
717
+ sourceDraft.revision &&
718
+ targetDraft.revision === sourceDraft.revision &&
719
+ targetDraft.text === submission.text
720
+ const submittedDraftWasUnstored =
721
+ !submission.storedRevision &&
722
+ !sourceDraft.revision &&
723
+ !targetDraft.revision
724
+ const submittedDraftWasMoved = targetMatchesStoredBaseline || sourceMatchesSubmission || submittedDraftWasUnstored
725
+ if (
726
+ sourceDraft.revision &&
727
+ sourceDraft.text === submission.text
728
+ ) {
729
+ submission.migratedSources.push({
730
+ key: String(sourceKey),
731
+ revision: sourceDraft.revision,
732
+ })
733
+ }
734
+ submission.key = String(targetKey)
735
+ submission.revisionKey = targetRevisionKey
736
+ submission.keyRevision = this._draftRevisions?.get(targetRevisionKey) || 0
737
+ submission.storedRevision = targetDraft.revision
738
+ submission.storedUpdatedAt = targetDraft.updatedAt
739
+ submission.storedChangedOutsideController ||=
740
+ !submittedDraftWasMoved
741
+ if (submission.backupKey && movedBackups.has(submission.backupKey)) {
742
+ submission.backupKey = movedBackups.get(submission.backupKey)
743
+ }
744
+ })
745
+ }
746
+
747
+ _trackPartialDraftMigration(sourceKey, targetKey, sourceDraft, targetDraft) {
748
+ if (
749
+ !sourceDraft.revision ||
750
+ targetDraft.revision !== sourceDraft.revision
751
+ ) return
752
+
753
+ const namespace = chatDrafts.namespace()
754
+ this._pendingDraftSubmissions?.forEach((submission) => {
755
+ if (
756
+ submission.namespace !== namespace ||
757
+ String(submission.key) !== String(sourceKey) ||
758
+ submission.hadStash ||
759
+ submission.hadReview ||
760
+ submission.editing ||
761
+ submission.storedRevision !== sourceDraft.revision
762
+ ) return
763
+
764
+ submission.migratedSources.push({
765
+ key: String(targetKey),
766
+ revision: targetDraft.revision,
767
+ })
768
+ })
769
+ }
770
+
771
+ _clearMigratedSubmittedSources(submission) {
772
+ submission.migratedSources.forEach(({ key, revision }) => {
773
+ if (chatDrafts.revision(key) !== revision) return
774
+
775
+ chatDrafts.clear(key)
776
+ this._observeDraft(key, null, submission.namespace)
777
+ })
778
+ }
779
+
780
+ _persistFailedSubmissionDraft(submission) {
781
+ if (
782
+ submission.invalidated ||
783
+ submission.hadStash ||
784
+ submission.hadReview ||
785
+ submission.editing ||
786
+ submission.namespace !== chatDrafts.namespace()
787
+ ) return
788
+
789
+ const currentDraft = chatDrafts.snapshot(submission.key)
790
+ const currentKeyRevision =
791
+ this._draftRevisions?.get(submission.revisionKey) || 0
792
+ const submittedChatAdvanced =
793
+ submission.storedChangedOutsideController ||
794
+ currentKeyRevision !== submission.keyRevision ||
795
+ currentDraft.revision !== submission.storedRevision ||
796
+ currentDraft.updatedAt !== submission.storedUpdatedAt
797
+ if (submittedChatAdvanced) return
798
+
799
+ submission.backupKey ||= chatDrafts.saveSubmissionBackup(
800
+ submission.key,
801
+ submission.text,
802
+ { updatedAt: submission.backupUpdatedAt },
803
+ )
804
+ }
805
+
232
806
  setSendingState(isSending) {
233
807
  if (!this.submitTarget) return
234
808
 
@@ -248,6 +822,7 @@ export default class extends Controller {
248
822
 
249
823
  handleSend(event) {
250
824
  event.preventDefault()
825
+ const commandSubmissionId = event.commandSubmissionId || null
251
826
 
252
827
  // If active quote exists, handle based on type
253
828
  const store = this._reviewStore
@@ -271,6 +846,19 @@ export default class extends Controller {
271
846
  this.sending = true
272
847
  this.setSendingState(true)
273
848
  this.presenceController?.stoppedTyping()
849
+ if (commandSubmissionId) {
850
+ this.element.dispatchEvent(new CustomEvent('comments--form:submit-started', {
851
+ detail: { submissionId: commandSubmissionId },
852
+ }))
853
+ }
854
+
855
+ // Cancel any pending input debounce before capturing the submission. A
856
+ // write while the request is in flight looks like a newer draft and can
857
+ // restore the already-sent text on success. Failures persist below.
858
+ if (this._draftSaveTimer) {
859
+ clearTimeout(this._draftSaveTimer)
860
+ this._draftSaveTimer = null
861
+ }
274
862
 
275
863
  // Build final content from review quotes + user text
276
864
  if (hasQuotes) {
@@ -281,6 +869,56 @@ export default class extends Controller {
281
869
 
282
870
  const wasPrivate = this.privateCheckboxTarget?.checked ?? false
283
871
 
872
+ // Captured after the review-quote rewrite above, so it is the exact content
873
+ // going to the server. _restoreStashedDraft compares against it to tell a
874
+ // failure that left the command text behind from text typed mid-flight.
875
+ const submittedText = this.textareaTarget.value
876
+ const initialSubmittedDraftKey = this._activeDraftKey
877
+ const submittedDraftNamespace = chatDrafts.namespace()
878
+ const initialSubmittedDraftRevisionKey =
879
+ `${submittedDraftNamespace}:${initialSubmittedDraftKey}`
880
+ const initialSubmittedDraft = chatDrafts.snapshot(initialSubmittedDraftKey)
881
+ const submittedHadStash = this._stashedDraftBelongsToCurrentCreative()
882
+ const submittedBackup = chatDrafts.latestSubmissionBackup(initialSubmittedDraftKey)
883
+ const submittedBackupUpdatedAt = Math.max(
884
+ Date.now(),
885
+ (initialSubmittedDraft.updatedAt || 0) + 1,
886
+ )
887
+ const observedSubmittedText =
888
+ this._observedDrafts?.get(initialSubmittedDraftRevisionKey)
889
+ const observedSubmittedStoredRevision =
890
+ this._observedStoredDraftRevisions?.get(initialSubmittedDraftRevisionKey) || null
891
+ const submittedTextChangedOutsideController =
892
+ observedSubmittedText !== initialSubmittedDraft.text
893
+ const submittedRevisionChangedOutsideController =
894
+ observedSubmittedStoredRevision !== initialSubmittedDraft.revision
895
+ const submittedStoredDraftChangedOutsideController =
896
+ this._observedDrafts?.has(initialSubmittedDraftRevisionKey) &&
897
+ (submittedTextChangedOutsideController || submittedRevisionChangedOutsideController)
898
+ const submittedDraft = {
899
+ key: initialSubmittedDraftKey,
900
+ namespace: submittedDraftNamespace,
901
+ revisionKey: initialSubmittedDraftRevisionKey,
902
+ storedChangedOutsideController: submittedStoredDraftChangedOutsideController,
903
+ keyRevision: this._draftRevisions?.get(initialSubmittedDraftRevisionKey) || 0,
904
+ storedRevision: initialSubmittedDraft.revision,
905
+ storedUpdatedAt: initialSubmittedDraft.updatedAt,
906
+ backupUpdatedAt: submittedBackupUpdatedAt,
907
+ text: submittedText,
908
+ hadStash: submittedHadStash,
909
+ backupKey: submittedBackup?.text === submittedText ? submittedBackup.key : null,
910
+ migratedSources: [],
911
+ }
912
+ this._pendingDraftSubmissions ||= new Set()
913
+ this._pendingDraftSubmissions.add(submittedDraft)
914
+ const submittedEditingId = this.editingId
915
+ const submittedHadReview = hasQuotes
916
+ submittedDraft.editing = Boolean(submittedEditingId)
917
+ submittedDraft.hadReview = submittedHadReview
918
+ if (submittedHadStash) {
919
+ this._stashedDraft.submittedText = submittedText
920
+ }
921
+
284
922
  const formData = new FormData(this.formTarget)
285
923
  const effectiveTopicId = this.currentTopicId || this._mainTopicId
286
924
  if (effectiveTopicId) {
@@ -293,11 +931,12 @@ export default class extends Controller {
293
931
 
294
932
  let url = `/creatives/${this.creativeId}/comments`
295
933
  let method = 'POST'
296
- if (this.editingId) {
297
- url += `/${this.editingId}`
934
+ if (submittedEditingId) {
935
+ url += `/${submittedEditingId}`
298
936
  method = 'PATCH'
299
937
  }
300
938
 
939
+ let settlementUi = Promise.resolve()
301
940
  const doFetch = () => fetch(url, {
302
941
  method,
303
942
  headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content },
@@ -323,12 +962,97 @@ export default class extends Controller {
323
962
  })
324
963
  })
325
964
  .then((html) => {
326
- const wasEditing = this.editingId
965
+ if (submittedDraft.backupKey) {
966
+ chatDrafts.clearSubmissionBackupsThrough(
967
+ submittedDraft.backupKey,
968
+ submittedDraft.namespace,
969
+ )
970
+ submittedDraft.backupKey = null
971
+ }
972
+ if (
973
+ submittedDraft.invalidated ||
974
+ submittedDraft.namespace !== chatDrafts.namespace()
975
+ ) return
976
+
977
+ const {
978
+ key: submittedDraftKey,
979
+ namespace: submittedDraftNamespace,
980
+ revisionKey: submittedDraftRevisionKey,
981
+ storedChangedOutsideController: submittedStoredDraftChangedOutsideController,
982
+ keyRevision: submittedDraftKeyRevision,
983
+ storedRevision: submittedDraftStoredRevision,
984
+ hadStash: submittedHadStash,
985
+ } = submittedDraft
986
+ const ownsSubmittedDraftNamespace =
987
+ !submittedDraft.invalidated &&
988
+ submittedDraftNamespace === chatDrafts.namespace()
989
+ const switchedChats =
990
+ ownsSubmittedDraftNamespace &&
991
+ submittedDraftKey &&
992
+ this._activeDraftKey &&
993
+ String(submittedDraftKey) !== String(this._activeDraftKey)
994
+ const submittedChatStillActive =
995
+ submittedDraftKey &&
996
+ this._activeDraftKey &&
997
+ String(submittedDraftKey) === String(this._activeDraftKey)
998
+ const hasNewerActiveDraft =
999
+ ownsSubmittedDraftNamespace &&
1000
+ !submittedEditingId &&
1001
+ !submittedHadReview &&
1002
+ submittedChatStillActive &&
1003
+ (this._draftRevisions?.get(submittedDraftRevisionKey) || 0) !==
1004
+ submittedDraftKeyRevision
1005
+ const hasNewerStoredDraft =
1006
+ ownsSubmittedDraftNamespace &&
1007
+ !submittedEditingId &&
1008
+ !submittedHadReview &&
1009
+ submittedDraftKey &&
1010
+ (
1011
+ (this._draftRevisions?.get(submittedDraftRevisionKey) || 0) !==
1012
+ submittedDraftKeyRevision ||
1013
+ submittedStoredDraftChangedOutsideController ||
1014
+ chatDrafts.revision(submittedDraftKey) !== submittedDraftStoredRevision
1015
+ )
1016
+ const hasNewerDraft = hasNewerActiveDraft || hasNewerStoredDraft
1017
+ const newerDraft = hasNewerActiveDraft
1018
+ ? (
1019
+ this._draftSaveSuspendedForPermission
1020
+ ? chatDrafts.get(submittedDraftKey)
1021
+ : this.textareaTarget.value
1022
+ )
1023
+ : null
1024
+ if (switchedChats) this._flushDraftSave()
1025
+ clearTimeout(this._draftSaveTimer)
1026
+ this._draftSaveTimer = null
327
1027
  this.resetForm()
328
- if (wasEditing) {
1028
+ if (submittedEditingId) {
1029
+ if (ownsSubmittedDraftNamespace) this._restoreDraft()
329
1030
  // If editing, just replace the item in place
330
1031
  this.renderCommentHtml(html, { replaceExisting: true })
331
1032
  } else {
1033
+ if (submittedHadReview && ownsSubmittedDraftNamespace) {
1034
+ this._restoreDraft()
1035
+ } else if (hasNewerActiveDraft) {
1036
+ this.textareaTarget.value = newerDraft
1037
+ this._autoResize()
1038
+ this._updateSubmitButton()
1039
+ chatDrafts.set(submittedDraftKey, newerDraft)
1040
+ this._observeDraft(submittedDraftKey, newerDraft, submittedDraftNamespace)
1041
+ } else if (hasNewerStoredDraft && submittedChatStillActive) {
1042
+ this._restoreDraft()
1043
+ } else if (
1044
+ !submittedHadStash &&
1045
+ !hasNewerDraft &&
1046
+ submittedDraftKey &&
1047
+ ownsSubmittedDraftNamespace
1048
+ ) {
1049
+ chatDrafts.clear(submittedDraftKey)
1050
+ this._observeDraft(submittedDraftKey, null, submittedDraftNamespace)
1051
+ }
1052
+ if (ownsSubmittedDraftNamespace && !submittedHadReview) {
1053
+ this._clearMigratedSubmittedSources(submittedDraft)
1054
+ }
1055
+ if (switchedChats && !submittedHadReview) this._restoreDraft()
332
1056
  // New Comment:
333
1057
  // 1. If we are in "History Mode" (scrolled up), sending a message should jump us to the latest.
334
1058
  // 2. Ideally, we just reload the "Latest" page to ensure sync and Live Mode.
@@ -363,6 +1087,8 @@ export default class extends Controller {
363
1087
  }
364
1088
  })
365
1089
  .catch((error) => {
1090
+ if (submittedDraft.invalidated) return
1091
+
366
1092
  // Restore review quotes state on failure so user doesn't lose work
367
1093
  if (this._reviewStore.hasBackup()) {
368
1094
  const restoredText = this._reviewStore.restore()
@@ -370,12 +1096,28 @@ export default class extends Controller {
370
1096
  this._renderReviewQuoteChips()
371
1097
  this._updateSubmitButton()
372
1098
  }
373
- alertDialog(error?.message || 'Failed to submit comment')
1099
+ this._persistFailedSubmissionDraft(submittedDraft)
1100
+ settlementUi = alertDialog(error?.message || 'Failed to submit comment')
374
1101
  })
375
1102
  .finally(() => {
1103
+ this._pendingDraftSubmissions.delete(submittedDraft)
376
1104
  inFlightSends.delete(sendKey)
377
1105
  this._hasRetried = false
378
1106
  this.setSendingState(false)
1107
+ if (
1108
+ !submittedDraft.invalidated &&
1109
+ submittedDraft.namespace === chatDrafts.namespace()
1110
+ ) {
1111
+ this._restoreStashedDraft(submittedText)
1112
+ } else {
1113
+ this._stashedDraft = null
1114
+ }
1115
+ const announceSettlement = () => {
1116
+ this.element.dispatchEvent(new CustomEvent('comments--form:submit-settled', {
1117
+ detail: { submissionId: commandSubmissionId },
1118
+ }))
1119
+ }
1120
+ settlementUi.then(announceSettlement, announceSettlement)
379
1121
  })
380
1122
  }
381
1123
 
@@ -393,6 +1135,7 @@ export default class extends Controller {
393
1135
  handleCancel(event) {
394
1136
  event.preventDefault()
395
1137
  this.resetForm()
1138
+ this._restoreDraft()
396
1139
  }
397
1140
 
398
1141
  handleSearch(event) {
@@ -729,6 +1472,7 @@ export default class extends Controller {
729
1472
  if (!selectedText) return
730
1473
 
731
1474
  const store = this._reviewStore
1475
+ if (store.isEmpty) this._flushDraftSave()
732
1476
  store.saveActiveFeedback(this.textareaTarget.value)
733
1477
 
734
1478
  if (commentId) {
@@ -767,7 +1511,7 @@ export default class extends Controller {
767
1511
  this.textareaTarget.value = ''
768
1512
 
769
1513
  if (store.isEmpty) {
770
- this.textareaTarget.placeholder = ''
1514
+ this._restoreOrdinaryDraft()
771
1515
  } else if (!store.hasActive) {
772
1516
  this.textareaTarget.placeholder = this._getI18nText('reviewSummaryPlaceholder', 'Overall comment (optional)...')
773
1517
  }
@@ -884,6 +1628,9 @@ export default class extends Controller {
884
1628
  if (quote.id === store.activeId) {
885
1629
  const commentEl = document.querySelector(`[data-comment-id="${quote.commentId}"]`)
886
1630
  if (commentEl) {
1631
+ // Programmatic list scroll — drop the prev-message anchor so the next
1632
+ // previous-message click resolves from the quoted comment now in view.
1633
+ this.listController?.notifyProgrammaticScroll()
887
1634
  commentEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
888
1635
  commentEl.classList.add('comment-highlight')
889
1636
  setTimeout(() => commentEl.classList.remove('comment-highlight'), 2000)
@@ -921,7 +1668,7 @@ export default class extends Controller {
921
1668
  store.remove(quote.id)
922
1669
  if (wasActive) this.textareaTarget.value = ''
923
1670
  if (store.isEmpty) {
924
- this.textareaTarget.placeholder = ''
1671
+ this._restoreOrdinaryDraft()
925
1672
  } else if (!store.hasActive) {
926
1673
  this.textareaTarget.placeholder = this._getI18nText('reviewSummaryPlaceholder', 'Overall comment (optional)...')
927
1674
  }
@@ -956,10 +1703,20 @@ export default class extends Controller {
956
1703
  this.submitTarget.textContent = labels[state]
957
1704
  }
958
1705
 
1706
+ _restoreOrdinaryDraft() {
1707
+ this.textareaTarget.value = ''
1708
+ this.textareaTarget.placeholder = this._defaultPlaceholder()
1709
+ this._restoreDraft()
1710
+ }
1711
+
959
1712
  _getI18nText(key, fallback) {
960
1713
  return this.element.dataset[key] || fallback
961
1714
  }
962
1715
 
1716
+ _defaultPlaceholder() {
1717
+ return this._getI18nText('chatInputHint', 'Type a message, or use / for commands')
1718
+ }
1719
+
963
1720
  // --- Inbox inline reply mode ---
964
1721
 
965
1722
  get _isInboxSystemTopic() {
@@ -1035,7 +1792,7 @@ export default class extends Controller {
1035
1792
  this._reviewStore.clear()
1036
1793
  this._renderReviewQuoteChips()
1037
1794
  this._updateSubmitButton()
1038
- this.textareaTarget.placeholder = ''
1795
+ this.textareaTarget.placeholder = this._defaultPlaceholder()
1039
1796
  // Re-apply inbox reply mode indicator if we're in inbox System topic
1040
1797
  if (this._isInboxSystemTopic) {
1041
1798
  requestAnimationFrame(() => this._updateInboxReplyMode())
@@ -1051,6 +1808,20 @@ export default class extends Controller {
1051
1808
  const commentElement = doc.querySelector('.comment-item')
1052
1809
  if (!commentElement) return
1053
1810
 
1811
+ // A search-filtered list is the result set of a server-side query, and a
1812
+ // freshly posted comment carries no verdict on whether it matches. Splicing
1813
+ // it in drops an unrelated message among the results and — when there were
1814
+ // none — takes the "no results" notice with it, since removePlaceholder
1815
+ // below clears whatever empty state is on screen. Leave search and reload
1816
+ // the live list instead, the same exit the history-mode branch takes.
1817
+ // Edits are exempt: that comment is already in the result set, matching the
1818
+ // stream path, which blocks `append` but never `replace`/`remove`.
1819
+ const listCtrl = this.listController
1820
+ if (!replaceExisting && listCtrl?.manualSearchQuery) {
1821
+ listCtrl.resetToLatest()
1822
+ return
1823
+ }
1824
+
1054
1825
  this.removePlaceholder()
1055
1826
 
1056
1827
  const existing = listElement.querySelector(`#${commentElement.id}`)
@@ -1060,6 +1831,14 @@ export default class extends Controller {
1060
1831
  listElement.appendChild(commentElement)
1061
1832
  }
1062
1833
 
1834
+ // The submit response can win the race against its Turbo Stream echo. Keep
1835
+ // the All Messages read snapshot in step with this visible local append;
1836
+ // the stream then deduplicates without getting a second chance to do it.
1837
+ const addedTopic = listCtrl?.recordRenderedAllTopicWatermarks(
1838
+ commentElement,
1839
+ { includeNewTopics: !replaceExisting },
1840
+ )
1841
+ if (addedTopic) listCtrl?.reportRenderedAllTopics()
1063
1842
  renderMarkdownInContainer(commentElement)
1064
1843
  if (replaceExisting) {
1065
1844
  this.listController?.markCommentsRead()
@@ -1068,7 +1847,9 @@ export default class extends Controller {
1068
1847
 
1069
1848
  removePlaceholder() {
1070
1849
  const listElement = document.getElementById('comments-list')
1071
- const placeholder = listElement?.querySelector('#no-comments')
1072
- if (placeholder) placeholder.remove()
1850
+ // Every empty-list state carries .comments-placeholder: the discovery
1851
+ // cards (#no-comments) and the no-search-results notice. Either can be on
1852
+ // screen when the user posts.
1853
+ listElement?.querySelectorAll('.comments-placeholder').forEach((el) => el.remove())
1073
1854
  }
1074
1855
  }