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
@@ -5,7 +5,15 @@ import csrfFetch from '../../lib/api/csrf_fetch'
5
5
  import { alertDialog } from '../../lib/utils/dialog'
6
6
 
7
7
  const TYPING_TIMEOUT = 3000
8
- const AGENT_STATUS_TIMEOUT = 10000 // Safety timeout for agent_status (heartbeat expected every 3s)
8
+ const AGENT_TASK_POLL_INTERVAL = 15000 // Poll active task statuses every 15s
9
+ const STREAMING_HEARTBEAT_TIMEOUT = 5000 // Transition streaming → thinking if no heartbeat
10
+ const PRESENCE_HEARTBEAT_INTERVAL = 30000
11
+
12
+ // agent_status values that keep a task registered. thinking/streaming are the
13
+ // agent producing output; pending_approval is it paused on a tool approval,
14
+ // which is not the end of the task — treating it as idle would drop the task
15
+ // and stop the poll, so the status above could never be seen.
16
+ const LIVE_AGENT_STATUSES = new Set(['thinking', 'streaming', 'pending_approval'])
9
17
 
10
18
  export default class extends Controller {
11
19
  static targets = ['participants', 'typingIndicator', 'textarea', 'privateCheckbox', 'channelChips', 'scrollRow']
@@ -19,31 +27,66 @@ export default class extends Controller {
19
27
  this.manualTypingMessage = null
20
28
  this.presenceSubscription = null
21
29
  this.typingTimeoutHandle = null
30
+ this.activeAgentTasks = {} // { agentId: [taskId, ...] } - ordered, last is most recent
31
+ this.agentStates = {} // { agentId: 'streaming' | 'thinking' }
32
+ this.streamingHeartbeatTimers = {} // { agentId: timeoutHandle }
33
+ this.agentTaskPollHandle = null
22
34
  this.hasPresenceConnected = false
35
+ this.presenceHeartbeatHandle = null
23
36
  this.currentUserId = document.body.dataset.currentUserId
37
+ this.selectedTopicId = null
38
+ this.mainTopicId = null
39
+ this.renderedAllTopicIds = null
40
+ this.renderedAllIncludesLegacy = false
24
41
 
25
42
  this.handleInput = this.handleInput.bind(this)
26
43
  this.handleFocus = this.handleFocus.bind(this)
27
44
  this.handleBlur = this.handleBlur.bind(this)
28
45
  this.handleTopicChange = this.handleTopicChange.bind(this)
46
+ this.handleRenderedAllTopics = this.handleRenderedAllTopics.bind(this)
29
47
 
30
48
  this.textareaTarget.addEventListener('input', this.handleInput)
31
49
  this.textareaTarget.addEventListener('focus', this.handleFocus)
32
50
  this.textareaTarget.addEventListener('blur', this.handleBlur)
33
51
  this.privateCheckboxTarget?.addEventListener('change', () => this.stoppedTyping())
34
52
  this.element.addEventListener('comments--topics:change', this.handleTopicChange)
53
+ this.element.addEventListener('comments--list:rendered-all-topics', this.handleRenderedAllTopics)
35
54
  }
36
55
 
37
56
  disconnect() {
38
57
  this.unsubscribe()
58
+ this.stopAgentTaskPoll()
59
+ this.clearAllStreamingHeartbeats()
39
60
  this.textareaTarget.removeEventListener('input', this.handleInput)
40
61
  this.textareaTarget.removeEventListener('focus', this.handleFocus)
41
62
  this.textareaTarget.removeEventListener('blur', this.handleBlur)
42
63
  this.element.removeEventListener('comments--topics:change', this.handleTopicChange)
64
+ this.element.removeEventListener('comments--list:rendered-all-topics', this.handleRenderedAllTopics)
43
65
  }
44
66
 
45
67
  handleTopicChange(event) {
46
68
  const topicId = event.detail?.topicId
69
+ const nextSelectedTopicId = topicId || null
70
+ const nextMainTopicId = event.detail?.mainTopicId || this.mainTopicId
71
+ const selectionChanged = String(nextSelectedTopicId || '') !== String(this.selectedTopicId || '')
72
+
73
+ if (selectionChanged) {
74
+ this.stoppedTyping()
75
+ this.clearTopicIndicators()
76
+ }
77
+
78
+ this.selectedTopicId = nextSelectedTopicId
79
+ this.mainTopicId = nextMainTopicId
80
+ if (selectionChanged) {
81
+ this.renderedAllTopicIds = null
82
+ this.renderedAllIncludesLegacy = false
83
+ }
84
+
85
+ if (selectionChanged) {
86
+ this.reportViewingTopic()
87
+ this.requestRunningAgents()
88
+ }
89
+
47
90
  if (topicId) {
48
91
  this.refreshChannelChips(topicId)
49
92
  } else {
@@ -51,6 +94,15 @@ export default class extends Controller {
51
94
  }
52
95
  }
53
96
 
97
+ handleRenderedAllTopics(event) {
98
+ const { creativeId, topicIds, includesLegacy } = event.detail || {}
99
+ if (String(creativeId) !== String(this.creativeId) || this.selectedTopicId) return
100
+
101
+ this.renderedAllTopicIds = Array.isArray(topicIds) ? topicIds : []
102
+ this.renderedAllIncludesLegacy = Boolean(includesLegacy)
103
+ this.reportViewingTopic()
104
+ }
105
+
54
106
  get listController() {
55
107
  return this.application.getControllerForElementAndIdentifier(this.element, 'comments--list')
56
108
  }
@@ -64,7 +116,18 @@ export default class extends Controller {
64
116
  }
65
117
 
66
118
  onPopupOpened({ creativeId }) {
119
+ // Navigating the OPEN popup to another creative comes through here, not through
120
+ // onPopupClosed() — PopupController#_navigateToEntry reuses open()/openForCreative().
121
+ // Every piece of agent state below belongs to the chat being left: the poll is keyed
122
+ // on task ids and /tasks/active_statuses answers for any task the user can read, so a
123
+ // carried-over id keeps a foreign task's indicator alive here, and the Stop button it
124
+ // renders cancels a turn in a creative that is no longer on screen.
125
+ if (this.creativeId !== undefined && String(creativeId) !== String(this.creativeId)) {
126
+ this.resetAgentActivity()
127
+ }
67
128
  this.creativeId = creativeId
129
+ this.renderedAllTopicIds = null
130
+ this.renderedAllIncludesLegacy = false
68
131
  this.loadParticipants()
69
132
  this.subscribe()
70
133
  this.renderParticipants([])
@@ -81,6 +144,9 @@ export default class extends Controller {
81
144
  this.element, 'comments--topics'
82
145
  )
83
146
  const topicId = topicsCtrl?.currentTopicId
147
+ this.selectedTopicId = topicId || null
148
+ this.mainTopicId = topicsCtrl?.mainTopicId || null
149
+ this.reportViewingTopic()
84
150
  if (topicId) {
85
151
  this.refreshChannelChips(topicId)
86
152
  } else {
@@ -88,14 +154,50 @@ export default class extends Controller {
88
154
  }
89
155
  }
90
156
 
157
+ // Everything that describes activity in ONE chat. Leaving that chat — closed, or
158
+ // navigated to another creative with the popup still open — has to drop all of it
159
+ // together: the maps the indicator renders from, the timers that write back into
160
+ // them, and the poll that keeps them alive. Clearing the maps alone would leave the
161
+ // interval and the heartbeat timeouts running for a chat nobody is looking at.
162
+ resetAgentActivity() {
163
+ this.stopAgentTaskPoll()
164
+ this.clearAllStreamingHeartbeats()
165
+ this.clearTypingTimers()
166
+ this.typingUsers = {}
167
+ this.activeAgentTasks = {}
168
+ this.agentStates = {}
169
+ this.syncGlobalAgentTasks()
170
+ }
171
+
172
+ // An agent's turns, label, state and heartbeat come down together or not at all.
173
+ // The heartbeat is keyed by agent, so it may only go when the agent itself does —
174
+ // a surviving turn still needs it to degrade to thinking. Three paths remove an
175
+ // agent (idle, Stop, poll); routing them all through here is what keeps them from
176
+ // drifting apart, which is how that heartbeat got dropped early once already.
177
+ dropAgent(agentId) {
178
+ delete this.activeAgentTasks[agentId]
179
+ delete this.typingUsers[agentId]
180
+ delete this.agentStates[agentId]
181
+ this.clearStreamingHeartbeat(agentId)
182
+ }
183
+
184
+ // Ends one turn, and the agent with it once nothing is left. A missing taskId means
185
+ // the caller can't say which turn ended, so the agent goes entirely.
186
+ dropAgentTask(agentId, taskId) {
187
+ const tasks = this.activeAgentTasks[agentId]
188
+ if (!tasks) return this.dropAgent(agentId)
189
+ if (taskId) {
190
+ const idx = tasks.indexOf(taskId)
191
+ if (idx !== -1) tasks.splice(idx, 1)
192
+ }
193
+ if (!taskId || tasks.length === 0) this.dropAgent(agentId)
194
+ }
195
+
91
196
  onPopupClosed() {
92
197
  this.unsubscribe()
93
198
  this.participantsData = null
94
199
  this.currentPresentIds = []
95
- this.typingUsers = {}
96
- this.activeAgentTasks = {}
97
- this.syncGlobalAgentTasks()
98
- this.clearTypingTimers()
200
+ this.resetAgentActivity()
99
201
  this.clearManualTypingMessage()
100
202
  this.renderParticipants([])
101
203
  this.renderTypingIndicator()
@@ -116,13 +218,17 @@ export default class extends Controller {
116
218
 
117
219
  typing() {
118
220
  if (!this.presenceSubscription || this.privateCheckboxTarget?.checked) return
119
- this.presenceSubscription.perform('typing')
221
+ const topicId = this.typingTopicId
222
+ if (!topicId) return
223
+
224
+ this.presenceSubscription.perform('typing', { topic_id: topicId })
120
225
  this.resetTypingTimeout()
121
226
  }
122
227
 
123
228
  stoppedTyping() {
124
- if (this.presenceSubscription) {
125
- this.presenceSubscription.perform('stopped_typing')
229
+ const topicId = this.typingTopicId
230
+ if (this.presenceSubscription && topicId) {
231
+ this.presenceSubscription.perform('stopped_typing', { topic_id: topicId })
126
232
  }
127
233
  if (this.typingTimeoutHandle) {
128
234
  clearTimeout(this.typingTimeoutHandle)
@@ -130,7 +236,13 @@ export default class extends Controller {
130
236
  }
131
237
  }
132
238
 
133
- loadParticipants({ closeOnForbidden = false } = {}) {
239
+ requestRunningAgents() {
240
+ if (!this.presenceSubscription || !this.selectedTopicId) return
241
+
242
+ this.presenceSubscription.perform('running_agents', { topic_id: this.selectedTopicId })
243
+ }
244
+
245
+ loadParticipants() {
134
246
  if (!this.creativeId) return
135
247
  fetch(`/creatives/${this.creativeId}/comments/participants`, {
136
248
  cache: 'no-store',
@@ -150,16 +262,11 @@ export default class extends Controller {
150
262
  this.renderParticipants(this.currentPresentIds)
151
263
  this.renderTypingIndicator()
152
264
  })
153
- .catch((error) => {
265
+ .catch(() => {
154
266
  this.participantsData = []
155
267
  this.canShare = false
156
268
  this.renderParticipants([])
157
269
  this.renderTypingIndicator()
158
-
159
- if (closeOnForbidden) {
160
- alertDialog(error.message)
161
- this.popupController?.close()
162
- }
163
270
  })
164
271
  }
165
272
 
@@ -175,6 +282,8 @@ export default class extends Controller {
175
282
  this.listController?.loadInitialComments()
176
283
  }
177
284
  this.hasPresenceConnected = true
285
+ this.reportViewingTopic()
286
+ this.startPresenceHeartbeat()
178
287
  },
179
288
  received: (data) => this.handlePresenceMessage(data),
180
289
  },
@@ -182,6 +291,7 @@ export default class extends Controller {
182
291
  }
183
292
 
184
293
  unsubscribe() {
294
+ this.stopPresenceHeartbeat()
185
295
  if (this.presenceSubscription) {
186
296
  this.presenceSubscription.unsubscribe()
187
297
  this.presenceSubscription = null
@@ -189,6 +299,31 @@ export default class extends Controller {
189
299
  this.stoppedTyping()
190
300
  }
191
301
 
302
+ reportViewingTopic() {
303
+ if (!this.presenceSubscription) return
304
+
305
+ const payload = { topic_id: this.selectedTopicId }
306
+ if (!this.selectedTopicId && Array.isArray(this.renderedAllTopicIds)) {
307
+ payload.rendered_topic_ids = this.renderedAllTopicIds
308
+ if (this.renderedAllIncludesLegacy) payload.rendered_legacy_topic = true
309
+ }
310
+ this.presenceSubscription.perform('viewing_topic', payload)
311
+ }
312
+
313
+ startPresenceHeartbeat() {
314
+ this.stopPresenceHeartbeat()
315
+ this.presenceHeartbeatHandle = setInterval(() => {
316
+ this.presenceSubscription?.perform('heartbeat')
317
+ }, PRESENCE_HEARTBEAT_INTERVAL)
318
+ }
319
+
320
+ stopPresenceHeartbeat() {
321
+ if (this.presenceHeartbeatHandle) {
322
+ clearInterval(this.presenceHeartbeatHandle)
323
+ this.presenceHeartbeatHandle = null
324
+ }
325
+ }
326
+
192
327
  handlePresenceMessage(data) {
193
328
  if (data.ids) {
194
329
  this.currentPresentIds = data.ids.map((id) => parseInt(id, 10))
@@ -196,7 +331,9 @@ export default class extends Controller {
196
331
  this.updateReadReceiptPresence(this.currentPresentIds)
197
332
  }
198
333
  if (data.typing) {
199
- const { id, name } = data.typing
334
+ const { id, name, topic_id: topicId } = data.typing
335
+ if (!this.isSelectedTopic(topicId)) return
336
+
200
337
  const isNewTyper = !(id in this.typingUsers)
201
338
  // The user always wants to see their OWN typing indicator the moment it
202
339
  // appears — they just started typing. Stick-to-end (which pauses while the
@@ -213,7 +350,9 @@ export default class extends Controller {
213
350
  }, TYPING_TIMEOUT)
214
351
  }
215
352
  if (data.stop_typing) {
216
- const { id } = data.stop_typing
353
+ const { id, topic_id: topicId } = data.stop_typing
354
+ if (!this.isSelectedTopic(topicId)) return
355
+
217
356
  delete this.typingUsers[id]
218
357
  if (this.typingTimers[id]) {
219
358
  clearTimeout(this.typingTimers[id])
@@ -234,40 +373,63 @@ export default class extends Controller {
234
373
  this.formController?.setCommentPermission(shareChange.can_comment)
235
374
  }
236
375
 
237
- this.loadParticipants({ closeOnForbidden: shareChange.has_access === false })
376
+ if (shareChange.has_access === false) {
377
+ document.dispatchEvent(new CustomEvent('workspace-tree:invalidate', {
378
+ detail: { creativeIds: [String(this.creativeId)] },
379
+ }))
380
+ alertDialog(this.element.dataset.noPermissionText || 'No permission')
381
+ if (this.popupController?.isDocked()) {
382
+ this.popupController.resetDockedToEmpty()
383
+ } else {
384
+ this.popupController?.close()
385
+ }
386
+ return
387
+ }
388
+
389
+ this.loadParticipants()
238
390
  return
239
391
  }
240
392
  if (data.channel_chips) {
241
393
  this.refreshChannelChips(data.channel_chips.topic_id)
242
394
  }
243
395
  if (data.agent_status) {
244
- const { id, name, status, task_id, creative_id: agentCreativeId } = data.agent_status
396
+ const { id, name, status, task_id, topic_id: topicId, creative_id: agentCreativeId } = data.agent_status
245
397
  // Only show typing indicator if agent is working on this specific creative
246
398
  if (agentCreativeId && String(agentCreativeId) !== String(this.creativeId)) {
247
399
  return
248
400
  }
401
+ if (!topicId) {
402
+ // Turns are tracked per agent as an array, so a legacy idle has to match a
403
+ // member — String() on the array only happens to work at length 1.
404
+ const activeTaskIds = this.activeAgentTasks?.[id] || []
405
+ const matchingLegacyIdle = status === 'idle' && task_id &&
406
+ activeTaskIds.some((activeTaskId) => String(activeTaskId) === String(task_id))
407
+ if (!matchingLegacyIdle) return
408
+ } else if (!this.isSelectedTopic(topicId)) {
409
+ return
410
+ }
411
+
249
412
  const isNewAgent = (status === 'thinking' || status === 'streaming') && !(id in this.typingUsers)
250
- if (status === 'thinking' || status === 'streaming') {
413
+ if (LIVE_AGENT_STATUSES.has(status)) {
251
414
  this.typingUsers[id] = name
252
- if (!this.activeAgentTasks) this.activeAgentTasks = {}
253
- if (task_id) this.activeAgentTasks[id] = task_id
254
- // Safety timeout: auto-remove if no heartbeat within AGENT_STATUS_TIMEOUT
255
- if (!this.agentStatusTimers) this.agentStatusTimers = {}
256
- if (this.agentStatusTimers[id]) clearTimeout(this.agentStatusTimers[id])
257
- this.agentStatusTimers[id] = setTimeout(() => {
258
- delete this.typingUsers[id]
259
- delete this.agentStatusTimers[id]
260
- delete this.activeAgentTasks?.[id]
261
- this.syncGlobalAgentTasks()
262
- this.renderTypingIndicator()
263
- }, AGENT_STATUS_TIMEOUT)
264
- } else {
265
- delete this.typingUsers[id]
266
- delete this.activeAgentTasks?.[id]
267
- if (this.agentStatusTimers?.[id]) {
268
- clearTimeout(this.agentStatusTimers[id])
269
- delete this.agentStatusTimers[id]
415
+ this.agentStates[id] = status
416
+ if (!this.activeAgentTasks[id]) this.activeAgentTasks[id] = []
417
+ if (task_id && !this.activeAgentTasks[id].includes(task_id)) {
418
+ this.activeAgentTasks[id].push(task_id)
270
419
  }
420
+ // Streaming heartbeat: transition to thinking if no update within timeout
421
+ this.clearStreamingHeartbeat(id)
422
+ if (status === 'streaming') {
423
+ this.streamingHeartbeatTimers[id] = setTimeout(() => {
424
+ this.agentStates[id] = 'thinking'
425
+ delete this.streamingHeartbeatTimers[id]
426
+ this.renderTypingIndicator()
427
+ }, STREAMING_HEARTBEAT_TIMEOUT)
428
+ }
429
+ this.startAgentTaskPoll()
430
+ } else {
431
+ this.dropAgentTask(id, task_id)
432
+ this.maybeStopAgentTaskPoll()
271
433
  }
272
434
  this.syncGlobalAgentTasks()
273
435
  this.renderTypingIndicator({ newItem: isNewAgent })
@@ -384,7 +546,7 @@ export default class extends Controller {
384
546
  this.typingIndicatorTarget.style.opacity = '1'
385
547
 
386
548
  // Add stop button first (before avatars/names) for active agent tasks
387
- const hasActiveTask = ids.some((id) => this.activeAgentTasks?.[id])
549
+ const hasActiveTask = ids.some((id) => this.activeAgentTasks[id]?.length > 0)
388
550
  if (hasActiveTask) {
389
551
  const stopBtn = document.createElement('button')
390
552
  stopBtn.type = 'button'
@@ -394,8 +556,11 @@ export default class extends Controller {
394
556
  stopBtn.title = stopLabel
395
557
  stopBtn.addEventListener('click', () => {
396
558
  ids.forEach((id) => {
397
- const taskId = this.activeAgentTasks?.[id]
398
- if (taskId) this.cancelAgentTask(taskId, id)
559
+ const tasks = this.activeAgentTasks[id]
560
+ if (tasks?.length > 0) {
561
+ // Cancel the last (most recent) task
562
+ this.cancelAgentTask(tasks[tasks.length - 1], id)
563
+ }
399
564
  })
400
565
  })
401
566
  this.typingIndicatorTarget.appendChild(stopBtn)
@@ -425,9 +590,13 @@ export default class extends Controller {
425
590
  this.typingIndicatorTarget.appendChild(wrapper)
426
591
  })
427
592
  }
428
- const names = ids.map((id) => this.typingUsers[id])
429
593
  const text = document.createElement('span')
430
- text.textContent = `${names.join(', ')} ...`
594
+ const parts = ids.map((id) => {
595
+ const name = this.typingUsers[id]
596
+ const isAgentThinking = this.activeAgentTasks[id]?.length > 0 && this.agentStates[id] !== 'streaming'
597
+ return isAgentThinking ? `${name} \u23F3` : `${name} ...`
598
+ })
599
+ text.textContent = parts.join(' ')
431
600
  this.typingIndicatorTarget.appendChild(text)
432
601
 
433
602
  if (stickToEnd) this.scrollRowToEnd()
@@ -463,11 +632,13 @@ export default class extends Controller {
463
632
  }
464
633
 
465
634
  cancelAllAgentTasks() {
466
- const ids = Object.keys(this.activeAgentTasks || {})
467
- if (ids.length === 0) return false
468
- ids.forEach((id) => {
469
- const taskId = this.activeAgentTasks[id]
470
- if (taskId) this.cancelAgentTask(taskId, id)
635
+ const agentIds = Object.keys(this.activeAgentTasks)
636
+ if (agentIds.length === 0) return false
637
+ agentIds.forEach((agentId) => {
638
+ const tasks = this.activeAgentTasks[agentId]
639
+ if (tasks?.length > 0) {
640
+ this.cancelAgentTask(tasks[tasks.length - 1], agentId)
641
+ }
471
642
  })
472
643
  return true
473
644
  }
@@ -479,12 +650,8 @@ export default class extends Controller {
479
650
  })
480
651
  .then((response) => {
481
652
  if (response.ok) {
482
- delete this.typingUsers[agentId]
483
- delete this.activeAgentTasks?.[agentId]
484
- if (this.agentStatusTimers?.[agentId]) {
485
- clearTimeout(this.agentStatusTimers[agentId])
486
- delete this.agentStatusTimers[agentId]
487
- }
653
+ this.dropAgentTask(agentId, taskId)
654
+ this.maybeStopAgentTaskPoll()
488
655
  this.syncGlobalAgentTasks()
489
656
  this.renderTypingIndicator()
490
657
  }
@@ -548,7 +715,9 @@ export default class extends Controller {
548
715
  csrfFetch(`/channels/${id}`, {
549
716
  method: 'DELETE',
550
717
  headers: { Accept: 'application/json' },
551
- }).catch((err) => console.warn('[presence] detach channel failed:', err))
718
+ })
719
+ .then((response) => response.status === 204 && !response.redirected && btn.closest('.channel-chip')?.remove())
720
+ .catch((err) => console.warn('[presence] detach channel failed:', err))
552
721
  }
553
722
 
554
723
  clearTypingTimers() {
@@ -556,6 +725,99 @@ export default class extends Controller {
556
725
  this.typingTimers = {}
557
726
  }
558
727
 
728
+ // Every indicator on screen belongs to the topic being left, and the replay that
729
+ // follows is scoped to the new one — so the whole agent state goes, heartbeats and
730
+ // poll included. A surviving poll would keep resurrecting the old topic's turns.
731
+ clearTopicIndicators() {
732
+ this.resetAgentActivity()
733
+ this.renderTypingIndicator()
734
+ }
735
+
736
+ get typingTopicId() {
737
+ return this.selectedTopicId || this.mainTopicId
738
+ }
739
+
740
+ isSelectedTopic(topicId) {
741
+ return Boolean(
742
+ this.selectedTopicId &&
743
+ topicId &&
744
+ String(topicId) === String(this.selectedTopicId)
745
+ )
746
+ }
747
+
748
+ // ── Streaming heartbeat ─────────────────────────────────
749
+
750
+ clearStreamingHeartbeat(agentId) {
751
+ if (this.streamingHeartbeatTimers[agentId]) {
752
+ clearTimeout(this.streamingHeartbeatTimers[agentId])
753
+ delete this.streamingHeartbeatTimers[agentId]
754
+ }
755
+ }
756
+
757
+ clearAllStreamingHeartbeats() {
758
+ Object.values(this.streamingHeartbeatTimers).forEach((timer) => clearTimeout(timer))
759
+ this.streamingHeartbeatTimers = {}
760
+ }
761
+
762
+ // ── Agent task status polling ──────────────────────────
763
+
764
+ startAgentTaskPoll() {
765
+ if (this.agentTaskPollHandle) return
766
+ this.agentTaskPollHandle = setInterval(() => this.pollAgentTaskStatuses(), AGENT_TASK_POLL_INTERVAL)
767
+ }
768
+
769
+ stopAgentTaskPoll() {
770
+ if (this.agentTaskPollHandle) {
771
+ clearInterval(this.agentTaskPollHandle)
772
+ this.agentTaskPollHandle = null
773
+ }
774
+ }
775
+
776
+ maybeStopAgentTaskPoll() {
777
+ if (Object.keys(this.activeAgentTasks).length === 0) this.stopAgentTaskPoll()
778
+ }
779
+
780
+ pollAgentTaskStatuses() {
781
+ const allTaskIds = Object.values(this.activeAgentTasks).flat()
782
+ if (allTaskIds.length === 0) {
783
+ this.stopAgentTaskPoll()
784
+ return
785
+ }
786
+ const requestedTaskIds = new Set(allTaskIds)
787
+
788
+ csrfFetch(`/tasks/active_statuses?task_ids=${allTaskIds.join(',')}`, {
789
+ headers: { Accept: 'application/json' },
790
+ })
791
+ .then((response) => {
792
+ if (!response.ok) return null
793
+ return response.json()
794
+ })
795
+ .then((data) => {
796
+ if (!data) return
797
+ // Task#active? decides what still deserves an indicator and a Stop
798
+ // button, so the client never has to know the status vocabulary.
799
+ const activeTaskIds = new Set(data.tasks.filter((t) => t.active).map((t) => t.id))
800
+
801
+ // This response only speaks for the ids it was asked about. A task that
802
+ // an agent_status message appended while the request was in flight was
803
+ // never requested, so its absence here means nothing — keep it until a
804
+ // poll that actually asked about it says otherwise.
805
+ let changed = false
806
+ Object.keys(this.activeAgentTasks).forEach((agentId) => {
807
+ const before = this.activeAgentTasks[agentId].length
808
+ this.activeAgentTasks[agentId] = this.activeAgentTasks[agentId].filter(
809
+ (taskId) => activeTaskIds.has(taskId) || !requestedTaskIds.has(taskId),
810
+ )
811
+ if (this.activeAgentTasks[agentId].length !== before) changed = true
812
+ if (this.activeAgentTasks[agentId].length === 0) this.dropAgent(agentId)
813
+ })
814
+
815
+ if (changed) this.renderTypingIndicator()
816
+ this.maybeStopAgentTaskPoll()
817
+ })
818
+ .catch((err) => console.warn('[presence] poll agent task statuses failed:', err))
819
+ }
820
+
559
821
  handleInput() {
560
822
  this.clearManualTypingMessage()
561
823
  this.typing()
@@ -0,0 +1,94 @@
1
+ // Tracks where the "previous message" button left off.
2
+ //
3
+ // The button scrolls with `behavior: 'smooth'`, so a rapid second click arrives
4
+ // while the list is still mid-animation. Deriving the current position purely
5
+ // from geometry at that moment picks the item we just left (it has not reached
6
+ // the top yet), which makes the list bounce up and down instead of walking
7
+ // backwards. Remembering the last target as an anchor keeps each click stepping
8
+ // one message further back.
9
+ //
10
+ // The anchor is dropped once the user moves the list themselves, so geometry
11
+ // takes over from wherever they landed. Invalidation is driven by input events
12
+ // rather than scroll events: a scroll event cannot be attributed to the user
13
+ // without guessing how long our own animation runs, and guessing short brings
14
+ // the bounce back. Dropping the anchor is always the safe direction — a settled
15
+ // list resolves correctly from geometry alone.
16
+ //
17
+ // Kept free of DOM access so it can be unit tested: jsdom has no layout engine,
18
+ // so the controller measures the items and passes `{ id, top }` pairs in.
19
+
20
+ // Sub-pixel scroll offsets (HiDPI, browser rounding) mean an item parked at the
21
+ // top is rarely at exactly `viewportTop`.
22
+ const TOP_TOLERANCE = 4
23
+
24
+ export default class PrevMessageNavigator {
25
+ constructor() {
26
+ this.anchorId = null
27
+ }
28
+
29
+ // items: `{ id, top }` in DOM order (oldest first), top relative to viewport.
30
+ // Returns the index to scroll to, or -1 when there is nothing older in the
31
+ // list (the caller then loads older comments).
32
+ resolveTargetIndex(items, viewportTop) {
33
+ if (items.length === 0) return -1
34
+
35
+ if (this.anchorId !== null) {
36
+ const anchorIdx = items.findIndex((item) => item.id === this.anchorId)
37
+ if (anchorIdx !== -1 && this.anchorIsOnItsWay(items[anchorIdx].top, viewportTop)) {
38
+ return anchorIdx - 1
39
+ }
40
+ this.anchorId = null
41
+ }
42
+
43
+ let currentIdx = items.findIndex((item) => item.top >= viewportTop - TOP_TOLERANCE)
44
+ if (currentIdx === -1) currentIdx = items.length - 1
45
+
46
+ const isAtTop = Math.abs(items[currentIdx].top - viewportTop) < TOP_TOLERANCE
47
+ return isAtTop ? currentIdx - 1 : currentIdx
48
+ }
49
+
50
+ // The anchor travels from wherever it sat when we committed to `viewportTop`,
51
+ // and nothing else moves it. Outside that corridor the list was scrolled by
52
+ // something other than us - a new message arriving, a permalink jump - none of
53
+ // which fire a user input event, so the anchor would otherwise survive and wedge
54
+ // the button (anchored on the oldest item, every click resolves to -1 forever).
55
+ //
56
+ // The corridor spans both endpoints because the anchor can approach the top from
57
+ // either side: usually from above (the next message back), but from below when
58
+ // the click started between messages and we pulled the partially visible one up.
59
+ //
60
+ // Comparing the anchor against its own start position rather than the list's
61
+ // scroll offset keeps this correct when older comments are prepended: that
62
+ // shifts `scrollTop` but leaves the anchor where it is on screen.
63
+ anchorIsOnItsWay(anchorTop, viewportTop) {
64
+ const low = Math.min(this.anchorStartTop, viewportTop) - TOP_TOLERANCE
65
+ const high = Math.max(this.anchorStartTop, viewportTop) + TOP_TOLERANCE
66
+ return anchorTop >= low && anchorTop <= high
67
+ }
68
+
69
+ // Called once we start scrolling towards `id`, which currently sits at `top`.
70
+ commit(id, top) {
71
+ this.anchorId = id
72
+ this.anchorStartTop = top
73
+ }
74
+
75
+ // Called when the user interacts with the list (wheel, touch, key, pointer).
76
+ notifyUserInput() {
77
+ this.anchorId = null
78
+ }
79
+
80
+ // Called when our own code repositions the list without going through the
81
+ // button - jumping to the newest message, a permalink highlight. These fire no
82
+ // user input event, and a small jump can leave the anchor inside the corridor
83
+ // above, where geometry cannot tell it apart from our own in-flight scroll. The
84
+ // corridor still catches larger jumps, but the caller drops the anchor at these
85
+ // known sites so a still-visible message is never skipped.
86
+ notifyProgrammaticScroll() {
87
+ this.anchorId = null
88
+ }
89
+
90
+ // Called when the list contents are replaced wholesale.
91
+ reset() {
92
+ this.anchorId = null
93
+ }
94
+ }