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
@@ -0,0 +1,324 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import { Application } from '@hotwired/stimulus'
7
+ import FormController from '../form_controller'
8
+ import chatDrafts from '../../../lib/chat_drafts'
9
+
10
+ describe('FormController - IME composition send', () => {
11
+ let application
12
+ let container
13
+ let controller
14
+ let popupEl
15
+
16
+ const FIXTURE = `
17
+ <div id="comments-popup"
18
+ data-controller="comments--form"
19
+ data-update-comment-text="Update">
20
+ <form data-comments--form-target="form">
21
+ <input type="hidden" name="comment[quoted_comment_id]" data-comments--form-target="quotedCommentId" value="" />
22
+ <input type="hidden" name="comment[quoted_text]" data-comments--form-target="quotedText" value="" />
23
+ <div data-comments--form-target="quoteIndicator" style="display:none;">
24
+ <span data-comments--form-target="quoteIndicatorText"></span>
25
+ </div>
26
+ <div class="review-quotes-container" data-comments--form-target="reviewQuotesContainer" style="display:none;"></div>
27
+ <textarea data-comments--form-target="textarea" rows="2"></textarea>
28
+ <input type="checkbox" data-comments--form-target="privateCheckbox" />
29
+ <button type="submit" data-comments--form-target="submit">Send</button>
30
+ <button data-comments--form-target="cancel" style="display:none;">Cancel</button>
31
+ <button data-comments--form-target="searchButton" style="display:none;">Search</button>
32
+ <button data-comments--form-target="voiceButton" style="display:none;">Voice</button>
33
+ <input type="file" data-comments--form-target="imageInput" style="display:none;" />
34
+ <button data-comments--form-target="imageButton" style="display:none;">Image</button>
35
+ <div data-comments--form-target="attachmentList"></div>
36
+ </form>
37
+ </div>`
38
+
39
+ // Stimulus disconnects a removed controller asynchronously, so a previous
40
+ // test's pending draft flush can land inside a later test's timer wait.
41
+ // Giving each test its own chat id keeps those flushes off our key.
42
+ let chatId
43
+ let nextChatId = 77
44
+
45
+ const dispatchTopicChange = (effectiveCreativeId = chatId, topicId = '10073') => {
46
+ popupEl.dataset.effectiveCreativeId = String(effectiveCreativeId)
47
+ popupEl.dispatchEvent(
48
+ new CustomEvent('comments--topics:change', { detail: { topicId } }),
49
+ )
50
+ }
51
+
52
+ const typeInto = (textarea, value) => {
53
+ textarea.value = value
54
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
55
+ }
56
+
57
+ // A real IME commit ends the composition and then fires `input` for the
58
+ // committed text. jsdom has no IME, so replay that exact pair.
59
+ const commitComposition = (textarea, value = textarea.value) => {
60
+ textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: value }))
61
+ textarea.value = value
62
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
63
+ }
64
+
65
+ beforeEach(async () => {
66
+ window.localStorage.clear()
67
+ window.sessionStorage.clear()
68
+ document.body.dataset.currentUserId = '9'
69
+
70
+ if (typeof global.requestAnimationFrame !== 'function') {
71
+ global.requestAnimationFrame = (cb) => setTimeout(cb, 0)
72
+ }
73
+ const meta = document.createElement('meta')
74
+ meta.name = 'csrf-token'
75
+ meta.content = 'test-token'
76
+ document.head.appendChild(meta)
77
+
78
+ container = document.createElement('div')
79
+ container.innerHTML = FIXTURE
80
+ document.body.appendChild(container)
81
+
82
+ application = Application.start()
83
+ application.register('comments--form', FormController)
84
+ await new Promise((resolve) => setTimeout(resolve, 50))
85
+ popupEl = container.querySelector('#comments-popup')
86
+ controller = application.getControllerForElementAndIdentifier(popupEl, 'comments--form')
87
+ jest.spyOn(controller, 'setImageFiles').mockImplementation(() => {})
88
+ chatId = String(nextChatId)
89
+ nextChatId += 1
90
+ controller.creativeId = chatId
91
+ })
92
+
93
+ afterEach(() => {
94
+ application.stop()
95
+ document.body.removeChild(container)
96
+ document.head.querySelector('meta[name="csrf-token"]').remove()
97
+ delete document.body.dataset.currentUserId
98
+ jest.restoreAllMocks()
99
+ })
100
+
101
+ test('Enter pressed while an IME composition is active does not send', async () => {
102
+ dispatchTopicChange()
103
+ global.fetch = jest.fn(() => Promise.resolve({
104
+ ok: true,
105
+ status: 200,
106
+ text: () => Promise.resolve('<div></div>'),
107
+ }))
108
+ typeInto(controller.textareaTarget, '안녕하세')
109
+
110
+ controller.textareaTarget.dispatchEvent(new KeyboardEvent('keydown', {
111
+ key: 'Enter',
112
+ isComposing: true,
113
+ bubbles: true,
114
+ cancelable: true,
115
+ }))
116
+ await new Promise((resolve) => setTimeout(resolve, 20))
117
+
118
+ expect(global.fetch).not.toHaveBeenCalled()
119
+ expect(controller.textareaTarget.value).toBe('안녕하세')
120
+ })
121
+
122
+ test('an IME commit after send does not restore the sent text into the textarea', async () => {
123
+ dispatchTopicChange()
124
+ let finishFetch
125
+ global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
126
+
127
+ typeInto(controller.textareaTarget, '안녕하세요')
128
+ controller.handleSend(new Event('submit', { cancelable: true }))
129
+
130
+ // The IME commits the pending syllable right after keydown started the
131
+ // send. The textarea content is unchanged, but an input event still fires.
132
+ commitComposition(controller.textareaTarget)
133
+
134
+ finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
135
+ await new Promise((resolve) => setTimeout(resolve, 20))
136
+
137
+ expect(controller.textareaTarget.value).toBe('')
138
+ expect(chatDrafts.get(chatId)).toBeNull()
139
+ })
140
+
141
+ test('a slow send does not let the IME commit debounce the sent text into storage', async () => {
142
+ dispatchTopicChange()
143
+ let finishFetch
144
+ global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
145
+
146
+ typeInto(controller.textareaTarget, '안녕하세요')
147
+ controller.handleSend(new Event('submit', { cancelable: true }))
148
+ commitComposition(controller.textareaTarget)
149
+
150
+ // The response outlives the 500ms draft debounce window.
151
+ await new Promise((resolve) => setTimeout(resolve, 600))
152
+ expect(chatDrafts.get(chatId)).toBeNull()
153
+
154
+ finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
155
+ await new Promise((resolve) => setTimeout(resolve, 20))
156
+
157
+ expect(controller.textareaTarget.value).toBe('')
158
+ expect(chatDrafts.get(chatId)).toBeNull()
159
+ })
160
+
161
+ test('Enter after the composition ends still sends', async () => {
162
+ dispatchTopicChange()
163
+ global.fetch = jest.fn(() => Promise.resolve({
164
+ ok: true,
165
+ status: 200,
166
+ text: () => Promise.resolve('<div></div>'),
167
+ }))
168
+ typeInto(controller.textareaTarget, '안녕하세요')
169
+
170
+ controller.textareaTarget.dispatchEvent(new KeyboardEvent('keydown', {
171
+ key: 'Enter',
172
+ isComposing: false,
173
+ bubbles: true,
174
+ cancelable: true,
175
+ }))
176
+ await new Promise((resolve) => setTimeout(resolve, 20))
177
+
178
+ expect(global.fetch).toHaveBeenCalled()
179
+ expect(controller.textareaTarget.value).toBe('')
180
+ })
181
+
182
+ test('re-entering the same text during the send is preserved, not treated as an IME commit', async () => {
183
+ dispatchTopicChange()
184
+ let finishFetch
185
+ global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
186
+
187
+ typeInto(controller.textareaTarget, 'send me twice')
188
+ controller.handleSend(new Event('submit', { cancelable: true }))
189
+
190
+ // Select-all + paste of the identical string to send it again. No
191
+ // composition is involved, so this is a genuine edit even though the
192
+ // value matches the in-flight submission.
193
+ typeInto(controller.textareaTarget, 'send me twice')
194
+
195
+ finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
196
+ await new Promise((resolve) => setTimeout(resolve, 20))
197
+
198
+ expect(controller.textareaTarget.value).toBe('send me twice')
199
+ expect(chatDrafts.get(chatId)).toBe('send me twice')
200
+ })
201
+
202
+ test('an input event still carrying isComposing is treated as an IME commit', async () => {
203
+ dispatchTopicChange()
204
+ let finishFetch
205
+ global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
206
+
207
+ typeInto(controller.textareaTarget, '안녕하세요')
208
+ controller.handleSend(new Event('submit', { cancelable: true }))
209
+
210
+ // Some browsers fire the commit `input` before `compositionend`, with
211
+ // isComposing still set on the input event itself.
212
+ controller.textareaTarget.dispatchEvent(
213
+ new InputEvent('input', { bubbles: true, isComposing: true }),
214
+ )
215
+
216
+ finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
217
+ await new Promise((resolve) => setTimeout(resolve, 20))
218
+
219
+ expect(controller.textareaTarget.value).toBe('')
220
+ expect(chatDrafts.get(chatId)).toBeNull()
221
+ })
222
+
223
+ test.each(['paste', 'drop'])('a compositionend that trails the commit input does not swallow a later %s', async (gesture) => {
224
+ dispatchTopicChange()
225
+ let finishFetch
226
+ global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
227
+
228
+ typeInto(controller.textareaTarget, 'send me twice')
229
+ controller.handleSend(new Event('submit', { cancelable: true }))
230
+
231
+ // Input-before-compositionend ordering: the commit input already identified
232
+ // itself, so the trailing compositionend has no input left to pair with.
233
+ controller.textareaTarget.dispatchEvent(
234
+ new InputEvent('input', { bubbles: true, isComposing: true }),
235
+ )
236
+ controller.textareaTarget.dispatchEvent(
237
+ new CompositionEvent('compositionend', { bubbles: true, data: 'send me twice' }),
238
+ )
239
+
240
+ // Select-all + paste (or drag-drop) of the identical string to send it again.
241
+ controller.textareaTarget.dispatchEvent(new Event(gesture, { bubbles: true }))
242
+ typeInto(controller.textareaTarget, 'send me twice')
243
+
244
+ finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
245
+ await new Promise((resolve) => setTimeout(resolve, 20))
246
+
247
+ expect(controller.textareaTarget.value).toBe('send me twice')
248
+ expect(chatDrafts.get(chatId)).toBe('send me twice')
249
+ })
250
+
251
+ test('a compositionend that trails the commit input does not swallow later typing', async () => {
252
+ dispatchTopicChange()
253
+ let finishFetch
254
+ global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
255
+
256
+ typeInto(controller.textareaTarget, 'send me twice')
257
+ controller.handleSend(new Event('submit', { cancelable: true }))
258
+
259
+ controller.textareaTarget.dispatchEvent(
260
+ new InputEvent('input', { bubbles: true, isComposing: true }),
261
+ )
262
+ controller.textareaTarget.dispatchEvent(
263
+ new CompositionEvent('compositionend', { bubbles: true, data: 'send me twice' }),
264
+ )
265
+
266
+ // Retyping the same string by hand — an ordinary keystroke, not a commit.
267
+ controller.textareaTarget.dispatchEvent(new KeyboardEvent('keydown', {
268
+ key: 'e',
269
+ isComposing: false,
270
+ bubbles: true,
271
+ cancelable: true,
272
+ }))
273
+ typeInto(controller.textareaTarget, 'send me twice')
274
+
275
+ finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
276
+ await new Promise((resolve) => setTimeout(resolve, 20))
277
+
278
+ expect(controller.textareaTarget.value).toBe('send me twice')
279
+ expect(chatDrafts.get(chatId)).toBe('send me twice')
280
+ })
281
+
282
+ test('a composing keydown between compositionend and the commit input keeps the latch', async () => {
283
+ dispatchTopicChange()
284
+ let finishFetch
285
+ global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
286
+
287
+ typeInto(controller.textareaTarget, '안녕하세요')
288
+ controller.handleSend(new Event('submit', { cancelable: true }))
289
+
290
+ controller.textareaTarget.dispatchEvent(
291
+ new CompositionEvent('compositionend', { bubbles: true, data: '안녕하세요' }),
292
+ )
293
+ // Soft keyboards can interleave the next composing keystroke before the
294
+ // commit input lands; that is not a fresh gesture, so it must not expire.
295
+ controller.textareaTarget.dispatchEvent(new KeyboardEvent('keydown', {
296
+ keyCode: 229,
297
+ bubbles: true,
298
+ cancelable: true,
299
+ }))
300
+ controller.textareaTarget.dispatchEvent(new Event('input', { bubbles: true }))
301
+
302
+ finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
303
+ await new Promise((resolve) => setTimeout(resolve, 20))
304
+
305
+ expect(controller.textareaTarget.value).toBe('')
306
+ expect(chatDrafts.get(chatId)).toBeNull()
307
+ })
308
+
309
+ test('text genuinely typed during the send is still preserved', async () => {
310
+ dispatchTopicChange()
311
+ let finishFetch
312
+ global.fetch = jest.fn(() => new Promise((resolve) => { finishFetch = resolve }))
313
+
314
+ typeInto(controller.textareaTarget, 'first message')
315
+ controller.handleSend(new Event('submit', { cancelable: true }))
316
+ typeInto(controller.textareaTarget, 'typed while sending')
317
+
318
+ finishFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
319
+ await new Promise((resolve) => setTimeout(resolve, 20))
320
+
321
+ expect(controller.textareaTarget.value).toBe('typed while sending')
322
+ expect(chatDrafts.get(chatId)).toBe('typed while sending')
323
+ })
324
+ })
@@ -282,7 +282,7 @@ describe('FormController - Review Quote Chips', () => {
282
282
 
283
283
  expect(getQuotes(controller)).toHaveLength(0)
284
284
  expect(getActiveId(controller)).toBeNull()
285
- expect(controller.textareaTarget.placeholder).toBe('')
285
+ expect(controller.textareaTarget.placeholder).toBe('Type a message, or use / for commands')
286
286
  expect(container.querySelector('.review-quotes-container').style.display).toBe('none')
287
287
  })
288
288
 
@@ -306,7 +306,7 @@ describe('FormController - Review Quote Chips', () => {
306
306
 
307
307
  expect(getQuotes(controller)).toHaveLength(0)
308
308
  expect(getActiveId(controller)).toBeNull()
309
- expect(controller.textareaTarget.placeholder).toBe('')
309
+ expect(controller.textareaTarget.placeholder).toBe('Type a message, or use / for commands')
310
310
  })
311
311
  })
312
312
 
@@ -343,4 +343,30 @@ describe('FormController - Review Quote Chips', () => {
343
343
  expect(controller.textareaTarget.value).toBe('fb2')
344
344
  })
345
345
  })
346
+
347
+ describe('active chip re-click - programmatic scroll', () => {
348
+ // Re-clicking the active chip scrolls its quoted comment into view. That is a
349
+ // programmatic list scroll, so it must drop the prev-message anchor the same way
350
+ // scrollToBottom/highlightComment do — otherwise a stale anchor inside the corridor
351
+ // survives and the next previous-message click skips the comment now in view.
352
+ test('re-clicking the active chip notifies the list of a programmatic scroll', () => {
353
+ const commentEl = document.createElement('div')
354
+ commentEl.setAttribute('data-comment-id', '1')
355
+ commentEl.scrollIntoView = jest.fn() // jsdom has no scrollIntoView
356
+ document.body.appendChild(commentEl)
357
+
358
+ const notifyProgrammaticScroll = jest.fn()
359
+ jest
360
+ .spyOn(controller, 'listController', 'get')
361
+ .mockReturnValue({ notifyProgrammaticScroll })
362
+
363
+ controller.appendReviewQuote(1, 'first') // freshly appended quote is active
364
+ container.querySelector('.review-quote-chip-text').click()
365
+
366
+ expect(commentEl.scrollIntoView).toHaveBeenCalled()
367
+ expect(notifyProgrammaticScroll).toHaveBeenCalled()
368
+
369
+ commentEl.remove()
370
+ })
371
+ })
346
372
  })
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import CommentsFormController from '../form_controller'
7
+
8
+ const COMMENT_HTML =
9
+ '<div id="comment_9" class="comment-item" data-comment-id="9">hello team</div>'
10
+
11
+ // renderCommentHtml reaches for #comments-list in the document and for the
12
+ // sibling list controller through the `listController` getter. Stub both.
13
+ const buildController = ({ manualSearchQuery = null } = {}) => {
14
+ document.body.innerHTML = `
15
+ <div id="comments-list">
16
+ <div id="no-search-results" class="comments-placeholder">no results</div>
17
+ </div>
18
+ `
19
+
20
+ const listCtrl = {
21
+ manualSearchQuery,
22
+ resetToLatest: jest.fn(),
23
+ markCommentsRead: jest.fn(),
24
+ recordRenderedAllTopicWatermarks: jest.fn(),
25
+ reportRenderedAllTopics: jest.fn(),
26
+ }
27
+
28
+ const controller = Object.create(CommentsFormController.prototype)
29
+ Object.defineProperty(controller, 'listController', { get: () => listCtrl })
30
+
31
+ return { controller, listCtrl }
32
+ }
33
+
34
+ const listItems = () =>
35
+ document.getElementById('comments-list').querySelectorAll('.comment-item')
36
+
37
+ describe('CommentsFormController local submit during search', () => {
38
+ test('leaves search and reloads rather than appending into the results', () => {
39
+ const { controller, listCtrl } = buildController({ manualSearchQuery: 'invoice' })
40
+
41
+ controller.renderCommentHtml(COMMENT_HTML)
42
+
43
+ // The posted comment carries no verdict on whether it matches the query,
44
+ // so it must not be spliced into the result set.
45
+ expect(listItems()).toHaveLength(0)
46
+ expect(listCtrl.resetToLatest).toHaveBeenCalledTimes(1)
47
+ })
48
+
49
+ test('keeps the no-results notice instead of clearing it on submit', () => {
50
+ const { controller } = buildController({ manualSearchQuery: 'invoice' })
51
+
52
+ controller.renderCommentHtml(COMMENT_HTML)
53
+
54
+ // removePlaceholder() clears any .comments-placeholder, so reaching it
55
+ // would strip the explanation and leave an unrelated message alone on
56
+ // screen. resetToLatest() re-renders the list from the server instead.
57
+ expect(document.getElementById('no-search-results')).not.toBeNull()
58
+ })
59
+
60
+ test('appends normally when no search is active', () => {
61
+ const { controller, listCtrl } = buildController({ manualSearchQuery: null })
62
+
63
+ controller.renderCommentHtml(COMMENT_HTML)
64
+
65
+ expect(listItems()).toHaveLength(1)
66
+ expect(listCtrl.resetToLatest).not.toHaveBeenCalled()
67
+ expect(listCtrl.recordRenderedAllTopicWatermarks).toHaveBeenCalledWith(
68
+ document.getElementById('comment_9'),
69
+ { includeNewTopics: true },
70
+ )
71
+ // The placeholder is stale once a real comment lands.
72
+ expect(document.getElementById('no-search-results')).toBeNull()
73
+ })
74
+
75
+ test('extends and reports the All Messages snapshot for a locally appended topic', () => {
76
+ const { controller, listCtrl } = buildController({ manualSearchQuery: null })
77
+ listCtrl.recordRenderedAllTopicWatermarks.mockReturnValue(true)
78
+
79
+ controller.renderCommentHtml(
80
+ '<div id="comment_10" class="comment-item" data-comment-id="10" data-topic-id="2">new topic</div>',
81
+ )
82
+
83
+ expect(listCtrl.recordRenderedAllTopicWatermarks).toHaveBeenCalledWith(
84
+ document.getElementById('comment_10'),
85
+ { includeNewTopics: true },
86
+ )
87
+ expect(listCtrl.reportRenderedAllTopics).toHaveBeenCalledTimes(1)
88
+ })
89
+
90
+ test('still replaces an edited comment in place during a search', () => {
91
+ // Mirrors the stream path, which blocks `append` but never `replace` /
92
+ // `remove`: the edited comment is already part of the result set, so
93
+ // dropping the update would leave stale text on screen.
94
+ const { controller, listCtrl } = buildController({ manualSearchQuery: 'invoice' })
95
+ document.getElementById('comments-list').insertAdjacentHTML(
96
+ 'beforeend',
97
+ '<div id="comment_9" class="comment-item" data-comment-id="9">old text</div>',
98
+ )
99
+
100
+ controller.renderCommentHtml(COMMENT_HTML, { replaceExisting: true })
101
+
102
+ expect(listCtrl.resetToLatest).not.toHaveBeenCalled()
103
+ expect(document.getElementById('comment_9').textContent).toBe('hello team')
104
+ expect(listItems()).toHaveLength(1)
105
+ })
106
+ })
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import CommentsFormController from '../form_controller'
7
+
8
+ // A slash command with an input schema takes over the textarea and submits
9
+ // itself (modules/command_menu.js `argsForm.onSubmit`), so a draft the user had
10
+ // already typed cannot go out with it. The menu hands the draft over on a
11
+ // `comments--form:stash-draft` event and the controller puts it back once the
12
+ // send settles.
13
+ const buildController = () => {
14
+ document.body.innerHTML = '<div id="comments-popup"><textarea></textarea></div>'
15
+ const textarea = document.querySelector('textarea')
16
+
17
+ const controller = Object.create(CommentsFormController.prototype)
18
+ Object.defineProperty(controller, 'textareaTarget', { get: () => textarea })
19
+ controller._autoResize = jest.fn()
20
+ controller._updateSubmitButton = jest.fn()
21
+ controller.creativeId = '7'
22
+
23
+ return { controller, textarea }
24
+ }
25
+
26
+ // The stash is tagged with the creative it was typed in, so tests build it the
27
+ // way handleStashDraft does rather than assigning a bare string.
28
+ const stash = (controller, draft) => {
29
+ controller._stashedDraft = { draft, creativeId: controller.creativeId }
30
+ }
31
+
32
+ describe('CommentsFormController stashed draft', () => {
33
+ test('stashes the draft carried by the event', () => {
34
+ const { controller } = buildController()
35
+
36
+ controller.handleStashDraft(
37
+ new CustomEvent('comments--form:stash-draft', { detail: { draft: 'roadmap notes' } }),
38
+ )
39
+
40
+ expect(controller._stashedDraft).toEqual({ draft: 'roadmap notes', creativeId: '7' })
41
+ })
42
+
43
+ test('receives the event dispatched on the textarea, which bubbles to the popup', () => {
44
+ // command_menu.js dispatches on `#new-comment-form textarea`, and the
45
+ // comments--form controller element is `#comments-popup`, which wraps the
46
+ // form (_comments_popup.html.erb). The stash only arrives if it bubbles.
47
+ const { controller, textarea } = buildController()
48
+ const element = document.getElementById('comments-popup')
49
+ element.addEventListener(
50
+ 'comments--form:stash-draft',
51
+ controller.handleStashDraft.bind(controller),
52
+ )
53
+
54
+ textarea.dispatchEvent(
55
+ new CustomEvent('comments--form:stash-draft', {
56
+ bubbles: true,
57
+ detail: { draft: 'roadmap notes' },
58
+ }),
59
+ )
60
+
61
+ expect(controller._stashedDraft).toEqual({ draft: 'roadmap notes', creativeId: '7' })
62
+ })
63
+
64
+ test('restores the draft into the textarea the send cleared', () => {
65
+ const { controller, textarea } = buildController()
66
+ stash(controller, 'roadmap notes')
67
+
68
+ // The success path runs resetForm() before .finally(), so the box is empty.
69
+ textarea.value = ''
70
+ controller._restoreStashedDraft()
71
+
72
+ expect(textarea.value).toBe('roadmap notes')
73
+ expect(controller._autoResize).toHaveBeenCalledTimes(1)
74
+ expect(controller._updateSubmitButton).toHaveBeenCalledTimes(1)
75
+ })
76
+
77
+ test('does not clobber text the user typed while the command was in flight', () => {
78
+ const { controller, textarea } = buildController()
79
+ stash(controller, 'roadmap notes')
80
+
81
+ textarea.value = 'something else entirely'
82
+ controller._restoreStashedDraft('/calendar 2026-08-14')
83
+
84
+ expect(textarea.value).toBe('something else entirely')
85
+ })
86
+
87
+ test('restores over the command text a failed send left in the box', () => {
88
+ // The failure path never runs resetForm(), so the submitted command is
89
+ // still sitting there. Matching it proves the user has not typed since,
90
+ // which makes the box safe to hand the draft back into.
91
+ const { controller, textarea } = buildController()
92
+ stash(controller, 'roadmap notes')
93
+
94
+ textarea.value = '/calendar 2026-08-14'
95
+ controller._restoreStashedDraft('/calendar 2026-08-14')
96
+
97
+ expect(textarea.value).toBe('roadmap notes')
98
+ expect(controller._autoResize).toHaveBeenCalledTimes(1)
99
+ expect(controller._updateSubmitButton).toHaveBeenCalledTimes(1)
100
+ })
101
+
102
+ test('does not clobber a review quote the failure path restored', () => {
103
+ // handleSend's .catch reinstates the backed-up quote text before .finally,
104
+ // so the box holds something other than what was submitted.
105
+ const { controller, textarea } = buildController()
106
+ stash(controller, 'roadmap notes')
107
+
108
+ textarea.value = '> quoted review text\n\nmy reply'
109
+ controller._restoreStashedDraft('/calendar 2026-08-14')
110
+
111
+ expect(textarea.value).toBe('> quoted review text\n\nmy reply')
112
+ })
113
+
114
+ test('consumes the stash so a later send does not resurrect it', () => {
115
+ const { controller, textarea } = buildController()
116
+ stash(controller, 'roadmap notes')
117
+
118
+ controller._restoreStashedDraft()
119
+ expect(controller._stashedDraft).toBeNull()
120
+
121
+ textarea.value = ''
122
+ controller._restoreStashedDraft()
123
+ expect(textarea.value).toBe('')
124
+ })
125
+
126
+ test('is a no-op for an ordinary send with nothing stashed', () => {
127
+ const { controller, textarea } = buildController()
128
+
129
+ controller._restoreStashedDraft()
130
+
131
+ expect(textarea.value).toBe('')
132
+ expect(controller._autoResize).not.toHaveBeenCalled()
133
+ })
134
+ })