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,534 +0,0 @@
1
- // Inline typo correction for the chat composer (Phase 1).
2
- //
3
- // Wires the chat textarea to the server endpoint and paints a *volatile*
4
- // highlight overlay: a mirror "backdrop" div sitting exactly behind the textarea
5
- // with <mark> spans over each edit span. The textarea value stays the single
6
- // source of truth — the backdrop is aria-hidden and never serialized, so
7
- // markdown-canonical storage never sees a highlight.
8
- //
9
- // Two highlight states (shape + colour, for colour-blind safety):
10
- // - auto-applied (confidence >= threshold): faint straight underline
11
- // - candidate (below threshold): wavy dotted underline (needs decision)
12
- //
13
- // Clicking a highlight opens a creatable combobox (reusing CommonPopup for
14
- // positioning + list + keyboard nav) pre-filled with the word currently in the
15
- // document, so Enter = keep. Typing adds the typed word as an always-present,
16
- // auto-selected custom option.
17
-
18
- import CommonPopup from '../lib/common_popup'
19
- import csrfFetch from '../lib/api/csrf_fetch'
20
- import {
21
- detectDevice,
22
- shouldRun,
23
- buildCandidateList,
24
- anchorEdits,
25
- applyEditAt,
26
- shiftEditsAfter,
27
- partitionByThreshold,
28
- } from '../lib/typo_correction'
29
-
30
- const DEBOUNCE_MS = 600
31
- const PRINTABLE_KEYDOWN_TTL_MS = 50
32
-
33
- let initialized = false
34
-
35
- // Mirror the textarea's box metrics onto the backdrop so highlight spans land on
36
- // the exact glyphs. Only the properties that affect text layout are copied.
37
- const MIRRORED_STYLES = [
38
- 'boxSizing', 'width', 'height', 'paddingTop', 'paddingRight', 'paddingBottom',
39
- 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth',
40
- 'borderLeftWidth', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
41
- 'lineHeight', 'letterSpacing', 'textTransform', 'wordSpacing', 'textIndent',
42
- ]
43
-
44
- // Escape by round-tripping through textContent: the browser never reinterprets
45
- // it as markup, and it's the canonical DOM-text sanitizer (recognized as a
46
- // barrier by static XSS analysis) for the values we splice into the backdrop and
47
- // popup HTML — both of which carry user-typed / model-suggested text.
48
- function escapeHtml(value) {
49
- const el = document.createElement('div')
50
- el.textContent = String(value)
51
- return el.innerHTML
52
- }
53
-
54
- export class TypoCorrector {
55
- constructor(textarea, { settings, location = 'chat', endpoint = '/typo_corrections', getVoiceActive = () => false, labels = {} } = {}) {
56
- this.textarea = textarea
57
- this.settings = settings
58
- this.location = location
59
- this.endpoint = endpoint
60
- this.getVoiceActive = getVoiceActive
61
- this.labels = {
62
- keep: labels.keep || 'keep',
63
- custom: labels.custom || 'custom',
64
- inputLabel: labels.inputLabel || 'correction',
65
- }
66
-
67
- this.edits = [] // anchored edits currently painted: {start,end,original,suggestion,confidence,state}
68
- this.lastPrintableKeydownAt = null
69
- this.lastInputAt = null
70
- this.debounceTimer = null
71
- this.requestSeq = 0
72
-
73
- this._buildBackdrop()
74
- this._bind()
75
- }
76
-
77
- _buildBackdrop() {
78
- const ta = this.textarea
79
- const wrap = document.createElement('div')
80
- wrap.className = 'typo-input-wrap'
81
- ta.parentNode.insertBefore(wrap, ta)
82
-
83
- this.backdrop = document.createElement('div')
84
- this.backdrop.className = 'typo-backdrop'
85
- this.backdrop.setAttribute('aria-hidden', 'true')
86
- this.highlightLayer = document.createElement('div')
87
- this.highlightLayer.className = 'typo-highlights'
88
- this.backdrop.appendChild(this.highlightLayer)
89
-
90
- wrap.appendChild(this.backdrop)
91
- wrap.appendChild(ta)
92
- this._syncBackdropStyle()
93
- }
94
-
95
- _syncBackdropStyle() {
96
- const computed = getComputedStyle(this.textarea)
97
- MIRRORED_STYLES.forEach((prop) => {
98
- this.highlightLayer.style[prop] = computed[prop]
99
- })
100
- // The textarea's own border is transparent-mirrored via padding offsets.
101
- this.highlightLayer.style.borderColor = 'transparent'
102
- this.highlightLayer.style.borderStyle = 'solid'
103
- }
104
-
105
- _bind() {
106
- this._onKeydown = (event) => {
107
- // A printable keydown immediately preceding the input marks a physical key.
108
- if (event.key && event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
109
- this.lastPrintableKeydownAt = performance.now()
110
- }
111
- }
112
- this._onInput = () => {
113
- // Stamp the input time *now*, while the keydown→input relationship is still
114
- // fresh. Classifying at detect time (after the debounce) would read a
115
- // ~600ms gap and misread every physical keypress as a soft keyboard,
116
- // defeating the default-off physical-keyboard gate.
117
- this.lastInputAt = performance.now()
118
- this._syncScroll()
119
- this._repaint() // keep existing highlights aligned with edited text
120
- // Our own auto-apply dispatches a synthetic `input` so other listeners
121
- // (auto-resize, draft save, send-button state) update. We must NOT let it
122
- // re-trigger detection: the corrected text has no typos, so the follow-up
123
- // would return [] and wipe the just-applied underline/undo affordance
124
- // before the user can click it. Real keystrokes still schedule detection.
125
- if (this.suppressNextDetect) {
126
- this.suppressNextDetect = false
127
- return
128
- }
129
- this._scheduleDetect()
130
- }
131
- this._onScroll = () => this._syncScroll()
132
- // form.reset() (after a comment sends, or on cancel) empties the textarea
133
- // WITHOUT firing an `input` event, so our repaint never runs and stale marks
134
- // would linger over the now-empty composer — still clickable, applying stale
135
- // offsets to the empty value. The `reset` event fires before the control is
136
- // cleared, so drop the edits now and repaint next frame once it's empty.
137
- this._onReset = () => {
138
- clearTimeout(this.debounceTimer)
139
- this.requestSeq++ // invalidate any in-flight detection response
140
- this.edits = []
141
- requestAnimationFrame(() => this._repaint())
142
- }
143
-
144
- this.form = this.textarea.form
145
- this.textarea.addEventListener('keydown', this._onKeydown)
146
- this.textarea.addEventListener('input', this._onInput)
147
- this.textarea.addEventListener('scroll', this._onScroll)
148
- this.form?.addEventListener('reset', this._onReset)
149
- }
150
-
151
- destroy() {
152
- clearTimeout(this.debounceTimer)
153
- this.textarea.removeEventListener('keydown', this._onKeydown)
154
- this.textarea.removeEventListener('input', this._onInput)
155
- this.textarea.removeEventListener('scroll', this._onScroll)
156
- this.form?.removeEventListener('reset', this._onReset)
157
- // Drop the combobox popup: it lives on document.body, so without this a Turbo
158
- // page-cache snapshot serializes an orphan popup (stale options, no live
159
- // instance) and the next corrector appends a duplicate. hide() also detaches
160
- // CommonPopup's document-level outside-click listeners.
161
- this.popup?.hide()
162
- this.popupEl?.remove()
163
- this.popupEl = null
164
- this.popupInput = null
165
- this.popup = null
166
- // Unwrap the textarea and drop the injected backdrop so a Turbo page-cache
167
- // snapshot doesn't serialize stale overlay DOM (it would duplicate on the
168
- // next build) or the bind marker (a restored snapshot keeps typoBound=true
169
- // but loses the JS listeners, so re-init is skipped and correction stays
170
- // dead until a full reload).
171
- const wrap = this.backdrop?.parentNode
172
- if (wrap && wrap.parentNode) {
173
- wrap.parentNode.insertBefore(this.textarea, wrap)
174
- wrap.remove()
175
- }
176
- delete this.textarea.dataset.typoBound
177
- }
178
-
179
- _syncScroll() {
180
- this.backdrop.scrollTop = this.textarea.scrollTop
181
- this.backdrop.scrollLeft = this.textarea.scrollLeft
182
- }
183
-
184
- _currentDevice() {
185
- return detectDevice({
186
- voiceActive: !!this.getVoiceActive(),
187
- lastPrintableKeydownAt: this.lastPrintableKeydownAt,
188
- inputAt: this.lastInputAt,
189
- })
190
- }
191
-
192
- _scheduleDetect() {
193
- clearTimeout(this.debounceTimer)
194
- this.debounceTimer = setTimeout(() => this._detect(), DEBOUNCE_MS)
195
- }
196
-
197
- async _detect() {
198
- const device = this._currentDevice();
199
- // Reset the printable-keydown marker so a stale value can't mis-classify the
200
- // next standalone input (e.g. soft-keyboard insert after a physical keypress).
201
- if (this.lastPrintableKeydownAt != null && performance.now() - this.lastPrintableKeydownAt > PRINTABLE_KEYDOWN_TTL_MS) {
202
- this.lastPrintableKeydownAt = null
203
- }
204
-
205
- if (!shouldRun(this.settings, { device, location: this.location })) return
206
- const text = this.textarea.value
207
- if (!text.trim()) { this._clear(); return }
208
-
209
- const seq = ++this.requestSeq
210
- let payload
211
- try {
212
- const res = await csrfFetch(this.endpoint, {
213
- method: 'POST',
214
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
215
- body: JSON.stringify({ text, device, location: this.location }),
216
- })
217
- if (!res.ok) return
218
- payload = await res.json()
219
- } catch {
220
- return
221
- }
222
- if (seq !== this.requestSeq) return // a newer keystroke superseded this one
223
- // The text may have changed while the request was in flight; only act if the
224
- // edits still anchor to the *current* textarea value.
225
- if (this.textarea.value !== text) return
226
-
227
- this._applyResult(payload)
228
- }
229
-
230
- _applyResult({ edits = [], threshold = 80 } = {}) {
231
- const original = this.textarea.value
232
- const anchored = anchorEdits(original, edits)
233
- const { autoApplied, candidates } = partitionByThreshold(anchored, threshold)
234
-
235
- // This round's edits. `apply` rewrites the word; `keep` leaves the text as-is
236
- // (a low-confidence candidate awaiting a decision).
237
- const incoming = [
238
- ...autoApplied.map((e) => ({ ...e, kind: 'apply' })),
239
- ...candidates.map((e) => ({ ...e, kind: 'keep', state: 'candidate', currentValue: e.original })),
240
- ]
241
-
242
- // Carry forward earlier corrections that are still anchored in the live text
243
- // and don't collide with this round. The server is stateless and never
244
- // re-reports an already-corrected word, so without this every detection round
245
- // would wipe the prior correction's mark and leave only the most recent one
246
- // clickable. Already-applied survivors sit in the text unchanged, so they're
247
- // re-emitted in place (kind 'keep'), not re-applied.
248
- const survivors = this.edits
249
- .filter((p) => original.slice(p.start, p.end) === p.currentValue)
250
- .filter((p) => !incoming.some((n) => n.start < p.end && p.start < n.end))
251
- .map((p) => ({ ...p, kind: 'keep' }))
252
-
253
- // Walk all edits left-to-right in one pass, rebuilding the text while
254
- // tracking a running delta. This assigns each tracked edit an *exact* final
255
- // span, rather than re-searching the corrected word by value afterwards —
256
- // that search would bind to an earlier identical word (e.g. correcting the
257
- // second "teh" in "the teh" lands the highlight on the first "the", so undo
258
- // would rewrite the wrong word).
259
- const events = [...incoming, ...survivors].sort((a, b) => a.start - b.start)
260
-
261
- const originalCaret = this.textarea.selectionStart
262
- let out = ''
263
- let cursor = 0
264
- let delta = 0
265
- let caret = originalCaret
266
- const tracked = []
267
- for (const e of events) {
268
- if (e.start < cursor) continue // overlapping span; skip defensively
269
- out += original.slice(cursor, e.start)
270
- const finalStart = e.start + delta
271
- if (e.kind === 'apply') {
272
- out += e.suggestion
273
- const d = e.suggestion.length - (e.end - e.start)
274
- if (e.start < originalCaret) caret += d
275
- delta += d
276
- tracked.push({
277
- original: e.original, suggestion: e.suggestion, confidence: e.confidence,
278
- state: 'applied', currentValue: e.suggestion,
279
- start: finalStart, end: finalStart + e.suggestion.length,
280
- })
281
- } else {
282
- // 'keep': a new candidate or a carried-forward survivor — text unchanged,
283
- // so re-emit the current slice and preserve its existing state.
284
- const slice = original.slice(e.start, e.end)
285
- out += slice
286
- tracked.push({
287
- original: e.original, suggestion: e.suggestion, confidence: e.confidence,
288
- state: e.state, currentValue: e.currentValue,
289
- start: finalStart, end: finalStart + slice.length,
290
- })
291
- }
292
- cursor = e.end
293
- }
294
- out += original.slice(cursor)
295
-
296
- this.edits = tracked
297
- if (out !== original) {
298
- this.textarea.value = out
299
- this.textarea.setSelectionRange(caret, caret)
300
- // Our own mutation: notify other listeners but don't re-run detection (it
301
- // would return [] on the now-clean text and wipe these fresh highlights).
302
- this.suppressNextDetect = true
303
- this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
304
- }
305
- this._repaint()
306
- }
307
-
308
- _clear() {
309
- this.edits = []
310
- this._repaint()
311
- }
312
-
313
- // Paint the backdrop: plain text with <mark> spans over each edit. Marks carry
314
- // the state class so CSS renders the two underline styles.
315
- _repaint() {
316
- // Drop edits that no longer match the live text (user kept typing).
317
- const text = this.textarea.value
318
- this.edits = this.edits.filter((e) => text.slice(e.start, e.end) === e.currentValue)
319
-
320
- if (this.edits.length === 0) {
321
- this.highlightLayer.textContent = text
322
- return
323
- }
324
- const sorted = [...this.edits].sort((a, b) => a.start - b.start)
325
- let html = ''
326
- let cursor = 0
327
- sorted.forEach((edit, i) => {
328
- if (edit.start < cursor) return // overlapping; skip
329
- html += escapeHtml(text.slice(cursor, edit.start))
330
- const cls = edit.state === 'applied' ? 'typo-mark typo-mark-applied' : 'typo-mark typo-mark-candidate'
331
- html += `<mark class="${cls}" data-typo-index="${i}">${escapeHtml(text.slice(edit.start, edit.end))}</mark>`
332
- cursor = edit.end
333
- })
334
- html += escapeHtml(text.slice(cursor))
335
- this.highlightLayer.innerHTML = html
336
- this._wireMarks(sorted)
337
- this._syncScroll()
338
- }
339
-
340
- // <mark> has pointer-events:auto (the rest of the backdrop is none), so clicks
341
- // land on a highlighted word and open the combobox there.
342
- _wireMarks(sorted) {
343
- this.highlightLayer.querySelectorAll('.typo-mark').forEach((mark) => {
344
- const idx = Number(mark.dataset.typoIndex)
345
- const edit = sorted[idx]
346
- if (!edit) return
347
- mark.addEventListener('mousedown', (event) => {
348
- event.preventDefault()
349
- this._openPopup(edit, mark)
350
- })
351
- })
352
- }
353
-
354
- _ensurePopup() {
355
- if (this.popupEl) return
356
- const el = document.createElement('div')
357
- el.className = 'typo-popup common-popup'
358
- el.style.display = 'none'
359
- el.innerHTML = `
360
- <input type="text" class="typo-popup-input" />
361
- <ul class="typo-popup-list" data-popup-list></ul>`
362
- document.body.appendChild(el)
363
- this.popupEl = el
364
- this.popupInput = el.querySelector('.typo-popup-input')
365
- // Localized assistive label (set via setAttribute, not innerHTML, so the
366
- // value is never parsed as markup).
367
- this.popupInput.setAttribute('aria-label', this.labels.inputLabel)
368
-
369
- this.popup = new CommonPopup(el, {
370
- listElement: el.querySelector('.typo-popup-list'),
371
- // item.label is user-typed / model-suggested text; escapeHtml round-trips
372
- // it through textContent so it can never be reinterpreted as markup.
373
- renderItem: (item) => {
374
- const tag = item.role === 'original' ? ` <span class="typo-popup-role">(${escapeHtml(this.labels.keep)})</span>`
375
- : item.role === 'custom' ? ` <span class="typo-popup-role">(${escapeHtml(this.labels.custom)})</span>` : ''
376
- return `<span class="typo-popup-value">${escapeHtml(item.label)}</span>${tag}`
377
- },
378
- onSelect: (item) => this._chooseValue(item.value),
379
- onClose: () => { this._activeEdit = null },
380
- })
381
-
382
- // Typing builds a creatable option and keeps input ↔ list bound.
383
- this.popupInput.addEventListener('input', () => this._refreshPopupItems(this.popupInput.value))
384
- this.popupInput.addEventListener('keydown', (event) => {
385
- if (event.key === 'Enter') {
386
- event.preventDefault()
387
- this._chooseValue(this.popupInput.value)
388
- return
389
- }
390
- if (this.popup.handleKey(event)) {
391
- // Sync the input to the active list item (arrow navigation).
392
- const active = this.popup.items[this.popup.activeIndex]
393
- if (active) this.popupInput.value = active.value
394
- }
395
- })
396
- }
397
-
398
- _refreshPopupItems(typed) {
399
- const edit = this._activeEdit
400
- if (!edit) return
401
- let items = buildCandidateList({
402
- currentValue: edit.currentValue,
403
- originalWord: edit.original,
404
- suggestions: [{ suggestion: edit.suggestion, confidence: edit.confidence }],
405
- })
406
- const trimmed = (typed || '').trim()
407
- if (trimmed && !items.some((i) => i.value === trimmed)) {
408
- // Creatable: the typed word is always present and auto-selected.
409
- items = [{ value: trimmed, label: trimmed, role: 'custom', isCurrent: false }, ...items]
410
- }
411
- this.popup.setItems(items)
412
- // Auto-select the typed custom entry, else the current document value.
413
- const selectIndex = trimmed
414
- ? Math.max(0, items.findIndex((i) => i.value === trimmed))
415
- : Math.max(0, items.findIndex((i) => i.isCurrent))
416
- this.popup.setActiveIndex(selectIndex)
417
- }
418
-
419
- _openPopup(edit, anchorEl) {
420
- this._ensurePopup()
421
- this._activeEdit = edit
422
- this.popupInput.value = edit.currentValue
423
- this._refreshPopupItems('')
424
- this.popup.showAt(anchorEl.getBoundingClientRect())
425
- // Desktop: focus + select-all so a keystroke immediately replaces the word.
426
- // Mobile keyboard is left to an explicit tap on the input (no auto-focus) —
427
- // chip taps are the primary path.
428
- // showAt() keeps the popup visibility:hidden until its own rAF (so it can
429
- // position off-screen first); focus()/select() are no-ops while hidden, so
430
- // defer them into a rAF that runs after showAt's (FIFO, registered later).
431
- if (!this._isCoarsePointer()) {
432
- requestAnimationFrame(() => {
433
- this.popupInput.focus()
434
- this.popupInput.select()
435
- })
436
- }
437
- }
438
-
439
- _isCoarsePointer() {
440
- return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches
441
- }
442
-
443
- _chooseValue(value) {
444
- const edit = this._activeEdit
445
- if (edit == null || value == null) { this.popup?.hide(); return }
446
- const replacement = String(value)
447
-
448
- if (replacement !== edit.currentValue) {
449
- const { text, delta } = applyEditAt(this.textarea.value, {
450
- start: edit.start, end: edit.end, replacement,
451
- })
452
- let caret = this.textarea.selectionStart
453
- if (edit.start < caret) caret += delta
454
- this.textarea.value = text
455
- this.textarea.setSelectionRange(caret, caret)
456
- // Shift later edits, then drop this one (it's now user-confirmed).
457
- this.edits = shiftEditsAfter(
458
- this.edits.filter((e) => e !== edit),
459
- edit.start,
460
- delta,
461
- )
462
- // Our own mutation: don't re-detect (it would overwrite the remaining
463
- // candidate highlights we just shifted to keep them aligned).
464
- this.suppressNextDetect = true
465
- this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
466
- } else {
467
- // "Keep" — user resolved it; remove the highlight.
468
- this.edits = this.edits.filter((e) => e !== edit)
469
- }
470
-
471
- this.popup?.hide()
472
- this._repaint()
473
- this.textarea.focus()
474
- }
475
- }
476
-
477
- // Module-scope init, mirroring mention_menu.js. Reads settings exposed by the
478
- // server on #comments-popup and only attaches when the master switch is on.
479
- function readSettings(root) {
480
- if (!root) return null
481
- const d = root.dataset
482
- if (d.typoEnabled == null) return null
483
- return {
484
- enabled: d.typoEnabled === 'true',
485
- threshold: parseInt(d.typoThreshold, 10) || 80,
486
- onVoice: d.typoOnVoice === 'true',
487
- onSoftKeyboard: d.typoOnSoftKeyboard === 'true',
488
- onPhysicalKeyboard: d.typoOnPhysicalKeyboard === 'true',
489
- inChat: d.typoInChat === 'true',
490
- inEditor: d.typoInEditor === 'true',
491
- }
492
- }
493
-
494
- let activeInstance = null
495
-
496
- export function initTypoCorrector() {
497
- const textarea = document.querySelector('#new-comment-form textarea')
498
- const root = document.getElementById('comments-popup')
499
- const settings = readSettings(root)
500
- if (!textarea || !settings || !settings.enabled) return null
501
- if (textarea.dataset.typoBound === 'true') return null
502
- textarea.dataset.typoBound = 'true'
503
-
504
- const voiceButton = document.getElementById('voice-comments-btn')
505
- activeInstance = new TypoCorrector(textarea, {
506
- settings,
507
- location: 'chat',
508
- // Use the mounted engine path (e.g. /collavre/typo_corrections) so requests
509
- // don't 404 when Collavre is mounted at a subpath instead of root.
510
- endpoint: root.dataset.typoEndpoint || '/typo_corrections',
511
- getVoiceActive: () => voiceButton?.dataset.voiceState === 'listening',
512
- labels: {
513
- keep: root.dataset.typoKeepLabel,
514
- custom: root.dataset.typoCustomLabel,
515
- inputLabel: root.dataset.typoInputLabel,
516
- },
517
- })
518
- return activeInstance
519
- }
520
-
521
- export function teardownTypoCorrector() {
522
- if (!activeInstance) return
523
- activeInstance.destroy()
524
- activeInstance = null
525
- }
526
-
527
- if (!initialized) {
528
- initialized = true
529
- document.addEventListener('turbo:load', initTypoCorrector)
530
- // Turbo caches a DOM snapshot on navigation but not the JS listeners. Tear the
531
- // instance down (clearing the bind marker + injected DOM) before the snapshot
532
- // is taken so a restored page re-initializes cleanly instead of skipping init.
533
- document.addEventListener('turbo:before-cache', teardownTypoCorrector)
534
- }
@@ -1,24 +0,0 @@
1
- module Collavre
2
- class InboxSummaryJob < ApplicationJob
3
- queue_as :default
4
-
5
- def perform
6
- Rails.logger.info("Running InboxSummaryJob")
7
- User.find_each do |user|
8
- if user.notifications_enabled == false
9
- Rails.logger.info("Skipping inbox summary for #{user.email} because notifications are disabled")
10
- next
11
- end
12
-
13
- items = InboxItem.where(owner: user, state: "new").order(created_at: :desc).limit(10)
14
- if items.empty?
15
- Rails.logger.info("No new inbox items for #{user.email}")
16
- next
17
- end
18
-
19
- Rails.logger.info("Sending inbox summary to #{user.email} with #{items.count} items")
20
- Collavre::InboxMailer.with(user: user, items: items).daily_summary.deliver_now
21
- end
22
- end
23
- end
24
- end
@@ -1,19 +0,0 @@
1
- module Collavre
2
- class InboxMailer < ApplicationMailer
3
- def daily_summary
4
- @user = params[:user]
5
- @items = params[:items]
6
- locale = @user.locale.presence || I18n.default_locale.to_s
7
- email = I18n.with_locale(locale) do
8
- mail to: @user.email, subject: I18n.t("collavre.inbox_mailer.daily_summary.subject")
9
- end
10
- Collavre::Email.create!(
11
- user: @user,
12
- email: @user.email,
13
- subject: email.subject,
14
- body: extract_body(email),
15
- event: :inbox_summary
16
- )
17
- end
18
- end
19
- end
@@ -1,89 +0,0 @@
1
- module Collavre
2
- class InboxItem < ApplicationRecord
3
- self.table_name = "inbox_items"
4
-
5
- # Use non-namespaced partial path for backward compatibility
6
- def to_partial_path
7
- "inbox_items/inbox_item"
8
- end
9
-
10
- INTERPOLATION_PATTERN = /%%|%\{([\w|]+)\}|%<(\w+)>[^\d]*?\d*\.?\d*[bBdiouxXeEfgGcps]/.freeze
11
-
12
- belongs_to :owner, class_name: Collavre.configuration.user_class_name
13
- belongs_to :comment, class_name: "Collavre::Comment", optional: true
14
- belongs_to :creative, class_name: "Collavre::Creative", optional: true
15
-
16
- after_commit :broadcast_badge_update, on: %i[create update destroy]
17
- after_create_commit :enqueue_push_notification
18
-
19
- enum :state, { new: "new", read: "read", archived: "archived" }, default: :new, scopes: false
20
- validates :message_key, presence: true
21
-
22
- # Kept explicit (not enum's generated `.new` class-scope, which collides with the constructor).
23
- scope :new_items, -> { where(state: "new") }
24
-
25
- def localized_message(locale: I18n.locale)
26
- msg =
27
- if message_key.present?
28
- params = message_params || {}
29
- translate_message(message_key, params.symbolize_keys, locale: locale)
30
- else
31
- message
32
- end
33
-
34
- msg&.gsub("&nbsp;", " ")&.gsub("\u00A0", " ")
35
- end
36
-
37
- private
38
-
39
- def translate_message(message_key, params, locale:)
40
- I18n.t(message_key, **params, locale: locale)
41
- rescue I18n::MissingInterpolationArgument => e
42
- missing_keys = extract_missing_keys(e.string, params)
43
- fallback_params =
44
- missing_keys.index_with do |missing_key|
45
- default_interpolation_value(missing_key, locale: locale)
46
- end
47
-
48
- I18n.t(message_key, **params.merge(fallback_params), locale: locale)
49
- end
50
-
51
- def extract_missing_keys(translation_string, params)
52
- interpolations =
53
- translation_string.to_s.scan(INTERPOLATION_PATTERN).map do |match|
54
- match.compact.first&.to_sym
55
- end
56
-
57
- interpolations.compact.uniq - params.keys
58
- end
59
-
60
- def default_interpolation_value(key, locale:)
61
- case key.to_sym
62
- when :comment_content
63
- I18n.t("collavre.inbox.comment_content_unavailable", locale: locale, default: "")
64
- else
65
- ""
66
- end
67
- end
68
-
69
- def broadcast_badge_update
70
- # Recompute the new count for this owner:
71
- new_count = InboxItem.where(owner: owner, state: "new").count
72
-
73
- # Use Turbo::StreamsChannel to broadcast replace to that user's inbox stream:
74
- %w[desktop-inbox-badge mobile-inbox-badge].each do |target_id|
75
- Turbo::StreamsChannel.broadcast_replace_to(
76
- [ "inbox", owner ],
77
- target: target_id,
78
- partial: "inbox/badge_component/count",
79
- locals: { count: new_count, badge_id: target_id, show_zero: false }
80
- )
81
- end
82
- end
83
-
84
- def enqueue_push_notification
85
- msg = localized_message(locale: owner.locale || "en")
86
- PushNotificationJob.perform_later(owner_id, message: msg, link: link)
87
- end
88
- end
89
- end