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
@@ -64,19 +64,21 @@ describe('CommentsPresenceController', () => {
64
64
  })
65
65
 
66
66
  expect(setCommentPermission).toHaveBeenCalledWith(false)
67
- expect(loadParticipantsSpy).toHaveBeenCalledWith({ closeOnForbidden: false })
67
+ expect(loadParticipantsSpy).toHaveBeenCalledWith()
68
68
  expect(close).not.toHaveBeenCalled()
69
69
  })
70
70
 
71
- test('closes popup when affected user loses access', async () => {
71
+ test('clears docked chat and invalidates the workspace tree when access is revoked', async () => {
72
72
  const close = jest.fn()
73
- jest.spyOn(controller, 'popupController', 'get').mockReturnValue({ close })
74
- jest.spyOn(controller, 'formController', 'get').mockReturnValue({ setCommentPermission: jest.fn() })
75
-
76
- global.fetch.mockResolvedValue({
77
- ok: false,
78
- json: async () => ({ error: 'No permission' }),
73
+ const resetDockedToEmpty = jest.fn()
74
+ const workspaceListener = jest.fn()
75
+ document.addEventListener('workspace-tree:invalidate', workspaceListener)
76
+ jest.spyOn(controller, 'popupController', 'get').mockReturnValue({
77
+ close,
78
+ isDocked: () => true,
79
+ resetDockedToEmpty,
79
80
  })
81
+ jest.spyOn(controller, 'formController', 'get').mockReturnValue({ setCommentPermission: jest.fn() })
80
82
 
81
83
  controller.handlePresenceMessage({
82
84
  shares_changed: {
@@ -93,7 +95,37 @@ describe('CommentsPresenceController', () => {
93
95
  // alertDialog renders an in-app modal (replacing native alert for the
94
96
  // Tauri webview); assert the message surfaced there instead.
95
97
  expect(document.querySelector('.confirm-dialog-message')?.textContent).toBe('No permission')
98
+ expect(resetDockedToEmpty).toHaveBeenCalled()
99
+ expect(close).not.toHaveBeenCalled()
100
+ expect(workspaceListener).toHaveBeenCalledTimes(1)
101
+ expect(workspaceListener).toHaveBeenCalledWith(expect.objectContaining({
102
+ detail: { creativeIds: ['123'] },
103
+ }))
104
+ expect(global.fetch).not.toHaveBeenCalled()
105
+ document.removeEventListener('workspace-tree:invalidate', workspaceListener)
106
+ })
107
+
108
+ test('closes a floating popup immediately when access is revoked', () => {
109
+ const close = jest.fn()
110
+ jest.spyOn(controller, 'popupController', 'get').mockReturnValue({
111
+ close,
112
+ isDocked: () => false,
113
+ resetDockedToEmpty: jest.fn(),
114
+ })
115
+ jest.spyOn(controller, 'formController', 'get').mockReturnValue({ setCommentPermission: jest.fn() })
116
+
117
+ controller.handlePresenceMessage({
118
+ shares_changed: {
119
+ user_id: 7,
120
+ has_access: false,
121
+ can_comment: false,
122
+ has_access_changed: true,
123
+ can_comment_changed: true,
124
+ },
125
+ })
126
+
96
127
  expect(close).toHaveBeenCalled()
128
+ expect(global.fetch).not.toHaveBeenCalled()
97
129
  })
98
130
 
99
131
  test('keeps popup open for unaffected users and only refreshes participants', () => {
@@ -121,6 +153,191 @@ describe('CommentsPresenceController', () => {
121
153
  expect(loadInitialComments).not.toHaveBeenCalled()
122
154
  expect(close).not.toHaveBeenCalled()
123
155
  })
156
+
157
+ test('bootstraps the selected and main topics when the popup opens', () => {
158
+ const refreshChannelChips = jest.spyOn(controller, 'refreshChannelChips').mockImplementation(() => {})
159
+ jest.spyOn(application, 'getControllerForElementAndIdentifier').mockReturnValue({
160
+ currentTopicId: '45',
161
+ mainTopicId: '10',
162
+ })
163
+
164
+ controller.bootstrapChannelChips()
165
+
166
+ expect(controller.selectedTopicId).toBe('45')
167
+ expect(controller.mainTopicId).toBe('10')
168
+ expect(refreshChannelChips).toHaveBeenCalledWith('45')
169
+ })
170
+
171
+ test('clears topic timers when the popup closes', () => {
172
+ const resetAgentActivity = jest.spyOn(controller, 'resetAgentActivity').mockImplementation(() => {})
173
+
174
+ controller.onPopupClosed()
175
+
176
+ expect(resetAgentActivity).toHaveBeenCalled()
177
+ })
178
+
179
+ test('sends typing lifecycle with the selected topic', () => {
180
+ const perform = jest.fn()
181
+ controller.presenceSubscription = { perform }
182
+ controller.selectedTopicId = '45'
183
+
184
+ controller.typing()
185
+ controller.stoppedTyping()
186
+
187
+ expect(perform).toHaveBeenNthCalledWith(1, 'typing', { topic_id: '45' })
188
+ expect(perform).toHaveBeenNthCalledWith(2, 'stopped_typing', { topic_id: '45' })
189
+ })
190
+
191
+ test('renews the presence lease while the subscription is connected', () => {
192
+ jest.useFakeTimers()
193
+ const perform = jest.fn()
194
+ controller.presenceSubscription = { perform }
195
+
196
+ controller.startPresenceHeartbeat()
197
+ jest.advanceTimersByTime(30000)
198
+ controller.stopPresenceHeartbeat()
199
+ jest.useRealTimers()
200
+
201
+ expect(perform).toHaveBeenCalledWith('heartbeat')
202
+ })
203
+
204
+ test('reports the rendered All Messages topic snapshot', () => {
205
+ const perform = jest.fn()
206
+ controller.presenceSubscription = { perform }
207
+ controller.selectedTopicId = null
208
+
209
+ controller.handleRenderedAllTopics({ detail: { creativeId: '123', topicIds: ['10', '11'] } })
210
+
211
+ expect(perform).toHaveBeenCalledWith('viewing_topic', {
212
+ topic_id: null,
213
+ rendered_topic_ids: ['10', '11'],
214
+ })
215
+ })
216
+
217
+ test('reports a rendered legacy lane with the All Messages snapshot', () => {
218
+ const perform = jest.fn()
219
+ controller.presenceSubscription = { perform }
220
+ controller.selectedTopicId = null
221
+
222
+ controller.handleRenderedAllTopics({ detail: { creativeId: '123', topicIds: ['10'], includesLegacy: true } })
223
+
224
+ expect(perform).toHaveBeenCalledWith('viewing_topic', {
225
+ topic_id: null,
226
+ rendered_topic_ids: ['10'],
227
+ rendered_legacy_topic: true,
228
+ })
229
+ })
230
+
231
+ test('does not report a rendered snapshot from another creative', () => {
232
+ const perform = jest.fn()
233
+ controller.presenceSubscription = { perform }
234
+
235
+ controller.handleRenderedAllTopics({ detail: { creativeId: '456', topicIds: ['10'] } })
236
+
237
+ expect(perform).not.toHaveBeenCalled()
238
+ })
239
+
240
+ test('does not request running agents without a presence subscription', () => {
241
+ controller.selectedTopicId = '45'
242
+
243
+ controller.requestRunningAgents()
244
+
245
+ expect(controller.presenceSubscription).toBeNull()
246
+ })
247
+
248
+ test('sends All Messages input to Main but does not render topic indicators there', () => {
249
+ const perform = jest.fn()
250
+ controller.presenceSubscription = { perform }
251
+ controller.selectedTopicId = null
252
+ controller.mainTopicId = '10'
253
+
254
+ controller.typing()
255
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '10' } })
256
+
257
+ expect(perform).toHaveBeenCalledWith('typing', { topic_id: '10' })
258
+ expect(controller.typingUsers).toEqual({})
259
+ controller.stoppedTyping()
260
+ })
261
+
262
+ test('shows typing only for the selected topic', () => {
263
+ controller.selectedTopicId = '10'
264
+
265
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
266
+ expect(controller.typingUsers).toEqual({})
267
+
268
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '10' } })
269
+ expect(controller.typingUsers).toEqual({ 5: 'Alice' })
270
+ })
271
+
272
+ test('ignores stop events from another topic', () => {
273
+ controller.selectedTopicId = '10'
274
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '10' } })
275
+
276
+ controller.handlePresenceMessage({ stop_typing: { id: 5, topic_id: '11' } })
277
+ expect(controller.typingUsers).toEqual({ 5: 'Alice' })
278
+
279
+ controller.handlePresenceMessage({ stop_typing: { id: 5, topic_id: '10' } })
280
+ expect(controller.typingUsers).toEqual({})
281
+ })
282
+
283
+ test('ignores agent idle events from another topic and mismatched legacy tasks', () => {
284
+ controller.selectedTopicId = '10'
285
+ const status = {
286
+ id: 9,
287
+ name: 'Agent',
288
+ status: 'thinking',
289
+ task_id: 99,
290
+ creative_id: '123',
291
+ topic_id: '10',
292
+ }
293
+ controller.handlePresenceMessage({ agent_status: status })
294
+
295
+ controller.handlePresenceMessage({
296
+ agent_status: { ...status, status: 'idle', topic_id: '11' },
297
+ })
298
+ expect(controller.typingUsers).toEqual({ 9: 'Agent' })
299
+
300
+ const { topic_id: _topicId, ...legacyStatus } = status
301
+ controller.handlePresenceMessage({
302
+ agent_status: { ...legacyStatus, status: 'idle', task_id: 100 },
303
+ })
304
+ expect(controller.typingUsers).toEqual({ 9: 'Agent' })
305
+
306
+ controller.handlePresenceMessage({
307
+ agent_status: { ...legacyStatus, status: 'idle' },
308
+ })
309
+ expect(controller.typingUsers).toEqual({})
310
+ })
311
+
312
+ test('clears typing and agent state immediately when switching topics', () => {
313
+ const perform = jest.fn()
314
+ controller.presenceSubscription = { perform }
315
+ controller.selectedTopicId = '10'
316
+ controller.mainTopicId = '1'
317
+ controller.typingUsers = { 5: 'Alice', 9: 'Agent' }
318
+ controller.activeAgentTasks = { 9: 99 }
319
+ controller.typingTimers = { 5: setTimeout(() => {}, 1000) }
320
+ controller.streamingHeartbeatTimers = { 9: setTimeout(() => {}, 1000) }
321
+ controller.startAgentTaskPoll()
322
+ jest.spyOn(controller, 'refreshChannelChips').mockImplementation(() => {})
323
+
324
+ controller.handleTopicChange({ detail: { topicId: '11', mainTopicId: '1' } })
325
+
326
+ expect(perform).toHaveBeenCalledWith('stopped_typing', { topic_id: '10' })
327
+ expect(perform).toHaveBeenCalledWith('running_agents', { topic_id: '11' })
328
+ expect(controller.selectedTopicId).toBe('11')
329
+ expect(controller.typingUsers).toEqual({})
330
+ expect(controller.activeAgentTasks).toEqual({})
331
+ expect(controller.typingTimers).toEqual({})
332
+ expect(controller.streamingHeartbeatTimers).toEqual({})
333
+ // A poll left running would keep asking about the old topic's turns and
334
+ // re-render an indicator the user just navigated away from.
335
+ expect(controller.agentTaskPollHandle).toBeNull()
336
+
337
+ perform.mockClear()
338
+ controller.handleTopicChange({ detail: { topicId: '11', mainTopicId: '1' } })
339
+ expect(perform).not.toHaveBeenCalled()
340
+ })
124
341
  })
125
342
 
126
343
  describe('CommentsPresenceController typing-row horizontal scroll', () => {
@@ -169,6 +386,7 @@ describe('CommentsPresenceController typing-row horizontal scroll', () => {
169
386
  const el = document.getElementById('comments-popup')
170
387
  controller = application.getControllerForElementAndIdentifier(el, 'comments--presence')
171
388
  controller.creativeId = '123'
389
+ controller.selectedTopicId = '11'
172
390
  row = el.querySelector('#typing-scroll-viewport')
173
391
  })
174
392
 
@@ -182,7 +400,7 @@ describe('CommentsPresenceController typing-row horizontal scroll', () => {
182
400
  test('auto-scrolls a new typer into view when parked at the right edge', () => {
183
401
  setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 200 }) // at end
184
402
 
185
- controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
403
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
186
404
 
187
405
  expect(scrollLeftValue).toBe(300)
188
406
  })
@@ -190,20 +408,20 @@ describe('CommentsPresenceController typing-row horizontal scroll', () => {
190
408
  test('does NOT scroll when the user has scrolled back to look at earlier items', () => {
191
409
  setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 }) // scrolled back
192
410
 
193
- controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
411
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
194
412
 
195
413
  expect(scrollLeftValue).toBe(0)
196
414
  })
197
415
 
198
416
  test('does not re-scroll on repeat typing pings from the same user (not a new item)', () => {
199
417
  setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 200 })
200
- controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
418
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
201
419
  expect(scrollLeftValue).toBe(300)
202
420
 
203
421
  // User scrolls back; a heartbeat ping for the same (already-shown) typer
204
422
  // must not yank them to the end again.
205
423
  scrollLeftValue = 0
206
- controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
424
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
207
425
  expect(scrollLeftValue).toBe(0)
208
426
  })
209
427
 
@@ -213,20 +431,504 @@ describe('CommentsPresenceController typing-row horizontal scroll', () => {
213
431
  // always want to see their own indicator, so stick-to-end must not suppress it.
214
432
  setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 })
215
433
 
216
- controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
434
+ controller.handlePresenceMessage({ typing: { id: 7, name: 'Me', topic_id: '11' } })
217
435
 
218
436
  expect(scrollLeftValue).toBe(300)
219
437
  })
220
438
 
221
439
  test('does not re-scroll on repeat self typing pings (only the first appearance forces scroll)', () => {
222
440
  setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 })
223
- controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
441
+ controller.handlePresenceMessage({ typing: { id: 7, name: 'Me', topic_id: '11' } })
224
442
  expect(scrollLeftValue).toBe(300)
225
443
 
226
444
  // A heartbeat ping for the same (already-shown) self typer while scrolled
227
445
  // back must not yank to the end again — only the first appearance forces it.
228
446
  scrollLeftValue = 0
229
- controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
447
+ controller.handlePresenceMessage({ typing: { id: 7, name: 'Me', topic_id: '11' } })
230
448
  expect(scrollLeftValue).toBe(0)
231
449
  })
450
+
451
+ describe('detaching a channel', () => {
452
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
453
+
454
+ const appendChannelChip = () => {
455
+ controller.channelChipsTarget.innerHTML = `
456
+ <span class="channel-chip" data-channel-id="42">
457
+ Pull request
458
+ <button data-channel-id="42">Detach</button>
459
+ </span>
460
+ `
461
+ return controller.channelChipsTarget.querySelector('button')
462
+ }
463
+
464
+ test('removes the channel chip immediately after a successful request', async () => {
465
+ const button = appendChannelChip()
466
+ global.fetch.mockResolvedValueOnce({ status: 204, redirected: false, headers: new Headers() })
467
+
468
+ controller.detachChannel({ currentTarget: button })
469
+ await flush()
470
+
471
+ expect(global.fetch).toHaveBeenCalledWith('/channels/42', expect.objectContaining({
472
+ method: 'DELETE',
473
+ }))
474
+ expect(controller.channelChipsTarget.querySelector('.channel-chip')).toBeNull()
475
+ })
476
+
477
+ test('keeps the channel chip when the request fails', async () => {
478
+ const button = appendChannelChip()
479
+ global.fetch.mockResolvedValueOnce({ status: 500, redirected: false, headers: new Headers() })
480
+
481
+ controller.detachChannel({ currentTarget: button })
482
+ await flush()
483
+
484
+ expect(controller.channelChipsTarget.querySelector('.channel-chip')).not.toBeNull()
485
+ })
486
+
487
+ test('keeps the channel chip when the request is redirected', async () => {
488
+ const button = appendChannelChip()
489
+ global.fetch.mockResolvedValueOnce({ status: 200, redirected: true, headers: new Headers() })
490
+
491
+ controller.detachChannel({ currentTarget: button })
492
+ await flush()
493
+
494
+ expect(controller.channelChipsTarget.querySelector('.channel-chip')).not.toBeNull()
495
+ })
496
+
497
+ test('keeps the channel chip for an unexpected non-redirect response', async () => {
498
+ const button = appendChannelChip()
499
+ global.fetch.mockResolvedValueOnce({ status: 200, redirected: false, headers: new Headers() })
500
+
501
+ controller.detachChannel({ currentTarget: button })
502
+ await flush()
503
+
504
+ expect(controller.channelChipsTarget.querySelector('.channel-chip')).not.toBeNull()
505
+ })
506
+
507
+ test('keeps the channel chip when the request is interrupted', async () => {
508
+ const button = appendChannelChip()
509
+ jest.spyOn(console, 'warn').mockImplementation(() => {})
510
+ global.fetch.mockRejectedValueOnce(new Error('offline'))
511
+
512
+ controller.detachChannel({ currentTarget: button })
513
+ await flush()
514
+
515
+ expect(controller.channelChipsTarget.querySelector('.channel-chip')).not.toBeNull()
516
+ expect(console.warn).toHaveBeenCalledWith(
517
+ '[presence] detach channel failed:',
518
+ expect.any(Error),
519
+ )
520
+ })
521
+ })
522
+
523
+ describe('agent task status poll', () => {
524
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
525
+
526
+ const respondWith = (tasks) => {
527
+ global.fetch.mockResolvedValueOnce({
528
+ ok: true,
529
+ headers: new Headers(),
530
+ json: async () => ({ tasks }),
531
+ })
532
+ }
533
+
534
+ const armAgent = (agentId, taskIds) => {
535
+ controller.activeAgentTasks[agentId] = [...taskIds]
536
+ controller.typingUsers[agentId] = 'Agent'
537
+ controller.agentStates[agentId] = 'thinking'
538
+ }
539
+
540
+ test('drops a task the poll reports as finished', async () => {
541
+ armAgent('9', [55])
542
+ respondWith([{ id: 55, active: false }])
543
+
544
+ controller.pollAgentTaskStatuses()
545
+ await flush()
546
+
547
+ expect(controller.activeAgentTasks['9']).toBeUndefined()
548
+ expect(controller.typingUsers['9']).toBeUndefined()
549
+ })
550
+
551
+ // Which statuses count as active is the server's call (Task#active?), covered
552
+ // in tasks_controller_test.rb — here it only has to be believed.
553
+ test('keeps a task the poll still reports as active', async () => {
554
+ armAgent('9', [55])
555
+ respondWith([{ id: 55, active: true }])
556
+
557
+ controller.pollAgentTaskStatuses()
558
+ await flush()
559
+
560
+ expect(controller.activeAgentTasks['9']).toEqual([55])
561
+ expect(controller.typingUsers['9']).toBe('Agent')
562
+ })
563
+
564
+ test('keeps a task that started after the poll request was sent', async () => {
565
+ armAgent('9', [55])
566
+
567
+ let settle
568
+ global.fetch.mockReturnValueOnce(
569
+ new Promise((resolve) => {
570
+ settle = resolve
571
+ }),
572
+ )
573
+
574
+ controller.pollAgentTaskStatuses()
575
+
576
+ // A second turn starts while the request is in flight. The server was
577
+ // never asked about 77, so its absence from the response says nothing.
578
+ controller.activeAgentTasks['9'].push(77)
579
+
580
+ settle({
581
+ ok: true,
582
+ headers: new Headers(),
583
+ json: async () => ({ tasks: [{ id: 55, active: true }] }),
584
+ })
585
+ await flush()
586
+
587
+ expect(controller.activeAgentTasks['9']).toEqual([55, 77])
588
+ })
589
+
590
+ // A blip must not do what the old 10s timeout did: take the indicator, and
591
+ // with it the only Stop button, off screen while the turn is still running.
592
+ test('a failed poll leaves the turn alone', async () => {
593
+ jest.spyOn(console, 'warn').mockImplementation(() => {})
594
+ armAgent('9', [55])
595
+ global.fetch.mockRejectedValueOnce(new Error('offline'))
596
+
597
+ controller.pollAgentTaskStatuses()
598
+ await flush()
599
+
600
+ expect(controller.activeAgentTasks['9']).toEqual([55])
601
+ expect(controller.typingUsers['9']).toBe('Agent')
602
+ })
603
+
604
+ test('a poll with nothing left to ask about stops itself', () => {
605
+ controller.startAgentTaskPoll()
606
+
607
+ controller.pollAgentTaskStatuses()
608
+
609
+ expect(controller.agentTaskPollHandle).toBeNull()
610
+ expect(global.fetch).not.toHaveBeenCalled()
611
+ })
612
+
613
+ test('still drops a requested task even when a newer one arrived mid-flight', async () => {
614
+ armAgent('9', [55])
615
+
616
+ let settle
617
+ global.fetch.mockReturnValueOnce(
618
+ new Promise((resolve) => {
619
+ settle = resolve
620
+ }),
621
+ )
622
+
623
+ controller.pollAgentTaskStatuses()
624
+ controller.activeAgentTasks['9'].push(77)
625
+
626
+ settle({
627
+ ok: true,
628
+ headers: new Headers(),
629
+ json: async () => ({ tasks: [{ id: 55, active: false }] }),
630
+ })
631
+ await flush()
632
+
633
+ expect(controller.activeAgentTasks['9']).toEqual([77])
634
+ })
635
+ })
636
+
637
+ describe('agent_status for a task paused on approval', () => {
638
+ const agentStatus = (status) => ({
639
+ agent_status: { id: 9, name: 'Agent', status, task_id: 55, creative_id: '123', topic_id: '11' },
640
+ })
641
+
642
+ beforeEach(() => {
643
+ controller.handlePresenceMessage(agentStatus('streaming'))
644
+ expect(controller.activeAgentTasks['9']).toEqual([55])
645
+ expect(controller.agentTaskPollHandle).not.toBeNull()
646
+ })
647
+
648
+ // The server pauses the task on pending_approval while it still holds the
649
+ // topic slot and still accepts cancel. Dropping it here would also stop the
650
+ // poll, so nothing would ever observe that status.
651
+ test('keeps the task and the poll, and still offers Stop', () => {
652
+ controller.handlePresenceMessage(agentStatus('pending_approval'))
653
+
654
+ expect(controller.activeAgentTasks['9']).toEqual([55])
655
+ expect(controller.typingUsers['9']).toBe('Agent')
656
+ expect(controller.agentTaskPollHandle).not.toBeNull()
657
+ expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
658
+ })
659
+
660
+ // The other direction: a genuinely finished turn must still clear at once,
661
+ // rather than lingering until the next poll.
662
+ test('idle still drops the task and stops the poll', () => {
663
+ controller.handlePresenceMessage(agentStatus('idle'))
664
+
665
+ expect(controller.activeAgentTasks['9']).toBeUndefined()
666
+ expect(controller.typingUsers['9']).toBeUndefined()
667
+ expect(controller.agentTaskPollHandle).toBeNull()
668
+ })
669
+
670
+ // idle is broadcast more than once per turn (finalize, then teardown), and a
671
+ // reconnecting client can also replay one for a turn it never registered. The
672
+ // second one has no task list to walk and must still be a clean no-op.
673
+ test('a repeat idle for an agent with no task left is harmless', () => {
674
+ controller.handlePresenceMessage(agentStatus('idle'))
675
+ controller.typingUsers['9'] = 'Agent'
676
+ controller.agentStates['9'] = 'thinking'
677
+
678
+ controller.handlePresenceMessage(agentStatus('idle'))
679
+
680
+ expect(controller.typingUsers['9']).toBeUndefined()
681
+ expect(controller.agentStates['9']).toBeUndefined()
682
+ })
683
+ })
684
+
685
+ describe('indicator label per agent state', () => {
686
+ const agentStatus = (status) => ({
687
+ agent_status: { id: 9, name: 'Agent', status, task_id: 55, creative_id: '123', topic_id: '11' },
688
+ })
689
+ const label = () => controller.typingIndicatorTarget.lastChild.textContent
690
+
691
+ test('streaming reads as typing', () => {
692
+ controller.handlePresenceMessage(agentStatus('streaming'))
693
+
694
+ expect(label()).toBe('Agent ...')
695
+ })
696
+
697
+ test('thinking reads as waiting', () => {
698
+ controller.handlePresenceMessage(agentStatus('thinking'))
699
+
700
+ expect(label()).toBe('Agent ⏳')
701
+ })
702
+
703
+ // The reported bug: a long turn used to make the whole indicator (and with
704
+ // it the only Stop button) disappear. It must degrade to waiting instead.
705
+ test('a stalled stream degrades to waiting and keeps Stop reachable', () => {
706
+ jest.useFakeTimers()
707
+ try {
708
+ controller.handlePresenceMessage(agentStatus('streaming'))
709
+ expect(label()).toBe('Agent ...')
710
+
711
+ jest.advanceTimersByTime(5000)
712
+
713
+ expect(label()).toBe('Agent ⏳')
714
+ expect(controller.activeAgentTasks['9']).toEqual([55])
715
+ expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
716
+ } finally {
717
+ jest.useRealTimers()
718
+ }
719
+ })
720
+
721
+ // Each heartbeat re-arms the timer, so a stream that keeps reporting never
722
+ // flips to waiting.
723
+ test('a live heartbeat keeps it typing', () => {
724
+ jest.useFakeTimers()
725
+ try {
726
+ controller.handlePresenceMessage(agentStatus('streaming'))
727
+ jest.advanceTimersByTime(3000)
728
+ controller.handlePresenceMessage(agentStatus('streaming'))
729
+ jest.advanceTimersByTime(3000)
730
+
731
+ expect(label()).toBe('Agent ...')
732
+ } finally {
733
+ jest.useRealTimers()
734
+ }
735
+ })
736
+
737
+ // The scheduler runs more than one turn per agent (topic concurrency > 1),
738
+ // and the heartbeat timer is per agent, not per turn. One turn going idle
739
+ // must not disarm the timer the surviving turn still depends on, or that
740
+ // turn stays labelled as typing forever once its stream goes quiet.
741
+ test('one turn ending leaves the other turn able to degrade to waiting', () => {
742
+ const statusFor = (status, taskId) => ({
743
+ agent_status: { id: 9, name: 'Agent', status, task_id: taskId, creative_id: '123', topic_id: '11' },
744
+ })
745
+ jest.useFakeTimers()
746
+ try {
747
+ controller.handlePresenceMessage(statusFor('streaming', 55))
748
+ controller.handlePresenceMessage(statusFor('streaming', 66))
749
+
750
+ controller.handlePresenceMessage(statusFor('idle', 55))
751
+ expect(controller.activeAgentTasks['9']).toEqual([66])
752
+ expect(label()).toBe('Agent ...')
753
+
754
+ jest.advanceTimersByTime(5000)
755
+
756
+ expect(label()).toBe('Agent ⏳')
757
+ expect(controller.activeAgentTasks['9']).toEqual([66])
758
+ expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
759
+ } finally {
760
+ jest.useRealTimers()
761
+ }
762
+ })
763
+
764
+ test('a human typer is never labelled as waiting', () => {
765
+ controller.typingUsers['7'] = 'Human'
766
+ controller.renderTypingIndicator()
767
+
768
+ expect(label()).toBe('Human ...')
769
+ })
770
+ })
771
+
772
+ describe('navigating the open popup to another creative', () => {
773
+ beforeEach(() => {
774
+ // onPopupOpened's other work needs a cable and a server; neither is the
775
+ // subject here.
776
+ jest.spyOn(controller, 'loadParticipants').mockImplementation(() => {})
777
+ jest.spyOn(controller, 'subscribe').mockImplementation(() => {})
778
+ jest.spyOn(controller, 'bootstrapChannelChips').mockImplementation(() => {})
779
+
780
+ controller.handlePresenceMessage({
781
+ agent_status: { id: 9, name: 'Agent', status: 'streaming', task_id: 55, creative_id: '123', topic_id: '11' },
782
+ })
783
+ expect(controller.activeAgentTasks['9']).toEqual([55])
784
+ expect(controller.agentTaskPollHandle).not.toBeNull()
785
+ })
786
+
787
+ // The popup is never closed on navigation — PopupController#_navigateToEntry
788
+ // reuses open()/openForCreative() — so onPopupClosed() does not run and this
789
+ // is the only place that can drop the previous chat's state. Left behind, the
790
+ // poll keeps asking about a task in another creative, active_statuses answers
791
+ // for it (it only checks readability), and the indicator plus a Stop button
792
+ // for that foreign turn stay on screen.
793
+ test('drops the previous chat’s agent state and stops the poll', () => {
794
+ controller.onPopupOpened({ creativeId: '456' })
795
+
796
+ expect(controller.activeAgentTasks).toEqual({})
797
+ expect(controller.typingUsers).toEqual({})
798
+ expect(controller.agentStates).toEqual({})
799
+ expect(controller.agentTaskPollHandle).toBeNull()
800
+ expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).toBeNull()
801
+ })
802
+
803
+ // Scoped to an actual change of creative: re-opening the same chat must not
804
+ // blank a turn that is streaming in it right now, which would leave it
805
+ // invisible (and uncancellable) until the next 3s heartbeat.
806
+ test('keeps it when the same creative is re-opened', () => {
807
+ controller.onPopupOpened({ creativeId: 123 })
808
+
809
+ expect(controller.activeAgentTasks['9']).toEqual([55])
810
+ expect(controller.typingUsers['9']).toBe('Agent')
811
+ expect(controller.agentTaskPollHandle).not.toBeNull()
812
+ })
813
+ })
814
+
815
+ describe('stopping a turn', () => {
816
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
817
+ const cancelOk = () => global.fetch.mockResolvedValueOnce({ ok: true, headers: new Headers() })
818
+ const cancelledUrls = () => global.fetch.mock.calls.map(([url]) => url)
819
+
820
+ const arrive = (status, { agentId = 9, taskId = 55, name = 'Agent' } = {}) =>
821
+ controller.handlePresenceMessage({
822
+ agent_status: { id: agentId, name, status, task_id: taskId, creative_id: '123', topic_id: '11' },
823
+ })
824
+
825
+ test('the Stop button cancels the turn and takes the indicator down with it', async () => {
826
+ arrive('streaming')
827
+ cancelOk()
828
+
829
+ controller.typingIndicatorTarget.querySelector('.agent-stop-btn').click()
830
+ await flush()
831
+
832
+ expect(cancelledUrls()).toEqual(['/tasks/55/cancel'])
833
+ expect(controller.activeAgentTasks['9']).toBeUndefined()
834
+ expect(controller.typingUsers['9']).toBeUndefined()
835
+ expect(controller.agentStates['9']).toBeUndefined()
836
+ expect(controller.agentTaskPollHandle).toBeNull()
837
+ expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).toBeNull()
838
+ })
839
+
840
+ // One Stop button stands for one agent, so it has to mean the turn the user
841
+ // is watching — the newest — not whichever turn happened to start first.
842
+ test('Stop ends the newest turn when an agent has two in flight', async () => {
843
+ arrive('streaming', { taskId: 55 })
844
+ arrive('streaming', { taskId: 77 })
845
+ cancelOk()
846
+
847
+ controller.typingIndicatorTarget.querySelector('.agent-stop-btn').click()
848
+ await flush()
849
+
850
+ expect(cancelledUrls()).toEqual(['/tasks/77/cancel'])
851
+ // The other turn is still running, so the agent — and its Stop button — stay.
852
+ expect(controller.activeAgentTasks['9']).toEqual([55])
853
+ expect(controller.typingUsers['9']).toBe('Agent')
854
+ expect(controller.agentTaskPollHandle).not.toBeNull()
855
+ expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
856
+ })
857
+
858
+ // A cancel that lands after the same task was already dropped (an idle
859
+ // broadcast, or a poll that saw it finish) must still clear the name.
860
+ test('cancelling a turn already dropped elsewhere still clears the agent', async () => {
861
+ arrive('streaming')
862
+ delete controller.activeAgentTasks['9']
863
+ cancelOk()
864
+
865
+ controller.cancelAgentTask(55, '9')
866
+ await flush()
867
+
868
+ expect(controller.typingUsers['9']).toBeUndefined()
869
+ expect(controller.agentStates['9']).toBeUndefined()
870
+ expect(controller.streamingHeartbeatTimers['9']).toBeUndefined()
871
+ })
872
+
873
+ test('a rejected cancel leaves the turn on screen', async () => {
874
+ arrive('streaming')
875
+ global.fetch.mockResolvedValueOnce({ ok: false, headers: new Headers() })
876
+
877
+ controller.cancelAgentTask(55, '9')
878
+ await flush()
879
+
880
+ expect(controller.activeAgentTasks['9']).toEqual([55])
881
+ expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
882
+ })
883
+
884
+ // Escape in the composer routes here (form_controller keydown). Returning
885
+ // false is what lets that handler fall through to its other behaviour.
886
+ test('Escape reports false when there is nothing to stop', () => {
887
+ expect(controller.cancelAllAgentTasks()).toBe(false)
888
+ expect(global.fetch).not.toHaveBeenCalled()
889
+ })
890
+
891
+ test('Escape stops the newest turn of every agent', async () => {
892
+ arrive('streaming', { agentId: 9, taskId: 55 })
893
+ arrive('streaming', { agentId: 9, taskId: 77 })
894
+ arrive('streaming', { agentId: 10, taskId: 88, name: 'Other' })
895
+ cancelOk()
896
+ cancelOk()
897
+
898
+ expect(controller.cancelAllAgentTasks()).toBe(true)
899
+ await flush()
900
+
901
+ expect(cancelledUrls()).toEqual(['/tasks/77/cancel', '/tasks/88/cancel'])
902
+ })
903
+ })
904
+
905
+ // The poll interval and the heartbeat timeouts write back into state the
906
+ // indicator renders from. Leaving them armed for a chat nobody is looking at
907
+ // keeps fetching /tasks/active_statuses forever and can repaint a torn-down row.
908
+ describe('leaving the chat', () => {
909
+ beforeEach(() => {
910
+ jest.spyOn(controller, 'unsubscribe').mockImplementation(() => {})
911
+ controller.handlePresenceMessage({
912
+ agent_status: { id: 9, name: 'Agent', status: 'streaming', task_id: 55, creative_id: '123', topic_id: '11' },
913
+ })
914
+ expect(controller.agentTaskPollHandle).not.toBeNull()
915
+ expect(controller.streamingHeartbeatTimers['9']).toBeDefined()
916
+ })
917
+
918
+ test('closing the popup disarms the poll and the heartbeats', () => {
919
+ controller.onPopupClosed()
920
+
921
+ expect(controller.agentTaskPollHandle).toBeNull()
922
+ expect(controller.streamingHeartbeatTimers).toEqual({})
923
+ expect(controller.activeAgentTasks).toEqual({})
924
+ expect(controller.typingUsers).toEqual({})
925
+ })
926
+
927
+ test('disconnecting the controller disarms the poll and the heartbeats', () => {
928
+ controller.disconnect()
929
+
930
+ expect(controller.agentTaskPollHandle).toBeNull()
931
+ expect(controller.streamingHeartbeatTimers).toEqual({})
932
+ })
933
+ })
232
934
  })