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
@@ -1,3 +1,20 @@
1
+ // Gap between the anchor (caret, button) and the popup edge.
2
+ const ANCHOR_GAP = 4
3
+ // Breathing room kept between the popup and the edge of its region.
4
+ const BOUNDS_PADDING = 8
5
+ // Floor for the space-derived max-height. Header + search input already eat
6
+ // ~90px, so anything under this leaves no list at all; overflowing the region
7
+ // slightly beats rendering a sliver.
8
+ const MIN_POPUP_HEIGHT = 160
9
+
10
+ // Anchor provider for a popup hung off a live element. Pass this to showAt
11
+ // instead of the element's rect so placement can re-measure it later; returns
12
+ // null once the element leaves the document, where its rect would read as all
13
+ // zeros and drag the popup into the top-left corner.
14
+ export function elementAnchor(element) {
15
+ return () => (element?.isConnected ? element.getBoundingClientRect() : null)
16
+ }
17
+
1
18
  export default class CommonPopup {
2
19
  constructor(element, { listElement, onSelect, renderItem, onClose, closeOnOutsideClick = true } = {}) {
3
20
  this.element = element
@@ -8,16 +25,49 @@ export default class CommonPopup {
8
25
  this.closeOnOutsideClick = closeOnOutsideClick
9
26
  this.items = []
10
27
  this.activeIndex = -1
28
+ // Pending showAt placement frame, so hide() can cancel it.
29
+ this._openFrame = null
30
+ // Set by showAt when the caller can re-measure its anchor — see anchorRect.
31
+ this._anchorProvider = null
32
+ this._anchorRect = null
33
+ // True once the placement frame has actually revealed the popup. isOpen() is
34
+ // already true during the frame between display:block and that reveal, so it
35
+ // cannot tell "on screen" apart from "measured but still blank" — and only
36
+ // the first of those may skip the blanking step. See showAt.
37
+ this._revealed = false
11
38
 
12
39
  this.handleOutsideClick = this.handleOutsideClick.bind(this)
40
+ this.handleViewportResize = this.handleViewportResize.bind(this)
13
41
  }
14
42
 
15
- showAt(anchorRect, boundsElement = null) {
43
+ // `anchor` is either a rect or a function returning one. Prefer the function
44
+ // (see elementAnchor / caretAnchor): a rect is a snapshot, and the anchor can
45
+ // move while the popup is open — the on-screen keyboard shrinks the viewport
46
+ // and comments--presence lifts the composer clear of it on that same resize,
47
+ // so by the time we re-place, the opening rect points under the keyboard.
48
+ // Rects stay supported for anchors with nothing to re-measure, such as one
49
+ // derived from a text selection that has since been cleared.
50
+ showAt(anchor, boundsElement = null) {
16
51
  if (!this.element) return
17
52
 
53
+ // Typeahead popups call showAt on every keystroke to re-anchor a menu that
54
+ // is already up (see modules/command_menu.js). Blanking it to
55
+ // visibility:hidden and waiting a frame would then blink the menu off and
56
+ // back on once per character, so a popup already on screen is only re-placed.
57
+ const wasRevealed = this._revealed && this.element.isConnected
58
+
18
59
  // When set, the popup is caged inside this element's rect (e.g. the chat
19
60
  // box) instead of the viewport — see updatePosition.
20
61
  this._boundsElement = boundsElement
62
+ this._anchorProvider = typeof anchor === 'function' ? anchor : null
63
+ // Last known anchor geometry, so reposition() can re-place after the
64
+ // popup's content has changed size, and so a provider that stops resolving
65
+ // has something to fall back on.
66
+ this._anchorRect = this._anchorProvider ? null : anchor
67
+ // Keep the flip decision for the rest of this open: re-anchoring a menu
68
+ // that is already above the caret must not drop it back under the caret
69
+ // for a frame before it flips up again.
70
+ if (!wasRevealed) this._placedAbove = false
21
71
 
22
72
  // Re-opening while already open (e.g. clicking the same typo mark twice):
23
73
  // a listener from the previous open is still live, so the opening mousedown
@@ -28,11 +78,28 @@ export default class CommonPopup {
28
78
  document.removeEventListener('touchstart', this.handleOutsideClick)
29
79
 
30
80
  this.element.style.display = 'block'
31
- this.element.style.visibility = 'hidden'
81
+ if (wasRevealed) {
82
+ // Already painted, so place it now rather than a frame late — otherwise
83
+ // the menu sits at the previous caret's position for one frame.
84
+ this.updatePosition(this.anchorRect())
85
+ } else {
86
+ this.element.style.visibility = 'hidden'
87
+ }
32
88
 
33
- requestAnimationFrame(() => {
34
- this.updatePosition(anchorRect)
89
+ cancelAnimationFrame(this._openFrame)
90
+ this._openFrame = requestAnimationFrame(() => {
91
+ this._openFrame = null
92
+ // The popup can be gone before this frame runs: hide() on a rapid
93
+ // open/close, or a Turbo navigation that tears the element out of the
94
+ // document without closing the popup. Registering here regardless would
95
+ // leave document/visualViewport holding this popup and its detached
96
+ // element forever, and hide()'s removals already ran against listeners
97
+ // that did not exist yet.
98
+ if (!this.isOpen() || !this.element.isConnected) return
99
+
100
+ this.updatePosition(this.anchorRect())
35
101
  this.element.style.visibility = 'visible'
102
+ this._revealed = true
36
103
  // Register the outside-click listeners only after the opening event has
37
104
  // finished propagating. When a popup is opened from a mousedown handler
38
105
  // (e.g. clicking a typo highlight), registering synchronously here would
@@ -43,42 +110,106 @@ export default class CommonPopup {
43
110
  document.addEventListener('mousedown', this.handleOutsideClick)
44
111
  document.addEventListener('touchstart', this.handleOutsideClick)
45
112
  }
113
+ // The on-screen keyboard shrinks the visual viewport without firing a
114
+ // window resize, and it opens *after* showAt (the consumer focuses its
115
+ // input a frame later), so the placement made here is already stale.
116
+ window.visualViewport?.addEventListener('resize', this.handleViewportResize)
46
117
  })
47
118
  }
48
119
 
120
+ // Re-run placement against the anchor showAt was given. Call this whenever the
121
+ // popup's content changes size: showAt measures one frame after opening, so a
122
+ // popup whose body arrives asynchronously (a fetched tree, search results) was
123
+ // sized while it still read "Loading…" and would otherwise keep the placement
124
+ // that placeholder height implied.
125
+ reposition() {
126
+ if (!this.isOpen()) return
127
+ this.updatePosition(this.anchorRect())
128
+ }
129
+
130
+ // The anchor's geometry now, not as it was when showAt ran.
131
+ anchorRect() {
132
+ if (!this._anchorProvider) return this._anchorRect
133
+
134
+ let rect = null
135
+ try {
136
+ rect = this._anchorProvider()
137
+ } catch {
138
+ rect = null
139
+ }
140
+ // The anchor can go away mid-open (Turbo swap, a textarea replaced by a
141
+ // stream update). Keep the last place we saw it rather than collapsing to
142
+ // the origin.
143
+ if (rect) this._anchorRect = rect
144
+ return this._anchorRect
145
+ }
146
+
147
+ handleViewportResize() {
148
+ this.reposition()
149
+ }
150
+
151
+ // The region the popup must stay inside: an explicit bounds element (the chat
152
+ // box) when caged, otherwise the visual viewport. window.innerHeight is the
153
+ // wrong number on mobile — it still counts the strip the keyboard covers.
154
+ regionRect() {
155
+ const bounds = this._boundsElement?.getBoundingClientRect?.()
156
+ if (bounds) return bounds
157
+
158
+ const viewport = window.visualViewport
159
+ const left = viewport?.offsetLeft || 0
160
+ const top = viewport?.offsetTop || 0
161
+ const width = viewport?.width || window.innerWidth
162
+ const height = viewport?.height || window.innerHeight
163
+ return { left, top, right: left + width, bottom: top + height, width, height }
164
+ }
165
+
49
166
  updatePosition(anchorRect) {
50
167
  if (!this.element) return
51
168
 
52
- const scrollX = window.scrollX || window.pageXOffset || 0
53
- const scrollY = window.scrollY || window.pageYOffset || 0
54
169
  const offsetParent = this.element.offsetParent
55
170
  const parentRect = offsetParent?.getBoundingClientRect?.() || { left: 0, top: 0 }
56
171
  const parentScrollX = offsetParent?.scrollLeft || 0
57
172
  const parentScrollY = offsetParent?.scrollTop || 0
58
- const boundsPadding = 8
59
173
  const rect = anchorRect || this.element.getBoundingClientRect()
174
+ const anchorTop = rect?.top || 0
175
+ const anchorBottom = rect?.bottom || 0
60
176
 
61
- let viewportLeft = (rect?.left || 0)
62
- let viewportTop = (rect?.bottom || 0) + 4
63
-
177
+ // Measure unconstrained. A max-height left behind by an earlier placement
178
+ // would make offsetHeight report that cap instead of the content height, so
179
+ // every later call would agree the popup "fits" wherever it was first put.
180
+ this.element.style.maxHeight = ''
64
181
  const { offsetWidth: width, offsetHeight: height } = this.element
65
182
 
66
- // Clamp within a container's rect when bounded (keeps the popup caged inside
67
- // the chat box), otherwise within the viewport.
68
- const bounds = this._boundsElement?.getBoundingClientRect?.()
69
- let minLeft = boundsPadding
70
- let minTop = boundsPadding
71
- let maxLeft = window.innerWidth - width - boundsPadding
72
- let maxTop = window.innerHeight - height - boundsPadding
73
- if (bounds) {
74
- minLeft = bounds.left + boundsPadding
75
- minTop = bounds.top + boundsPadding
76
- maxLeft = bounds.right - width - boundsPadding
77
- maxTop = bounds.bottom - height - boundsPadding
78
- // Cap size so a long list scrolls inside the box instead of spilling past it.
79
- this.element.style.maxWidth = `${bounds.width - boundsPadding * 2}px`
80
- this.element.style.maxHeight = `${bounds.height - boundsPadding * 2}px`
183
+ const region = this.regionRect()
184
+ const spaceBelow = region.bottom - BOUNDS_PADDING - (anchorBottom + ANCHOR_GAP)
185
+ const spaceAbove = (anchorTop - ANCHOR_GAP) - (region.top + BOUNDS_PADDING)
186
+
187
+ // Flip above the anchor when the popup genuinely does not fit below it and
188
+ // there is more room up there — a chat composer pinned to the bottom of the
189
+ // screen leaves almost nothing under the caret. Once flipped, stay flipped
190
+ // for the rest of this open: content that shrinks again (collapsing tree
191
+ // nodes) must not make the popup hop back across the anchor.
192
+ const placeAbove = this._placedAbove || (height > spaceBelow && spaceAbove > spaceBelow)
193
+ this._placedAbove = placeAbove
194
+
195
+ // Cap to the space actually available so the list scrolls inside the popup
196
+ // instead of running off-screen, both now and if the content grows later.
197
+ const available = Math.max(placeAbove ? spaceAbove : spaceBelow, MIN_POPUP_HEIGHT)
198
+ this.element.style.maxHeight = `${available}px`
199
+ if (this._boundsElement) {
200
+ this.element.style.maxWidth = `${region.width - BOUNDS_PADDING * 2}px`
81
201
  }
202
+ const renderedHeight = Math.min(height, available)
203
+
204
+ let viewportLeft = rect?.left || 0
205
+ let viewportTop = placeAbove
206
+ ? anchorTop - ANCHOR_GAP - renderedHeight
207
+ : anchorBottom + ANCHOR_GAP
208
+
209
+ const minLeft = region.left + BOUNDS_PADDING
210
+ const maxLeft = region.right - width - BOUNDS_PADDING
211
+ const minTop = region.top + BOUNDS_PADDING
212
+ const maxTop = region.bottom - renderedHeight - BOUNDS_PADDING
82
213
 
83
214
  viewportLeft = Math.max(minLeft, Math.min(viewportLeft, maxLeft))
84
215
  viewportTop = Math.max(minTop, Math.min(viewportTop, maxTop))
@@ -200,14 +331,22 @@ export default class CommonPopup {
200
331
 
201
332
  hide(reason = 'manual') {
202
333
  if (!this.element || !this.isOpen()) return
334
+ // Drop a placement frame that has not run yet, so it cannot re-show and
335
+ // re-register listeners after this close.
336
+ cancelAnimationFrame(this._openFrame)
337
+ this._openFrame = null
203
338
  this.element.style.display = 'none'
204
339
  this.element.style.visibility = ''
340
+ this._revealed = false
205
341
  this.items = []
206
342
  this.activeIndex = -1
207
343
  this.updateActiveItem()
208
344
 
209
345
  document.removeEventListener('mousedown', this.handleOutsideClick)
210
346
  document.removeEventListener('touchstart', this.handleOutsideClick)
347
+ window.visualViewport?.removeEventListener('resize', this.handleViewportResize)
348
+ this._anchorRect = null
349
+ this._anchorProvider = null
211
350
 
212
351
  if (typeof this.onClose === 'function') {
213
352
  this.onClose(reason)
@@ -0,0 +1,27 @@
1
+ export function isCreativeNavigation(url) {
2
+ url = new URL(url, window.location.origin)
3
+
4
+ return url.origin === window.location.origin && /\/creatives(?:\/|$)/.test(url.pathname)
5
+ }
6
+
7
+ // Only these HTML routes reach CreativesController#index visit persistence:
8
+ // the Creative index and a single Creative show route (which redirects there).
9
+ // Nested Creative routes load supporting UI and must not reserve a visit
10
+ // sequence merely because a last-visited controller is connected.
11
+ export function isCreativeVisitNavigation(url) {
12
+ url = new URL(url, window.location.origin)
13
+
14
+ return url.origin === window.location.origin && /\/creatives(?:\.html|\/\d+)?\/?$/.test(url.pathname)
15
+ }
16
+
17
+ export function preventCreativeLinkPrefetch(event) {
18
+ const link = event.target
19
+ if (!(link instanceof HTMLAnchorElement) || !isCreativeNavigation(link.href)) return
20
+
21
+ event.preventDefault()
22
+ }
23
+
24
+ // A prefetched Creative response can be promoted by Turbo without issuing a
25
+ // second request. Prevent it globally so confirmed navigation remains the only
26
+ // way to update the user's last visited Creative, regardless of link source.
27
+ document.addEventListener("turbo:before-prefetch", preventCreativeLinkPrefetch)
@@ -0,0 +1,97 @@
1
+ import csrfFetch, { refreshCsrfToken } from './api/csrf_fetch'
2
+ import { isCreativeVisitNavigation } from './creative_link_prefetch'
3
+
4
+ let pendingRequestController = null
5
+ const SEQUENCE_HEADER = 'X-Collavre-Last-Visited-Creative-Sequence'
6
+ const TOKEN_HEADER = 'X-Collavre-Last-Visited-Creative-Token'
7
+
8
+ function sequenceUrl(baseUrl) {
9
+ const url = new URL(baseUrl, window.location.origin)
10
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/next_last_visited_sequence`
11
+ return `${url.pathname}${url.search}`
12
+ }
13
+
14
+ async function nextLastVisitedCreativeSequence(baseUrl, signal, retryCsrf = true) {
15
+ const response = await csrfFetch(sequenceUrl(baseUrl), {
16
+ method: 'PATCH',
17
+ headers: { Accept: 'application/json' },
18
+ signal,
19
+ })
20
+ if (response.status === 422 && retryCsrf && !signal?.aborted) {
21
+ await refreshCsrfToken({ signal })
22
+ if (signal?.aborted) return null
23
+ return nextLastVisitedCreativeSequence(baseUrl, signal, false)
24
+ }
25
+ if (!response.ok) return null
26
+
27
+ const sequence = Number((await response.json()).sequence)
28
+ return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : null
29
+ }
30
+
31
+ export async function prepareLastVisitedCreativeNavigation(event, baseUrl, visitToken) {
32
+ const fetchOptions = event.detail?.fetchOptions
33
+ if (
34
+ !baseUrl ||
35
+ !visitToken ||
36
+ !fetchOptions ||
37
+ !isCreativeVisitNavigation(event.detail?.url) ||
38
+ String(fetchOptions.method || 'GET').toUpperCase() !== 'GET'
39
+ ) return
40
+
41
+ event.preventDefault()
42
+ try {
43
+ const sequence = await nextLastVisitedCreativeSequence(baseUrl)
44
+ if (!sequence) return
45
+
46
+ const headers = new Headers(fetchOptions.headers)
47
+ headers.set(TOKEN_HEADER, visitToken)
48
+ headers.set(SEQUENCE_HEADER, String(sequence))
49
+ fetchOptions.headers = headers
50
+ } finally {
51
+ event.detail.resume?.()
52
+ }
53
+ }
54
+
55
+ export function cancelPendingLastVisitedCreative() {
56
+ pendingRequestController?.abort()
57
+ pendingRequestController = null
58
+ }
59
+
60
+ export function rememberLastVisitedCreative(baseUrl, creativeId, visitToken) {
61
+ if (!baseUrl || !creativeId || !visitToken) return
62
+
63
+ cancelPendingLastVisitedCreative()
64
+ const requestController = new AbortController()
65
+ pendingRequestController = requestController
66
+ const url = new URL(baseUrl, window.location.origin)
67
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/${encodeURIComponent(creativeId)}/remember_last_visited`
68
+ url.searchParams.set('visit_token', visitToken)
69
+ const request = (sequence) => csrfFetch(`${url.pathname}${url.search}`, {
70
+ method: 'PATCH',
71
+ headers: {
72
+ Accept: 'application/json',
73
+ [SEQUENCE_HEADER]: String(sequence),
74
+ },
75
+ signal: requestController.signal,
76
+ })
77
+
78
+ nextLastVisitedCreativeSequence(baseUrl, requestController.signal)
79
+ .then(async (sequence) => {
80
+ if (!sequence || requestController.signal.aborted) return null
81
+ return request(sequence)
82
+ })
83
+ .then(async (response) => {
84
+ if (!response) return
85
+ if (requestController.signal.aborted || response.ok || response.status !== 422) return
86
+
87
+ await refreshCsrfToken({ signal: requestController.signal })
88
+ if (requestController.signal.aborted) return
89
+ const sequence = await nextLastVisitedCreativeSequence(baseUrl, requestController.signal)
90
+ if (!sequence || requestController.signal.aborted) return
91
+ await request(sequence)
92
+ })
93
+ .catch(() => {})
94
+ .finally(() => {
95
+ if (pendingRequestController === requestController) pendingRequestController = null
96
+ })
97
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import {
6
+ $createParagraphNode,
7
+ $createTextNode,
8
+ $getRoot,
9
+ createEditor,
10
+ } from 'lexical'
11
+ import { LinkNode } from '@lexical/link'
12
+ import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'
13
+ import {
14
+ $createCreativeLinkNode,
15
+ $isCreativeLinkNode,
16
+ CreativeLinkNode,
17
+ creativeIdFromUrl,
18
+ } from '../creative_link_node'
19
+ import { lexicalToMarkdown } from '../markdown_serialize'
20
+
21
+ function createTestEditor() {
22
+ return createEditor({
23
+ namespace: 'creative-link-test',
24
+ nodes: [LinkNode, CreativeLinkNode],
25
+ onError(error) {
26
+ throw error
27
+ },
28
+ })
29
+ }
30
+
31
+ describe('CreativeLinkNode', () => {
32
+ test('extracts creative ids only from canonical internal paths', () => {
33
+ expect(creativeIdFromUrl('/creatives/42')).toBe(42)
34
+ expect(creativeIdFromUrl('/creatives/42?topic_id=3')).toBe(42)
35
+ expect(creativeIdFromUrl('https://example.com/creatives/42')).toBeNull()
36
+ expect(creativeIdFromUrl('/creatives/not-a-number')).toBeNull()
37
+ })
38
+
39
+ test('exports data-creative-id while keeping canonical Markdown', () => {
40
+ const editor = createTestEditor()
41
+ editor.update(() => {
42
+ const paragraph = $createParagraphNode()
43
+ const link = $createCreativeLinkNode('/creatives/42', 42)
44
+ link.append($createTextNode('Internal page'))
45
+ paragraph.append(link)
46
+ $getRoot().append(paragraph)
47
+ }, { discrete: true })
48
+
49
+ let html = ''
50
+ editor.getEditorState().read(() => {
51
+ html = $generateHtmlFromNodes(editor)
52
+ })
53
+
54
+ expect(html).toContain('data-creative-id="42"')
55
+ expect(lexicalToMarkdown(editor)).toBe('[Internal page](/creatives/42)')
56
+ })
57
+
58
+ test('reconstructs the dedicated node from a canonical internal href', () => {
59
+ const editor = createTestEditor()
60
+ let imported = null
61
+
62
+ editor.update(() => {
63
+ const doc = new DOMParser().parseFromString(
64
+ '<p><a href="/creatives/77">Existing</a></p>',
65
+ 'text/html',
66
+ )
67
+ const nodes = $generateNodesFromDOM(editor, doc.body)
68
+ nodes.forEach((node) => $getRoot().append(node))
69
+ imported = $getRoot().getFirstChild().getFirstChild()
70
+ }, { discrete: true })
71
+
72
+ let importedType = null
73
+ let importedCreativeId = null
74
+ editor.getEditorState().read(() => {
75
+ importedType = imported.getType()
76
+ importedCreativeId = imported.getCreativeId()
77
+ })
78
+ expect(importedType).toBe('creative-link')
79
+ expect(importedCreativeId).toBe(77)
80
+ })
81
+
82
+ test('imports serialized nodes and preserves link attributes when cloned', () => {
83
+ const editor = createTestEditor()
84
+ let imported = null
85
+ let cloned = null
86
+
87
+ editor.update(() => {
88
+ imported = CreativeLinkNode.importJSON({
89
+ type: 'creative-link',
90
+ version: 1,
91
+ url: '/creatives/51',
92
+ creativeId: 51,
93
+ rel: 'nofollow',
94
+ target: '_blank',
95
+ title: 'Imported page',
96
+ })
97
+ imported.append($createTextNode('Imported'))
98
+ const paragraph = $createParagraphNode()
99
+ paragraph.append(imported)
100
+ $getRoot().append(paragraph)
101
+ cloned = CreativeLinkNode.clone(imported)
102
+ }, { discrete: true })
103
+
104
+ editor.getEditorState().read(() => {
105
+ expect(imported.exportJSON()).toMatchObject({
106
+ type: 'creative-link',
107
+ creativeId: 51,
108
+ url: '/creatives/51',
109
+ rel: 'nofollow',
110
+ target: '_blank',
111
+ title: 'Imported page',
112
+ })
113
+ expect($isCreativeLinkNode(imported)).toBe(true)
114
+ })
115
+ expect(cloned.__url).toBe('/creatives/51')
116
+ expect(cloned.__creativeId).toBe(51)
117
+ expect(cloned.__rel).toBe('nofollow')
118
+ expect($isCreativeLinkNode({})).toBe(false)
119
+ })
120
+
121
+ test('updates and removes the DOM creative id with the node state', () => {
122
+ const editor = createTestEditor()
123
+
124
+ editor.update(() => {
125
+ const paragraph = $createParagraphNode()
126
+ const linked = $createCreativeLinkNode('/creatives/61', 61)
127
+ const unlinked = $createCreativeLinkNode('/other', null)
128
+ paragraph.append(linked, unlinked)
129
+ $getRoot().append(paragraph)
130
+
131
+ const element = document.createElement('a')
132
+ linked.updateDOM(linked, element, {})
133
+ expect(element.dataset.creativeId).toBe('61')
134
+
135
+ unlinked.updateDOM(linked, element, {})
136
+ expect(element.hasAttribute('data-creative-id')).toBe(false)
137
+ }, { discrete: true })
138
+ })
139
+
140
+ test('imports data-creative-id without an href and keeps link attributes', () => {
141
+ const editor = createTestEditor()
142
+ let imported = null
143
+
144
+ editor.update(() => {
145
+ const doc = new DOMParser().parseFromString(
146
+ '<p><a data-creative-id="88" rel="nofollow" target="_blank" title="Page">Existing</a></p>',
147
+ 'text/html',
148
+ )
149
+ const nodes = $generateNodesFromDOM(editor, doc.body)
150
+ nodes.forEach((node) => $getRoot().append(node))
151
+ imported = $getRoot().getFirstChild().getFirstChild()
152
+ }, { discrete: true })
153
+
154
+ editor.getEditorState().read(() => {
155
+ expect(imported.getURL()).toBe('/creatives/88')
156
+ expect(imported.getCreativeId()).toBe(88)
157
+ expect(imported.getRel()).toBe('nofollow')
158
+ expect(imported.getTarget()).toBe('_blank')
159
+ expect(imported.getTitle()).toBe('Page')
160
+ })
161
+ })
162
+ })
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import {
7
+ $createParagraphNode,
8
+ $createTextNode,
9
+ $getRoot,
10
+ createEditor,
11
+ } from 'lexical'
12
+ import { LinkNode } from '@lexical/link'
13
+ import { CreativeLinkNode } from '../creative_link_node'
14
+ import { registerCreativeLinkTrigger } from '../creative_link_trigger'
15
+ import { lexicalToMarkdown } from '../markdown_serialize'
16
+
17
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
18
+
19
+ function createTestEditor() {
20
+ const editor = createEditor({
21
+ namespace: 'creative-link-trigger-test',
22
+ nodes: [LinkNode, CreativeLinkNode],
23
+ onError(error) {
24
+ throw error
25
+ },
26
+ })
27
+ const root = document.createElement('div')
28
+ root.contentEditable = 'true'
29
+ document.body.appendChild(root)
30
+ editor.setRootElement(root)
31
+ return editor
32
+ }
33
+
34
+ describe('creative link [[ trigger', () => {
35
+ afterEach(() => {
36
+ document.body.innerHTML = ''
37
+ })
38
+
39
+ test('opens once and replaces the trigger with a canonical creative link', async () => {
40
+ const editor = createTestEditor()
41
+ const openPicker = jest.fn(() => true)
42
+ const unregister = registerCreativeLinkTrigger(editor, openPicker)
43
+
44
+ editor.update(() => {
45
+ const paragraph = $createParagraphNode()
46
+ const text = $createTextNode('Before [[')
47
+ paragraph.append(text)
48
+ $getRoot().append(paragraph)
49
+ text.selectEnd()
50
+ }, { discrete: true })
51
+ await flush()
52
+
53
+ expect(openPicker).toHaveBeenCalledTimes(1)
54
+ openPicker.mock.calls[0][0].onSelect({ id: 99, label: 'Target page' })
55
+
56
+ expect(lexicalToMarkdown(editor)).toBe('Before [Target page](/creatives/99)')
57
+ expect(openPicker).toHaveBeenCalledTimes(1)
58
+ unregister()
59
+ })
60
+
61
+ test('keeps the trigger when the picker cannot open', async () => {
62
+ const editor = createTestEditor()
63
+ const openPicker = jest.fn(() => false)
64
+ const unregister = registerCreativeLinkTrigger(editor, openPicker)
65
+
66
+ editor.update(() => {
67
+ const paragraph = $createParagraphNode()
68
+ const text = $createTextNode('[[')
69
+ paragraph.append(text)
70
+ $getRoot().append(paragraph)
71
+ text.selectEnd()
72
+ }, { discrete: true })
73
+ await flush()
74
+
75
+ expect(openPicker).toHaveBeenCalledTimes(1)
76
+ expect(lexicalToMarkdown(editor)).toBe('[[')
77
+ unregister()
78
+ })
79
+
80
+ test('does not immediately reopen a dismissed trigger', async () => {
81
+ const editor = createTestEditor()
82
+ const openPicker = jest.fn(() => true)
83
+ const unregister = registerCreativeLinkTrigger(editor, openPicker)
84
+
85
+ let textNode = null
86
+ editor.update(() => {
87
+ const paragraph = $createParagraphNode()
88
+ textNode = $createTextNode('[[')
89
+ paragraph.append(textNode)
90
+ $getRoot().append(paragraph)
91
+ textNode.selectEnd()
92
+ }, { discrete: true })
93
+ await flush()
94
+
95
+ openPicker.mock.calls[0][0].onClose()
96
+ editor.update(() => textNode.getLatest().selectEnd(), { discrete: true })
97
+ await flush()
98
+
99
+ expect(openPicker).toHaveBeenCalledTimes(1)
100
+ unregister()
101
+ })
102
+
103
+ test('clears a dismissed trigger after editing away and opens for a new trigger', async () => {
104
+ const editor = createTestEditor()
105
+ const openPicker = jest.fn(() => true)
106
+ const unregister = registerCreativeLinkTrigger(editor, openPicker)
107
+
108
+ let textNode = null
109
+ editor.update(() => {
110
+ const paragraph = $createParagraphNode()
111
+ textNode = $createTextNode('[[')
112
+ paragraph.append(textNode)
113
+ $getRoot().append(paragraph)
114
+ textNode.selectEnd()
115
+ }, { discrete: true })
116
+ await flush()
117
+ openPicker.mock.calls[0][0].onClose()
118
+
119
+ editor.update(() => {
120
+ textNode.getLatest().setTextContent('plain')
121
+ textNode.getLatest().selectEnd()
122
+ }, { discrete: true })
123
+ await flush()
124
+ editor.update(() => {
125
+ textNode.getLatest().setTextContent('plain [[')
126
+ textNode.getLatest().selectEnd()
127
+ }, { discrete: true })
128
+ await flush()
129
+
130
+ expect(openPicker).toHaveBeenCalledTimes(2)
131
+ unregister()
132
+ })
133
+ })