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,100 @@
1
+ // Shared navigation for the creative list filters (GNB chat button and the
2
+ // search popup).
3
+ //
4
+ // Two things these filters have in common:
5
+ //
6
+ // 1. Their parameters (comment, min_progress, search, show_archived,
7
+ // search_mode) are only consumed by Creatives::IndexQuery, so a toggle
8
+ // pressed anywhere else has to land on the creative index rather than
9
+ // decorate the current path with a parameter nothing reads.
10
+ // 2. In workspace mode the list is rendered inside the
11
+ // #creative-workspace-content turbo-frame, so only that frame needs to be
12
+ // replaced. Assigning window.location.href bypasses Turbo entirely and
13
+ // rebuilds the whole page — assets, workspace tree and docked chat
14
+ // included.
15
+
16
+ export const WORKSPACE_FRAME_ID = 'creative-workspace-content'
17
+
18
+ // The creative index is reachable at both "/" (root route) and "/creatives",
19
+ // so `onIndex` has to come from the server; a pathname comparison would get
20
+ // the root route wrong.
21
+ export function buildFilterUrl({ indexPath, onIndex }, mutate) {
22
+ const url = onIndex
23
+ ? new URL(window.location.href)
24
+ : new URL(indexPath || '/', window.location.origin)
25
+
26
+ mutate(url.searchParams)
27
+ return url
28
+ }
29
+
30
+ // Shared by the GNB component and the search popup's progress/comment buttons.
31
+ export function applyFilterParam(params, filter) {
32
+ if (filter === 'comment') {
33
+ if (params.get('comment') === 'true') {
34
+ params.delete('comment')
35
+ } else {
36
+ params.set('comment', 'true')
37
+ }
38
+ return
39
+ }
40
+
41
+ params.delete('min_progress')
42
+ params.delete('max_progress')
43
+
44
+ if (filter === 'complete') {
45
+ params.set('min_progress', '1')
46
+ params.set('max_progress', '1')
47
+ } else if (filter === 'incomplete') {
48
+ params.set('min_progress', '0')
49
+ params.set('max_progress', '0.99')
50
+ }
51
+ }
52
+
53
+ // Returns true when only the workspace frame was replaced, meaning the GNB
54
+ // survived the navigation and its server-rendered state is now stale.
55
+ //
56
+ // Turbo is imported unconditionally by application.js, so it is assumed
57
+ // present here the same way creative_tree_row.js assumes it.
58
+ export function visitFilterUrl(url) {
59
+ const frame = document.getElementById(WORKSPACE_FRAME_ID)
60
+
61
+ window.Turbo.visit(
62
+ `${url.pathname}${url.search}`,
63
+ frame ? { action: 'advance', frame: WORKSPACE_FRAME_ID } : { action: 'advance' }
64
+ )
65
+ return Boolean(frame)
66
+ }
67
+
68
+ function progressState(params) {
69
+ const min = params.get('min_progress')
70
+ const max = params.get('max_progress')
71
+ if (min === '1' && max === '1') return 'complete'
72
+ if (min === '0' && max === '0.99') return 'incomplete'
73
+ return 'all'
74
+ }
75
+
76
+ // Re-derives the `active` classes the server would have rendered for the URL
77
+ // we are navigating to. Mirrors _search_form.html.erb; only needed on the
78
+ // frame path, where the GNB is not re-rendered.
79
+ export function syncFilterButtons(url) {
80
+ const params = url.searchParams
81
+ const hasProgress = Boolean(params.get('min_progress') || params.get('max_progress'))
82
+ const hasArchived = params.get('show_archived') === 'true'
83
+ const hasSearch = Boolean(params.get('search')?.trim())
84
+ const active = {
85
+ [`progress:${progressState(params)}`]: true,
86
+ comment: params.get('comment') === 'true',
87
+ archived: hasArchived,
88
+ 'any-filter': hasProgress || hasArchived || hasSearch
89
+ }
90
+
91
+ document.querySelectorAll('[data-filter-state]').forEach((element) => {
92
+ const isActive = active[element.dataset.filterState] === true
93
+ element.classList.toggle('active', isActive)
94
+
95
+ // Buttons whose label depends on the filter state (archive show/hide)
96
+ // carry both translations so they can be swapped without a round trip.
97
+ const label = isActive ? element.dataset.labelOn : element.dataset.labelOff
98
+ if (label) element.textContent = label
99
+ })
100
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ const openCreativeLinkPicker = jest.fn()
8
+
9
+ jest.unstable_mockModule('../creative_link_picker', () => ({
10
+ openCreativeLinkPicker,
11
+ }))
12
+
13
+ await import('../command_menu')
14
+
15
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
16
+
17
+ describe('command menu Creative picker', () => {
18
+ const originalFetch = global.fetch
19
+
20
+ afterEach(() => {
21
+ document.body.innerHTML = ''
22
+ global.fetch = originalFetch
23
+ jest.clearAllMocks()
24
+ })
25
+
26
+ test('routes the /creative command through the shared picker', async () => {
27
+ document.body.innerHTML = `
28
+ <form id="new-comment-form"><textarea>/creative</textarea></form>
29
+ <div id="comments-popup" data-creative-id="7"></div>
30
+ <div id="command-menu" style="display:none">
31
+ <ul data-popup-list></ul>
32
+ </div>
33
+ `
34
+ global.fetch = jest.fn().mockResolvedValue({
35
+ ok: true,
36
+ json: () => Promise.resolve([{
37
+ type: 'popup',
38
+ popup_type: 'creative_picker',
39
+ name: 'creative',
40
+ label: '/creative',
41
+ aliases: [],
42
+ }]),
43
+ })
44
+
45
+ document.dispatchEvent(new Event('turbo:load'))
46
+ const textarea = document.querySelector('textarea')
47
+ textarea.setSelectionRange(9, 9)
48
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
49
+ await flush()
50
+ await flush()
51
+
52
+ textarea.dispatchEvent(new KeyboardEvent('keydown', {
53
+ key: 'Enter',
54
+ bubbles: true,
55
+ cancelable: true,
56
+ }))
57
+
58
+ expect(openCreativeLinkPicker).toHaveBeenCalledWith(textarea, {
59
+ triggerRange: { start: 0, end: 9 },
60
+ })
61
+ })
62
+ })
@@ -0,0 +1,267 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ jest.unstable_mockModule('../creative_link_picker', () => ({
8
+ openCreativeLinkPicker: jest.fn(),
9
+ }))
10
+
11
+ await import('../command_menu')
12
+
13
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
14
+ // flush() plus the placement frame CommonPopup defers its reveal to.
15
+ const settle = () => flush().then(() => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))))
16
+
17
+ const COMMANDS = [
18
+ { name: 'task_create', label: '/task_create', aliases: [] },
19
+ { name: 'topic_list', label: '/topic_list', aliases: [] },
20
+ ]
21
+
22
+ // The command list is fetched on every input event, so the menu's behaviour while
23
+ // a lookup is in flight is what the user actually sees when typing "/task".
24
+ describe('command menu typeahead', () => {
25
+ const originalFetch = global.fetch
26
+
27
+ const setup = () => {
28
+ document.body.innerHTML = `
29
+ <form id="new-comment-form"><textarea></textarea></form>
30
+ <div id="comments-popup" data-creative-id="7"></div>
31
+ <div id="command-menu" style="display:none">
32
+ <ul data-popup-list></ul>
33
+ </div>
34
+ `
35
+ document.dispatchEvent(new Event('turbo:load'))
36
+ return {
37
+ textarea: document.querySelector('textarea'),
38
+ menu: document.getElementById('command-menu'),
39
+ }
40
+ }
41
+
42
+ const type = (textarea, value) => {
43
+ textarea.value = value
44
+ textarea.setSelectionRange(value.length, value.length)
45
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
46
+ }
47
+
48
+ afterEach(() => {
49
+ document.body.innerHTML = ''
50
+ global.fetch = originalFetch
51
+ jest.clearAllMocks()
52
+ })
53
+
54
+ test('shares one request across the keystrokes typed before it resolves', async () => {
55
+ let resolveFetch
56
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
57
+ const { textarea, menu } = setup()
58
+
59
+ for (const value of ['/', '/t', '/ta', '/tas', '/task']) type(textarea, value)
60
+ expect(global.fetch).toHaveBeenCalledTimes(1)
61
+
62
+ resolveFetch({ ok: true, json: () => Promise.resolve(COMMANDS) })
63
+ await flush()
64
+
65
+ expect(menu.style.display).toBe('block')
66
+ // Only the query the user actually ended on is rendered.
67
+ expect(menu.querySelectorAll('.common-popup-item')).toHaveLength(1)
68
+ expect(menu.textContent).toContain('/task_create')
69
+ })
70
+
71
+ test('a failed lookup is not cached, so the next keystroke retries', async () => {
72
+ global.fetch = jest.fn()
73
+ .mockRejectedValueOnce(new Error('offline'))
74
+ .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(COMMANDS) })
75
+ const { textarea, menu } = setup()
76
+
77
+ type(textarea, '/')
78
+ await flush()
79
+ expect(menu.style.display).toBe('none')
80
+
81
+ type(textarea, '/t')
82
+ await flush()
83
+ expect(global.fetch).toHaveBeenCalledTimes(2)
84
+ expect(menu.style.display).toBe('block')
85
+ })
86
+
87
+ test('a lookup that lands after the "/" is deleted does not re-open the menu', async () => {
88
+ let resolveFetch
89
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
90
+ const { textarea, menu } = setup()
91
+
92
+ type(textarea, '/')
93
+ // The user backspaces before the command list arrives.
94
+ type(textarea, '')
95
+
96
+ resolveFetch({ ok: true, json: () => Promise.resolve(COMMANDS) })
97
+ await flush()
98
+
99
+ expect(menu.style.display).toBe('none')
100
+ })
101
+
102
+ test('a stale lookup cannot overwrite the menu with an outdated query', async () => {
103
+ const pending = []
104
+ global.fetch = jest.fn(() => new Promise((resolve) => { pending.push(resolve) }))
105
+ const { textarea, menu } = setup()
106
+
107
+ type(textarea, '/')
108
+ // Second keystroke reuses the in-flight request, so there is still only one.
109
+ type(textarea, '/topic')
110
+ expect(pending).toHaveLength(1)
111
+
112
+ pending[0]({ ok: true, json: () => Promise.resolve(COMMANDS) })
113
+ await flush()
114
+
115
+ // Rendered for "/topic", not for the earlier empty query that would have
116
+ // listed every command.
117
+ expect(menu.querySelectorAll('.common-popup-item')).toHaveLength(1)
118
+ expect(menu.textContent).toContain('/topic_list')
119
+ })
120
+
121
+ test('the popup is not blanked between keystrokes once it is open', async () => {
122
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(COMMANDS) })
123
+ const { textarea, menu } = setup()
124
+
125
+ type(textarea, '/')
126
+ // Let the placement frame reveal the popup, as it does for a real first open.
127
+ await settle()
128
+ expect(menu.style.visibility).toBe('visible')
129
+
130
+ // Every later keystroke must leave it on screen: blanking it here is what
131
+ // made the menu blink once per character.
132
+ for (const value of ['/t', '/ta', '/tas']) {
133
+ type(textarea, value)
134
+ await flush()
135
+ expect(menu.style.visibility).toBe('visible')
136
+ }
137
+ })
138
+
139
+ test('command arguments refocus after settlement without stealing deliberate focus', async () => {
140
+ global.fetch = jest.fn().mockResolvedValue({
141
+ ok: true,
142
+ json: () => Promise.resolve([{
143
+ name: 'task_create',
144
+ label: '/task_create',
145
+ aliases: [],
146
+ input_schema: [{ name: 'title', type: 'string', required: false }],
147
+ }]),
148
+ })
149
+ const { textarea, menu } = setup()
150
+ const submit = document.createElement('button')
151
+ const submitClick = jest.fn()
152
+ let submissionId
153
+ submit.type = 'button'
154
+ submit.setAttribute('data-comments--form-target', 'submit')
155
+ submit.addEventListener('click', (event) => {
156
+ submitClick()
157
+ submissionId = event.commandSubmissionId
158
+ document.getElementById('comments-popup').dispatchEvent(
159
+ new CustomEvent('comments--form:submit-started', { detail: { submissionId } }),
160
+ )
161
+ })
162
+ document.getElementById('new-comment-form').appendChild(submit)
163
+
164
+ type(textarea, '/task')
165
+ await settle()
166
+ textarea.dispatchEvent(new KeyboardEvent('keydown', {
167
+ key: 'Enter',
168
+ bubbles: true,
169
+ cancelable: true,
170
+ }))
171
+
172
+ const titleInput = document.querySelector('#command-args-dialog textarea')
173
+ titleInput.dispatchEvent(new KeyboardEvent('keydown', {
174
+ key: 'Enter',
175
+ bubbles: true,
176
+ cancelable: true,
177
+ }))
178
+ await flush()
179
+
180
+ expect(document.getElementById('command-args-dialog')).toBeNull()
181
+ expect(menu.style.display).toBe('none')
182
+ expect(submitClick).toHaveBeenCalledTimes(1)
183
+ expect(document.activeElement).not.toBe(textarea)
184
+
185
+ document.getElementById('comments-popup').dispatchEvent(
186
+ new CustomEvent('comments--form:submit-settled', {
187
+ detail: { submissionId: 'an-unrelated-send' },
188
+ }),
189
+ )
190
+ expect(document.activeElement).not.toBe(textarea)
191
+
192
+ document.getElementById('comments-popup').dispatchEvent(
193
+ new CustomEvent('comments--form:submit-settled', { detail: { submissionId } }),
194
+ )
195
+
196
+ expect(document.activeElement).toBe(textarea)
197
+
198
+ type(textarea, '/task')
199
+ await settle()
200
+ textarea.dispatchEvent(new KeyboardEvent('keydown', {
201
+ key: 'Enter',
202
+ bubbles: true,
203
+ cancelable: true,
204
+ }))
205
+ document.querySelector('#command-args-dialog textarea').dispatchEvent(new KeyboardEvent('keydown', {
206
+ key: 'Enter',
207
+ bubbles: true,
208
+ cancelable: true,
209
+ }))
210
+ await flush()
211
+
212
+ const otherControl = document.createElement('button')
213
+ document.body.appendChild(otherControl)
214
+ otherControl.focus()
215
+ document.getElementById('comments-popup').dispatchEvent(
216
+ new CustomEvent('comments--form:submit-settled', { detail: { submissionId } }),
217
+ )
218
+
219
+ expect(submitClick).toHaveBeenCalledTimes(2)
220
+ expect(document.activeElement).toBe(otherControl)
221
+ })
222
+
223
+ test('a rejected command submission removes its settlement listener', async () => {
224
+ global.fetch = jest.fn().mockResolvedValue({
225
+ ok: true,
226
+ json: () => Promise.resolve([{
227
+ name: 'task_create',
228
+ label: '/task_create',
229
+ aliases: [],
230
+ input_schema: [{ name: 'title', type: 'string', required: false }],
231
+ }]),
232
+ })
233
+ const { textarea } = setup()
234
+ const submit = document.createElement('button')
235
+ let rejectedSubmissionId
236
+ submit.type = 'button'
237
+ submit.setAttribute('data-comments--form-target', 'submit')
238
+ submit.addEventListener('click', (event) => {
239
+ rejectedSubmissionId = event.commandSubmissionId
240
+ // No submit-started event: the form controller rejected this attempt.
241
+ })
242
+ document.getElementById('new-comment-form').appendChild(submit)
243
+
244
+ type(textarea, '/task')
245
+ await settle()
246
+ textarea.dispatchEvent(new KeyboardEvent('keydown', {
247
+ key: 'Enter', bubbles: true, cancelable: true,
248
+ }))
249
+ document.querySelector('#command-args-dialog textarea').dispatchEvent(new KeyboardEvent('keydown', {
250
+ key: 'Enter', bubbles: true, cancelable: true,
251
+ }))
252
+ await flush()
253
+
254
+ expect(document.activeElement).toBe(textarea)
255
+
256
+ const otherControl = document.createElement('button')
257
+ document.body.appendChild(otherControl)
258
+ otherControl.focus()
259
+ document.getElementById('comments-popup').dispatchEvent(
260
+ new CustomEvent('comments--form:submit-settled', {
261
+ detail: { submissionId: rejectedSubmissionId },
262
+ }),
263
+ )
264
+
265
+ expect(document.activeElement).toBe(otherControl)
266
+ })
267
+ })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import {
7
+ escapeMarkdownLabel,
8
+ insertMarkdownCreativeLink,
9
+ markdownCreativeCommandRange,
10
+ openCreativeLinkPicker,
11
+ } from '../creative_link_picker'
12
+
13
+ describe('creative link picker helpers', () => {
14
+ afterEach(() => {
15
+ document.body.innerHTML = ''
16
+ delete window.Stimulus
17
+ })
18
+
19
+ test('finds /creative only at the beginning of a Markdown line', () => {
20
+ expect(markdownCreativeCommandRange('/creative', 9)).toEqual({ start: 0, end: 9 })
21
+ expect(markdownCreativeCommandRange('first\n/creative', 15)).toEqual({ start: 6, end: 15 })
22
+ expect(markdownCreativeCommandRange('text /creative', 14)).toBeNull()
23
+ expect(markdownCreativeCommandRange('/creative later', 15)).toBeNull()
24
+ })
25
+
26
+ test('inserts the canonical Markdown link and notifies textarea consumers', () => {
27
+ const textarea = document.createElement('textarea')
28
+ textarea.value = 'before after'
29
+ textarea.setSelectionRange(7, 7)
30
+ const inputListener = jest.fn()
31
+ textarea.addEventListener('input', inputListener)
32
+
33
+ expect(insertMarkdownCreativeLink(textarea, { id: 12, label: 'Target' })).toBe(true)
34
+ expect(textarea.value).toBe('before [Target](/creatives/12) after')
35
+ expect(inputListener).toHaveBeenCalledTimes(1)
36
+ })
37
+
38
+ test('escapes Markdown constructs in the selected Creative label', () => {
39
+ const textarea = document.createElement('textarea')
40
+
41
+ expect(escapeMarkdownLabel('a\\`*_[]()<>~b')).toBe('a\\\\\\`\\*\\_\\[\\]\\(\\)\\<\\>\\~b')
42
+ expect(insertMarkdownCreativeLink(textarea, {
43
+ id: 12,
44
+ label: 'x](https://evil.example)',
45
+ })).toBe(true)
46
+ expect(textarea.value).toBe('[x\\]\\(https://evil.example\\)](/creatives/12) ')
47
+ })
48
+
49
+ test('removes the trigger and opens the shared picker with creation enabled', () => {
50
+ document.body.innerHTML = '<div id="link-creative-modal"></div><textarea id="editor">/creative</textarea>'
51
+ const textarea = document.getElementById('editor')
52
+ textarea.setSelectionRange(9, 9)
53
+ const controller = { open: jest.fn() }
54
+ window.Stimulus = {
55
+ getControllerForElementAndIdentifier: jest.fn(() => controller),
56
+ }
57
+
58
+ const opened = openCreativeLinkPicker(textarea, {
59
+ triggerRange: { start: 0, end: 9 },
60
+ })
61
+
62
+ expect(opened).toBe(true)
63
+ expect(textarea.value).toBe('')
64
+ expect(controller.open).toHaveBeenCalledTimes(1)
65
+ expect(controller.open.mock.calls[0][3]).toEqual({ allowCreate: true })
66
+
67
+ textarea.blur()
68
+ controller.open.mock.calls[0][2]()
69
+ expect(document.activeElement).toBe(textarea)
70
+
71
+ controller.open.mock.calls[0][1]({ id: 18, label: 'Created' })
72
+ expect(textarea.value).toBe('[Created](/creatives/18) ')
73
+ })
74
+
75
+ test('does not remove the trigger when the shared picker is unavailable', () => {
76
+ document.body.innerHTML = '<textarea id="editor">/creative</textarea>'
77
+ const textarea = document.getElementById('editor')
78
+ textarea.setSelectionRange(9, 9)
79
+
80
+ expect(openCreativeLinkPicker(textarea, {
81
+ triggerRange: { start: 0, end: 9 },
82
+ })).toBe(false)
83
+ expect(textarea.value).toBe('/creative')
84
+ })
85
+ })