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
@@ -41,9 +41,11 @@ describe('CommonPopup bounds clamping', () => {
41
41
  const popup = build({ popupWidth: 320, popupHeight: 400 })
42
42
  popup._boundsElement = boundsElement
43
43
  popup.updatePosition({ left: 200, bottom: 200 })
44
- // inner box = dimension - 2*pad
44
+ // inner box = width - 2*pad
45
45
  expect(element.style.maxWidth).toBe('384px')
46
- expect(element.style.maxHeight).toBe('584px')
46
+ // Height is capped to the space under the anchor rather than the whole
47
+ // box (700 - 8 - 204), so content arriving later cannot spill past it.
48
+ expect(element.style.maxHeight).toBe('488px')
47
49
  })
48
50
 
49
51
  test('falls back to viewport clamping when no bounds element is set', () => {
@@ -52,7 +54,10 @@ describe('CommonPopup bounds clamping', () => {
52
54
  // jsdom viewport defaults 1024x768; anchor at 0 → left clamps to padding 8, parent 0
53
55
  Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
54
56
  popup.updatePosition({ left: 0, bottom: 0 })
55
- expect(element.style.maxHeight).toBe('')
57
+ // Space below the anchor inside the viewport: 768 - 8 - 4.
58
+ expect(element.style.maxHeight).toBe('756px')
56
59
  expect(element.style.left).toBe('8px')
60
+ // maxWidth stays a stylesheet concern when unbounded.
61
+ expect(element.style.maxWidth).toBe('')
57
62
  })
58
63
  })
@@ -0,0 +1,374 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+ import CommonPopup, { elementAnchor } from '../common_popup'
6
+
7
+ // Viewport-fit placement: a popup anchored to a caret sitting just above the
8
+ // chat composer has almost no room below it. updatePosition must flip above the
9
+ // anchor, cap the popup to the space it actually has, and re-run once async
10
+ // content (the creative mini-tree) has replaced the "Loading…" placeholder.
11
+ describe('CommonPopup viewport-fit placement', () => {
12
+ const VIEWPORT_WIDTH = 1024
13
+
14
+ let element
15
+
16
+ // offsetHeight is mutable so a test can simulate the popup growing after its
17
+ // async content lands, which is the whole point of reposition().
18
+ const build = ({ popupWidth = 320, popupHeight = 400 } = {}) => {
19
+ element = document.createElement('div')
20
+ Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
21
+ Object.defineProperty(element, 'offsetWidth', { get: () => popupWidth, configurable: true })
22
+ Object.defineProperty(element, 'offsetHeight', {
23
+ get: () => element._testHeight,
24
+ configurable: true,
25
+ })
26
+ element._testHeight = popupHeight
27
+ document.body.appendChild(element)
28
+ return new CommonPopup(element, { listElement: document.createElement('ul') })
29
+ }
30
+
31
+ const stubVisualViewport = (height) => {
32
+ const listeners = {}
33
+ window.visualViewport = {
34
+ offsetLeft: 0,
35
+ offsetTop: 0,
36
+ width: VIEWPORT_WIDTH,
37
+ height,
38
+ addEventListener: (type, fn) => { listeners[type] = fn },
39
+ removeEventListener: (type) => { delete listeners[type] },
40
+ }
41
+ return listeners
42
+ }
43
+
44
+ afterEach(() => {
45
+ document.body.innerHTML = ''
46
+ delete window.visualViewport
47
+ })
48
+
49
+ test('flips above the anchor when the popup does not fit below it', () => {
50
+ const popup = build({ popupHeight: 400 })
51
+ // Caret on the last line of a composer pinned to the bottom: only 48px
52
+ // of viewport under it, far less than the popup's 400px.
53
+ popup.updatePosition({ left: 200, top: 700, bottom: 720 })
54
+
55
+ // top = anchorTop - gap - height = 700 - 4 - 400
56
+ expect(element.style.top).toBe('296px')
57
+ // Space above = 700 - 4 - 8 = 688, and the popup fits in it untouched.
58
+ expect(element.style.maxHeight).toBe('688px')
59
+ })
60
+
61
+ test('stays below the anchor when there is room for the popup', () => {
62
+ const popup = build({ popupHeight: 400 })
63
+ popup.updatePosition({ left: 200, top: 80, bottom: 100 })
64
+
65
+ expect(element.style.top).toBe('104px')
66
+ // Capped to the space below (768 - 8 - 104) so later growth still fits.
67
+ expect(element.style.maxHeight).toBe('656px')
68
+ })
69
+
70
+ test('caps the popup to the larger side when it fits on neither', () => {
71
+ const popup = build({ popupHeight: 400 })
72
+ // spaceBelow = 768 - 8 - 564 = 196; spaceAbove = 360 - 4 - 8 = 348.
73
+ popup.updatePosition({ left: 200, top: 360, bottom: 560 })
74
+
75
+ expect(element.style.maxHeight).toBe('348px')
76
+ // Rendered height is now the cap, so the top edge sits at the padding.
77
+ expect(element.style.top).toBe('8px')
78
+ })
79
+
80
+ test('never shrinks the popup below a usable minimum', () => {
81
+ const popup = build({ popupHeight: 400 })
82
+ // Anchor pinned to the very bottom: nothing below, 8px above.
83
+ popup.updatePosition({ left: 200, top: 20, bottom: 760 })
84
+
85
+ expect(element.style.maxHeight).toBe('160px')
86
+ })
87
+
88
+ test('measures the popup unconstrained so a stale cap cannot stick', () => {
89
+ const popup = build({ popupHeight: 400 })
90
+ // A previous placement left a tiny cap behind.
91
+ element.style.maxHeight = '130px'
92
+ popup.updatePosition({ left: 200, top: 700, bottom: 720 })
93
+
94
+ // Had the 130px cap been read back as the popup's height, it would have
95
+ // "fit" below the caret and stayed pinned there.
96
+ expect(element.style.top).toBe('296px')
97
+ })
98
+
99
+ test('measures the visual viewport, not the layout viewport', () => {
100
+ // The on-screen keyboard covers the bottom 300px of a 768px window.
101
+ stubVisualViewport(468)
102
+ const popup = build({ popupHeight: 400 })
103
+ popup.updatePosition({ left: 200, top: 400, bottom: 420 })
104
+
105
+ // spaceBelow against the keyboard top = 468 - 8 - 424 = 36, so it flips;
106
+ // spaceAbove = 400 - 4 - 8 = 388, which becomes the cap.
107
+ expect(element.style.maxHeight).toBe('388px')
108
+ expect(element.style.top).toBe('8px')
109
+ })
110
+
111
+ describe('reposition after async content', () => {
112
+ let realRaf
113
+ let realCancelRaf
114
+
115
+ beforeEach(() => {
116
+ jest.useFakeTimers()
117
+ realRaf = window.requestAnimationFrame
118
+ realCancelRaf = window.cancelAnimationFrame
119
+ // Stubbed as a pair: the timer id doubles as the frame handle, so
120
+ // cancelAnimationFrame actually drops the pending callback the way
121
+ // the browser would. Stubbing only requestAnimationFrame would make
122
+ // cancellation silently no-op and hide regressions.
123
+ window.requestAnimationFrame = (cb) => setTimeout(cb, 0)
124
+ window.cancelAnimationFrame = (handle) => clearTimeout(handle)
125
+ })
126
+
127
+ afterEach(() => {
128
+ window.requestAnimationFrame = realRaf
129
+ window.cancelAnimationFrame = realCancelRaf
130
+ jest.useRealTimers()
131
+ })
132
+
133
+ const openAndSettle = (popup, anchorRect) => {
134
+ popup.showAt(anchorRect)
135
+ // showAt defers positioning by one animation frame.
136
+ jest.advanceTimersByTime(20)
137
+ }
138
+
139
+ test('re-places the popup once its content has grown', () => {
140
+ const popup = build({ popupHeight: 400 })
141
+ // Opens while still showing "Loading…": header + input only.
142
+ element._testHeight = 130
143
+ openAndSettle(popup, { left: 200, top: 700, bottom: 720 })
144
+ expect(element.style.top).toBe('566px')
145
+
146
+ // The mini-tree lands and the popup grows to its full height.
147
+ element._testHeight = 400
148
+ popup.reposition()
149
+
150
+ expect(element.style.top).toBe('296px')
151
+ })
152
+
153
+ test('keeps the popup above the anchor once flipped, even if it later shrinks', () => {
154
+ const popup = build({ popupHeight: 400 })
155
+ openAndSettle(popup, { left: 200, top: 700, bottom: 720 })
156
+ expect(element.style.top).toBe('296px')
157
+
158
+ // Collapsing every tree node shrinks the popup back to a size that
159
+ // would fit below — hopping across the caret would be jarring.
160
+ element._testHeight = 120
161
+ popup.reposition()
162
+
163
+ expect(element.style.top).toBe('576px')
164
+ })
165
+
166
+ test('does nothing when the popup is closed', () => {
167
+ const popup = build({ popupHeight: 400 })
168
+ popup.reposition()
169
+ expect(element.style.top).toBe('')
170
+ })
171
+
172
+ test('re-places on visual viewport resize while open, and unbinds on hide', () => {
173
+ const listeners = stubVisualViewport(768)
174
+ const popup = build({ popupHeight: 400 })
175
+ openAndSettle(popup, { left: 200, top: 300, bottom: 320 })
176
+ // spaceBelow = 768 - 8 - 324 = 436, enough for 400px.
177
+ expect(element.style.top).toBe('324px')
178
+ expect(element.style.maxHeight).toBe('436px')
179
+ expect(typeof listeners.resize).toBe('function')
180
+
181
+ // The on-screen keyboard opens and eats the bottom of the viewport.
182
+ window.visualViewport.height = 400
183
+ listeners.resize()
184
+
185
+ // spaceBelow = 400 - 8 - 324 = 68; spaceAbove = 300 - 4 - 8 = 288.
186
+ expect(element.style.maxHeight).toBe('288px')
187
+ expect(element.style.top).toBe('8px')
188
+
189
+ popup.hide()
190
+ expect(listeners.resize).toBeUndefined()
191
+ })
192
+
193
+ // The placement frame runs a tick after showAt, so a close that lands in
194
+ // between must not leave document/visualViewport holding this popup:
195
+ // hide()'s removeEventListener calls run before the additions would.
196
+ test('registers nothing when the popup is closed before its placement frame runs', () => {
197
+ const listeners = stubVisualViewport(768)
198
+ const addSpy = jest.spyOn(document, 'addEventListener')
199
+ const popup = build({ popupHeight: 400 })
200
+
201
+ popup.showAt({ left: 200, top: 300, bottom: 320 })
202
+ popup.hide()
203
+ jest.advanceTimersByTime(20)
204
+
205
+ expect(listeners.resize).toBeUndefined()
206
+ expect(addSpy.mock.calls.some(([type]) => type === 'mousedown' || type === 'touchstart')).toBe(false)
207
+ // The cancelled frame must not re-show what hide() closed.
208
+ expect(element.style.display).toBe('none')
209
+ addSpy.mockRestore()
210
+ })
211
+
212
+ // Turbo swaps the page without closing the popup: the element is gone
213
+ // from the document but still reads display:block.
214
+ test('registers nothing when the element is detached before its placement frame runs', () => {
215
+ const listeners = stubVisualViewport(768)
216
+ const addSpy = jest.spyOn(document, 'addEventListener')
217
+ const popup = build({ popupHeight: 400 })
218
+
219
+ popup.showAt({ left: 200, top: 300, bottom: 320 })
220
+ element.remove()
221
+ jest.advanceTimersByTime(20)
222
+
223
+ expect(listeners.resize).toBeUndefined()
224
+ expect(addSpy.mock.calls.some(([type]) => type === 'mousedown' || type === 'touchstart')).toBe(false)
225
+ addSpy.mockRestore()
226
+ })
227
+
228
+ // Reopening before the first frame fires must drop the superseded one,
229
+ // so the popup is never placed against the anchor it has abandoned.
230
+ test('supersedes a pending placement frame when reopened', () => {
231
+ const popup = build({ popupHeight: 400 })
232
+ const placeSpy = jest.spyOn(popup, 'updatePosition')
233
+
234
+ popup.showAt({ left: 200, top: 300, bottom: 320 })
235
+ popup.showAt({ left: 200, top: 700, bottom: 720 })
236
+ jest.advanceTimersByTime(20)
237
+
238
+ expect(placeSpy).toHaveBeenCalledTimes(1)
239
+ expect(placeSpy).toHaveBeenCalledWith({ left: 200, top: 700, bottom: 720 })
240
+ expect(element.style.top).toBe('296px')
241
+ placeSpy.mockRestore()
242
+ })
243
+ })
244
+
245
+ // A rect captured at open time stops describing the anchor as soon as the
246
+ // anchor moves, and the one event that reliably moves it is the same one
247
+ // that triggers a re-place: the on-screen keyboard shrinks the viewport and
248
+ // comments--presence pushes the composer up off the keyboard on that very
249
+ // resize. Callers that can re-measure pass a provider instead of a rect.
250
+ describe('live anchor providers', () => {
251
+ let realRaf
252
+ let realCancelRaf
253
+
254
+ beforeEach(() => {
255
+ jest.useFakeTimers()
256
+ realRaf = window.requestAnimationFrame
257
+ realCancelRaf = window.cancelAnimationFrame
258
+ window.requestAnimationFrame = (cb) => setTimeout(cb, 0)
259
+ window.cancelAnimationFrame = (handle) => clearTimeout(handle)
260
+ })
261
+
262
+ afterEach(() => {
263
+ window.requestAnimationFrame = realRaf
264
+ window.cancelAnimationFrame = realCancelRaf
265
+ jest.useRealTimers()
266
+ })
267
+
268
+ const openAndSettle = (popup, anchor) => {
269
+ popup.showAt(anchor)
270
+ jest.advanceTimersByTime(20)
271
+ }
272
+
273
+ test('re-measures the anchor on every reposition', () => {
274
+ const popup = build({ popupHeight: 300 })
275
+ let caret = { left: 200, top: 300, bottom: 320 }
276
+ openAndSettle(popup, () => caret)
277
+ expect(element.style.top).toBe('324px')
278
+
279
+ // The composer scrolled: the caret is 120px higher than at open.
280
+ caret = { left: 200, top: 180, bottom: 200 }
281
+ popup.reposition()
282
+
283
+ expect(element.style.top).toBe('204px')
284
+ })
285
+
286
+ test('follows the anchor the keyboard pushed up, not its opening rect', () => {
287
+ const listeners = stubVisualViewport(768)
288
+ const popup = build({ popupHeight: 300 })
289
+ // Caret on a composer pinned to the bottom of a full-height viewport.
290
+ let caret = { left: 200, top: 700, bottom: 720 }
291
+ openAndSettle(popup, () => caret)
292
+ expect(element.style.top).toBe('396px')
293
+
294
+ // The keyboard takes the bottom 368px. comments--presence lifts the
295
+ // composer clear of it on the same resize, so the caret rides up
296
+ // with it — its opening rect now sits *under* the keyboard.
297
+ window.visualViewport.height = 400
298
+ caret = { left: 200, top: 332, bottom: 352 }
299
+ listeners.resize()
300
+
301
+ // Flipped above the lifted caret: 332 - 4 - 300.
302
+ expect(element.style.top).toBe('28px')
303
+ })
304
+
305
+ test('keeps the last measured rect when the anchor disappears', () => {
306
+ const popup = build({ popupHeight: 300 })
307
+ let caret = { left: 200, top: 300, bottom: 320 }
308
+ openAndSettle(popup, () => caret)
309
+ expect(element.style.top).toBe('324px')
310
+
311
+ // Turbo tore the textarea out: nothing left to measure. Reading a
312
+ // detached element's rect would report all zeros and fling the
313
+ // popup into the top-left corner.
314
+ caret = null
315
+ popup.reposition()
316
+
317
+ expect(element.style.top).toBe('324px')
318
+ })
319
+
320
+ test('keeps the last measured rect when the provider throws', () => {
321
+ const popup = build({ popupHeight: 300 })
322
+ let broken = false
323
+ openAndSettle(popup, () => {
324
+ if (broken) throw new Error('anchor gone')
325
+ return { left: 200, top: 300, bottom: 320 }
326
+ })
327
+ expect(element.style.top).toBe('324px')
328
+
329
+ broken = true
330
+ expect(() => popup.reposition()).not.toThrow()
331
+ expect(element.style.top).toBe('324px')
332
+ })
333
+
334
+ test('drops the provider on hide so the next open cannot reuse it', () => {
335
+ const popup = build({ popupHeight: 300 })
336
+ const provider = jest.fn(() => ({ left: 200, top: 300, bottom: 320 }))
337
+ openAndSettle(popup, provider)
338
+ popup.hide()
339
+
340
+ const callsWhileOpen = provider.mock.calls.length
341
+ openAndSettle(popup, { left: 200, top: 100, bottom: 120 })
342
+ popup.reposition()
343
+
344
+ expect(provider).toHaveBeenCalledTimes(callsWhileOpen)
345
+ expect(element.style.top).toBe('124px')
346
+ })
347
+
348
+ describe('elementAnchor', () => {
349
+ test('measures the element live', () => {
350
+ const anchor = document.createElement('button')
351
+ document.body.appendChild(anchor)
352
+ anchor.getBoundingClientRect = () => ({ left: 10, top: 500, bottom: 540 })
353
+ const provider = elementAnchor(anchor)
354
+
355
+ expect(provider().top).toBe(500)
356
+ anchor.getBoundingClientRect = () => ({ left: 10, top: 200, bottom: 240 })
357
+ expect(provider().top).toBe(200)
358
+ })
359
+
360
+ test('returns nothing once the element has left the document', () => {
361
+ const anchor = document.createElement('button')
362
+ document.body.appendChild(anchor)
363
+ const provider = elementAnchor(anchor)
364
+ anchor.remove()
365
+
366
+ expect(provider()).toBeNull()
367
+ })
368
+
369
+ test('returns nothing when there is no element at all', () => {
370
+ expect(elementAnchor(null)()).toBeNull()
371
+ })
372
+ })
373
+ })
374
+ })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+ import CommonPopup from '../common_popup'
6
+
7
+ // Typeahead menus (command menu, mention menu) call showAt on every keystroke to
8
+ // re-anchor a popup that is already on screen. showAt used to blank the element
9
+ // to visibility:hidden and wait a frame before revealing it again, so the menu
10
+ // blinked off and back on once per character typed.
11
+ describe('CommonPopup re-show while already open', () => {
12
+ let element
13
+ let frames
14
+
15
+ const build = ({ popupHeight = 100 } = {}) => {
16
+ element = document.createElement('div')
17
+ Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
18
+ Object.defineProperty(element, 'offsetWidth', { get: () => 320, configurable: true })
19
+ Object.defineProperty(element, 'offsetHeight', { get: () => popupHeight, configurable: true })
20
+ document.body.appendChild(element)
21
+ return new CommonPopup(element, { listElement: document.createElement('ul') })
22
+ }
23
+
24
+ const runFrames = () => {
25
+ const pending = frames.splice(0)
26
+ pending.forEach((fn) => fn())
27
+ }
28
+
29
+ beforeEach(() => {
30
+ frames = []
31
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation((fn) => {
32
+ frames.push(fn)
33
+ return frames.length
34
+ })
35
+ jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { })
36
+ global.requestAnimationFrame = window.requestAnimationFrame
37
+ global.cancelAnimationFrame = window.cancelAnimationFrame
38
+ })
39
+
40
+ afterEach(() => {
41
+ jest.restoreAllMocks()
42
+ document.body.innerHTML = ''
43
+ })
44
+
45
+ test('the first open hides the popup for a frame while it is measured', () => {
46
+ const popup = build()
47
+ popup.showAt({ left: 10, top: 100, bottom: 120 })
48
+
49
+ expect(element.style.display).toBe('block')
50
+ expect(element.style.visibility).toBe('hidden')
51
+
52
+ runFrames()
53
+ expect(element.style.visibility).toBe('visible')
54
+ })
55
+
56
+ test('re-anchoring an open popup never blanks it', () => {
57
+ const popup = build()
58
+ popup.showAt({ left: 10, top: 100, bottom: 120 })
59
+ runFrames()
60
+
61
+ const seen = []
62
+ for (const left of [20, 30, 40, 50]) {
63
+ popup.showAt({ left, top: 100, bottom: 120 })
64
+ seen.push(element.style.visibility)
65
+ }
66
+
67
+ expect(seen).toEqual(['visible', 'visible', 'visible', 'visible'])
68
+ expect(element.style.display).toBe('block')
69
+ })
70
+
71
+ test('re-anchoring places the popup immediately instead of a frame late', () => {
72
+ const popup = build()
73
+ popup.showAt({ left: 10, top: 100, bottom: 120 })
74
+ runFrames()
75
+ expect(element.style.left).toBe('10px')
76
+
77
+ // No frame is run: the new position must already be applied.
78
+ popup.showAt({ left: 240, top: 100, bottom: 120 })
79
+ expect(element.style.left).toBe('240px')
80
+ })
81
+
82
+ test('a popup flipped above the caret stays above when re-anchored', () => {
83
+ // 400px popup with the caret pinned near the bottom of a 768px viewport:
84
+ // it cannot fit below, so the first placement flips it above.
85
+ const popup = build({ popupHeight: 400 })
86
+ window.visualViewport = {
87
+ offsetLeft: 0, offsetTop: 0, width: 1024, height: 768,
88
+ addEventListener: () => { }, removeEventListener: () => { },
89
+ }
90
+
91
+ popup.showAt({ left: 200, top: 700, bottom: 720 })
92
+ runFrames()
93
+ expect(popup._placedAbove).toBe(true)
94
+ const flippedTop = element.style.top
95
+
96
+ popup.showAt({ left: 210, top: 700, bottom: 720 })
97
+ expect(popup._placedAbove).toBe(true)
98
+ expect(element.style.top).toBe(flippedTop)
99
+
100
+ delete window.visualViewport
101
+ })
102
+
103
+ test('re-registers the outside-click listeners so a re-open cannot self-close', () => {
104
+ const addSpy = jest.spyOn(document, 'addEventListener')
105
+ const removeSpy = jest.spyOn(document, 'removeEventListener')
106
+ const popup = build()
107
+
108
+ popup.showAt({ left: 10, top: 100, bottom: 120 })
109
+ runFrames()
110
+ popup.showAt({ left: 20, top: 100, bottom: 120 })
111
+
112
+ expect(removeSpy).toHaveBeenCalledWith('mousedown', popup.handleOutsideClick)
113
+ runFrames()
114
+ expect(addSpy).toHaveBeenCalledWith('mousedown', popup.handleOutsideClick)
115
+ expect(popup.isOpen()).toBe(true)
116
+ })
117
+
118
+ test('hide() after a re-anchor still closes the popup', () => {
119
+ const popup = build()
120
+ popup.showAt({ left: 10, top: 100, bottom: 120 })
121
+ runFrames()
122
+ popup.showAt({ left: 20, top: 100, bottom: 120 })
123
+ popup.hide()
124
+
125
+ expect(element.style.display).toBe('none')
126
+ expect(popup.isOpen()).toBe(false)
127
+ // _placedAbove is recomputed by the next genuine open.
128
+ popup.showAt({ left: 10, top: 100, bottom: 120 })
129
+ expect(element.style.visibility).toBe('hidden')
130
+ })
131
+ })
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { isCreativeVisitNavigation } from "../creative_link_prefetch"
5
+
6
+ function prefetch(link) {
7
+ const event = new Event("turbo:before-prefetch", { bubbles: true, cancelable: true })
8
+ link.dispatchEvent(event)
9
+ return event
10
+ }
11
+
12
+ afterEach(() => {
13
+ document.body.innerHTML = ""
14
+ })
15
+
16
+ test("prevents prefetching any same-origin Creative navigation link", () => {
17
+ const links = [ "/creatives/1", "/collavre/creatives/2", "/workspace/creatives?id=3" ].map((href) => {
18
+ const link = document.createElement("a")
19
+ link.href = href
20
+ document.body.appendChild(link)
21
+ return link
22
+ })
23
+
24
+ links.forEach((link) => expect(prefetch(link).defaultPrevented).toBe(true))
25
+ })
26
+
27
+ test("does not prevent prefetching unrelated or external links", () => {
28
+ const unrelated = document.createElement("a")
29
+ unrelated.href = "/users/1"
30
+ const external = document.createElement("a")
31
+ external.href = "https://example.com/creatives/1"
32
+ document.body.append(unrelated, external)
33
+
34
+ expect(prefetch(unrelated).defaultPrevented).toBe(false)
35
+ expect(prefetch(external).defaultPrevented).toBe(false)
36
+ })
37
+
38
+ test("identifies only Creative index and show routes as visit navigations", () => {
39
+ [ "/creatives", "/creatives.html", "/collavre/creatives/2" ].forEach((url) => {
40
+ expect(isCreativeVisitNavigation(url)).toBe(true)
41
+ })
42
+
43
+ ;[ "/creatives/2/comments/3/activity_log", "/creatives/next_last_visited_sequence" ].forEach((url) => {
44
+ expect(isCreativeVisitNavigation(url)).toBe(false)
45
+ })
46
+ })
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ const csrfFetch = jest.fn()
8
+ const refreshCsrfToken = jest.fn()
9
+
10
+ jest.unstable_mockModule('../api/csrf_fetch', () => ({
11
+ default: csrfFetch,
12
+ refreshCsrfToken,
13
+ }))
14
+
15
+ const {
16
+ prepareLastVisitedCreativeNavigation,
17
+ rememberLastVisitedCreative,
18
+ } = await import('../last_visited_creative')
19
+
20
+ describe('rememberLastVisitedCreative', () => {
21
+ beforeEach(() => {
22
+ csrfFetch.mockReset()
23
+ refreshCsrfToken.mockReset()
24
+ })
25
+
26
+ test('cancels the CSRF refresh when a newer visit supersedes a restored visit', async () => {
27
+ csrfFetch
28
+ .mockResolvedValue({ ok: true, json: async () => ({ sequence: 3 }) })
29
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ sequence: 2 }) })
30
+ .mockResolvedValueOnce({ ok: false, status: 422 })
31
+ refreshCsrfToken.mockImplementationOnce(({ signal }) => new Promise((resolve) => {
32
+ signal.addEventListener('abort', resolve, { once: true })
33
+ }))
34
+
35
+ rememberLastVisitedCreative('/creatives', 1, 'restored-token')
36
+ await new Promise((resolve) => setTimeout(resolve, 0))
37
+ rememberLastVisitedCreative('/creatives', 2, 'newer-token')
38
+
39
+ expect(refreshCsrfToken).toHaveBeenCalledWith(expect.objectContaining({ signal: expect.any(AbortSignal) }))
40
+ expect(csrfFetch).toHaveBeenCalledWith('/creatives/next_last_visited_sequence', expect.objectContaining({ method: 'PATCH' }))
41
+ })
42
+
43
+ test('refreshes CSRF and retries sequence reservation before remembering a restored Creative', async () => {
44
+ csrfFetch
45
+ .mockResolvedValueOnce({ ok: false, status: 422 })
46
+ .mockResolvedValueOnce({ ok: true, json: async () => ({ sequence: 2 }) })
47
+ .mockResolvedValueOnce({ ok: true })
48
+
49
+ rememberLastVisitedCreative('/creatives', 1, 'restored-token')
50
+ await new Promise((resolve) => setTimeout(resolve, 0))
51
+
52
+ expect(refreshCsrfToken).toHaveBeenCalledWith(expect.objectContaining({ signal: expect.any(AbortSignal) }))
53
+ expect(csrfFetch).toHaveBeenNthCalledWith(1, '/creatives/next_last_visited_sequence', expect.objectContaining({ method: 'PATCH' }))
54
+ expect(csrfFetch).toHaveBeenNthCalledWith(2, '/creatives/next_last_visited_sequence', expect.objectContaining({ method: 'PATCH' }))
55
+ expect(csrfFetch).toHaveBeenNthCalledWith(3, '/creatives/1/remember_last_visited?visit_token=restored-token', expect.objectContaining({ method: 'PATCH' }))
56
+ })
57
+
58
+ test('does not reserve a sequence for an unrelated Turbo navigation', async () => {
59
+ const fetchOptions = { method: 'GET', headers: new Headers() }
60
+ const resume = jest.fn()
61
+ const event = new CustomEvent('turbo:before-fetch-request', {
62
+ cancelable: true,
63
+ detail: { fetchOptions, resume, url: '/users' },
64
+ })
65
+
66
+ await prepareLastVisitedCreativeNavigation(event, '/creatives', 'server-token')
67
+
68
+ expect(event.defaultPrevented).toBe(false)
69
+ expect(resume).not.toHaveBeenCalled()
70
+ expect(csrfFetch).not.toHaveBeenCalled()
71
+ })
72
+
73
+ test('does not reserve a sequence for a nested Creative Turbo frame request', async () => {
74
+ const fetchOptions = { method: 'GET', headers: new Headers() }
75
+ const resume = jest.fn()
76
+ const event = new CustomEvent('turbo:before-fetch-request', {
77
+ cancelable: true,
78
+ detail: { fetchOptions, resume, url: '/creatives/1/comments/2/activity_log' },
79
+ })
80
+
81
+ await prepareLastVisitedCreativeNavigation(event, '/creatives', 'server-token')
82
+
83
+ expect(event.defaultPrevented).toBe(false)
84
+ expect(resume).not.toHaveBeenCalled()
85
+ expect(csrfFetch).not.toHaveBeenCalled()
86
+ })
87
+ })