collavre 0.24.1 → 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 (437) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +160 -82
  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 +2 -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 +15 -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__/topics_controller_action_errors.test.js +6 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +112 -2
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  94. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  95. data/app/javascript/controllers/comments/form_controller.js +792 -14
  96. data/app/javascript/controllers/comments/list_controller.js +296 -31
  97. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  98. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  99. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  100. data/app/javascript/controllers/comments/topics_controller.js +1705 -80
  101. data/app/javascript/controllers/common_popup_controller.js +2 -2
  102. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  103. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  104. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  105. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  106. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  107. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  108. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  109. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  110. data/app/javascript/controllers/index.js +25 -1
  111. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  112. data/app/javascript/controllers/link_creative_controller.js +89 -18
  113. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  114. data/app/javascript/controllers/search_popup_controller.js +64 -46
  115. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  116. data/app/javascript/controllers/topic_list_controller.js +44 -7
  117. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  118. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  119. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  120. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  121. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  122. data/app/javascript/creatives/tree_renderer.js +65 -22
  123. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  124. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  125. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  126. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  127. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  128. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  129. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  130. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  131. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  132. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  133. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  134. data/app/javascript/lib/api/creatives.js +32 -13
  135. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  136. data/app/javascript/lib/api/topics.js +63 -5
  137. data/app/javascript/lib/chat_drafts.js +786 -0
  138. data/app/javascript/lib/common_popup.js +164 -25
  139. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  140. data/app/javascript/lib/last_visited_creative.js +97 -0
  141. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  142. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  143. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  144. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  145. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  146. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  147. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  148. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  149. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  150. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  151. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  152. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  153. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  154. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  155. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  156. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  159. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  160. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  161. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  162. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  163. data/app/javascript/modules/command_args_form.js +0 -12
  164. data/app/javascript/modules/command_menu.js +80 -38
  165. data/app/javascript/modules/creative_link_picker.js +56 -0
  166. data/app/javascript/modules/creative_row_editor.js +446 -243
  167. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  168. data/app/javascript/modules/creative_tree_dom.js +4 -0
  169. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  170. data/app/javascript/modules/dom_listener_registry.js +23 -0
  171. data/app/javascript/modules/mention_menu.js +2 -3
  172. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  173. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  174. data/app/javascript/utils/caret_position.js +10 -0
  175. data/app/javascript/utils/chevron_icons.js +7 -0
  176. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  177. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  178. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  179. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  180. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  181. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  182. data/app/jobs/collavre/compress_job.rb +33 -22
  183. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  184. data/app/jobs/collavre/cron_action_job.rb +36 -18
  185. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  186. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  187. data/app/jobs/collavre/push_notification_job.rb +62 -18
  188. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  189. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  190. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  191. data/app/models/collavre/agent_gateway.rb +200 -0
  192. data/app/models/collavre/agent_subscription.rb +4 -4
  193. data/app/models/collavre/agent_workspace.rb +210 -0
  194. data/app/models/collavre/comment/broadcastable.rb +22 -52
  195. data/app/models/collavre/comment/notifiable.rb +154 -77
  196. data/app/models/collavre/comment/topic_membership.rb +41 -0
  197. data/app/models/collavre/comment.rb +336 -31
  198. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  199. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  200. data/app/models/collavre/comment_presence_store.rb +272 -12
  201. data/app/models/collavre/comment_read_pointer.rb +9 -15
  202. data/app/models/collavre/creative/describable.rb +8 -2
  203. data/app/models/collavre/creative/permissible.rb +11 -0
  204. data/app/models/collavre/creative.rb +29 -4
  205. data/app/models/collavre/creative_share.rb +109 -4
  206. data/app/models/collavre/current.rb +1 -0
  207. data/app/models/collavre/llm_model.rb +62 -0
  208. data/app/models/collavre/system_setting.rb +12 -1
  209. data/app/models/collavre/task.rb +121 -2
  210. data/app/models/collavre/topic.rb +51 -0
  211. data/app/models/collavre/user.rb +101 -41
  212. data/app/models/collavre/user_creative_preference.rb +3 -1
  213. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  214. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  215. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  216. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  217. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  218. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  219. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  220. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  221. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  222. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  223. data/app/services/collavre/ai_agent_service.rb +108 -20
  224. data/app/services/collavre/ai_client.rb +211 -21
  225. data/app/services/collavre/cli_proxy/client.rb +147 -0
  226. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  227. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  228. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  229. data/app/services/collavre/command_menu_service.rb +0 -10
  230. data/app/services/collavre/comment_move_service.rb +96 -7
  231. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  232. data/app/services/collavre/comments/action_executor.rb +48 -20
  233. data/app/services/collavre/comments/command_processor.rb +0 -1
  234. data/app/services/collavre/comments/list_response.rb +133 -0
  235. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  236. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  237. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  238. data/app/services/collavre/comments/topic_command.rb +72 -1
  239. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  240. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  241. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  242. data/app/services/collavre/creatives/reorderer.rb +15 -3
  243. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  244. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  245. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  246. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  247. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  248. data/app/services/collavre/home_page_path.rb +55 -0
  249. data/app/services/collavre/html_text.rb +64 -0
  250. data/app/services/collavre/http_client.rb +11 -2
  251. data/app/services/collavre/inbox_reply_service.rb +1 -1
  252. data/app/services/collavre/markdown_converter.rb +13 -1
  253. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  254. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  255. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  256. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  257. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  258. data/app/services/collavre/orchestration/matcher.rb +132 -17
  259. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  260. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  261. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  262. data/app/services/collavre/orchestration/selection.rb +43 -0
  263. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  264. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  265. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  266. data/app/services/collavre/system_events/context_builder.rb +60 -11
  267. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  268. data/app/services/collavre/system_events/envelope.rb +73 -0
  269. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  270. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  271. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  272. data/app/services/collavre/tools/id_list.rb +38 -0
  273. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  274. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  275. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  276. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  277. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  278. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  279. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  280. data/app/services/collavre/tools/topic_selection.rb +59 -0
  281. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  282. data/app/services/collavre/topic_branch_service.rb +39 -10
  283. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  284. data/app/services/collavre/topics/char_budget.rb +70 -0
  285. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  286. data/app/services/collavre/topics/message_page.rb +380 -0
  287. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  288. data/app/services/collavre/topics/message_scope.rb +61 -0
  289. data/app/services/collavre/topics/message_stats.rb +92 -0
  290. data/app/services/collavre/topics/move_blocker.rb +47 -0
  291. data/app/services/collavre/topics/next_name.rb +35 -0
  292. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  293. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  294. data/app/services/collavre/topics/reserved_name.rb +18 -0
  295. data/app/services/collavre/topics/serializer.rb +92 -0
  296. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  297. data/app/services/collavre/topics/topic_move.rb +80 -0
  298. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  299. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  300. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  301. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  302. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  303. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  304. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  305. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  306. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  307. data/app/views/collavre/comments/_comments_popup.html.erb +24 -17
  308. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  309. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  310. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  311. data/app/views/collavre/comments/_list.html.erb +3 -2
  312. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  313. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  314. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  315. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  316. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  317. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  318. data/app/views/collavre/creatives/index.html.erb +79 -18
  319. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  320. data/app/views/collavre/features/index.html.erb +42 -0
  321. data/app/views/collavre/features/show.html.erb +52 -0
  322. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  323. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  324. data/app/views/collavre/invites/show.html.erb +14 -1
  325. data/app/views/collavre/landing/show.html.erb +5 -1
  326. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  327. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  328. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  329. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  330. data/app/views/collavre/user_themes/index.html.erb +1 -1
  331. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  332. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  333. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  334. data/app/views/collavre/users/new.html.erb +4 -2
  335. data/app/views/collavre/users/new_ai.html.erb +14 -32
  336. data/app/views/collavre/users/show.html.erb +13 -1
  337. data/app/views/layouts/collavre/landing.html.erb +9 -4
  338. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  339. data/config/locales/admin.en.yml +3 -1
  340. data/config/locales/admin.ko.yml +3 -1
  341. data/config/locales/agent_gateway.en.yml +217 -0
  342. data/config/locales/agent_gateway.ko.yml +217 -0
  343. data/config/locales/ai_agent.en.yml +0 -1
  344. data/config/locales/ai_agent.ko.yml +0 -1
  345. data/config/locales/comments.en.yml +66 -30
  346. data/config/locales/comments.ko.yml +66 -30
  347. data/config/locales/creatives.en.yml +32 -1
  348. data/config/locales/creatives.ko.yml +32 -1
  349. data/config/locales/features.en.yml +140 -0
  350. data/config/locales/features.ko.yml +140 -0
  351. data/config/locales/invites.en.yml +3 -0
  352. data/config/locales/invites.ko.yml +3 -0
  353. data/config/locales/landing.en.yml +2 -1
  354. data/config/locales/landing.ko.yml +2 -1
  355. data/config/locales/tools.en.yml +20 -0
  356. data/config/locales/tools.ko.yml +20 -0
  357. data/config/locales/users.en.yml +15 -14
  358. data/config/locales/users.ko.yml +14 -14
  359. data/config/routes.rb +45 -19
  360. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  361. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  362. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  363. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  364. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  365. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  366. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  367. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  368. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  369. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  370. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  371. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  372. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  373. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  374. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  375. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  376. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  377. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  378. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  379. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  380. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  381. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  382. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  383. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  384. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  385. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  386. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  387. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  388. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  389. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  390. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  391. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  392. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  393. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  394. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  395. data/db/seeds.rb +1 -51
  396. data/lib/collavre/engine.rb +108 -1
  397. data/lib/collavre/feature_card_registry.rb +168 -0
  398. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  399. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  400. data/lib/collavre/user_extensions.rb +0 -2
  401. data/lib/collavre/version.rb +1 -1
  402. data/lib/collavre.rb +1 -0
  403. data/skills/collavre/SKILL.md +165 -0
  404. data/skills/collavre/package.json +8 -0
  405. data/skills/collavre/references/tool-reference.md +347 -0
  406. data/skills/collavre/scripts/collavre +634 -0
  407. data/skills/collavre/scripts/html_entities.js +24 -0
  408. metadata +222 -30
  409. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  410. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  411. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  412. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  413. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  414. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  415. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  416. data/app/javascript/lib/typo_correction.js +0 -146
  417. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  418. data/app/javascript/modules/creative_guide.js +0 -67
  419. data/app/javascript/modules/typo_corrector.js +0 -534
  420. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  421. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  422. data/app/models/collavre/inbox_item.rb +0 -89
  423. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  424. data/app/services/collavre/comments/work_command.rb +0 -161
  425. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  426. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  427. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  428. data/app/services/collavre/typo_corrector.rb +0 -188
  429. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  430. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  431. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  432. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  433. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  434. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  435. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  436. data/config/locales/mobile.en.yml +0 -16
  437. data/config/locales/mobile.ko.yml +0 -16
@@ -1,5 +1,6 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
  import chatHistory from '../../lib/chat_history'
3
+ import chatDrafts from '../../lib/chat_drafts'
3
4
 
4
5
  const SIZE_STORAGE_KEY = 'commentsPopupSize'
5
6
  const CREATIVE_CLICK_EVENT = 'creative-comments-click'
@@ -12,6 +13,8 @@ export default class extends Controller {
12
13
  'list',
13
14
  'form',
14
15
  'closeButton',
16
+ 'closeIcon',
17
+ 'expandDockedIcon',
15
18
  'leftHandle',
16
19
  'rightHandle',
17
20
  'fullscreenButton',
@@ -31,6 +34,9 @@ export default class extends Controller {
31
34
  this.touchStartY = null
32
35
  this.openFromUrlObserver = null
33
36
  this.openFromUrlTimeout = null
37
+ this.dockedOpenTimeout = null
38
+ this.openGeneration = 0
39
+ this._wakeLockRequest = null
34
40
  this.handleCreativeClick = this.handleCreativeClick.bind(this)
35
41
  this.handleCreativeDestroyed = this.handleCreativeDestroyed.bind(this)
36
42
  this.handleEditingStart = this.handleEditingStart.bind(this)
@@ -48,6 +54,7 @@ export default class extends Controller {
48
54
  this.handlePopupWheel = this.handlePopupWheel.bind(this)
49
55
  this.handleChatNavKeydown = this.handleChatNavKeydown.bind(this)
50
56
  this.handleDropdownOutsideClick = this.handleDropdownOutsideClick.bind(this)
57
+ this.handleDockedMediaChange = this.handleDockedMediaChange.bind(this)
51
58
  this._longPressTimer = null
52
59
  this._longPressTriggered = false
53
60
  this._isNavigating = false
@@ -64,6 +71,16 @@ export default class extends Controller {
64
71
  document.addEventListener('visibilitychange', this.handleVisibilityChange)
65
72
  window.addEventListener('popstate', this.handlePopState)
66
73
  document.addEventListener('keydown', this.handleChatNavKeydown)
74
+ this.dockedMediaQuery = typeof window.matchMedia === 'function'
75
+ ? window.matchMedia('(min-width: 768px)')
76
+ : {
77
+ matches: window.innerWidth >= 768,
78
+ addEventListener() {},
79
+ removeEventListener() {},
80
+ }
81
+ if (this.element.dataset.docked === 'true') {
82
+ this.dockedMediaQuery.addEventListener('change', this.handleDockedMediaChange)
83
+ }
67
84
 
68
85
  // Long press on nav buttons
69
86
  this._setupNavLongPress()
@@ -94,8 +111,16 @@ export default class extends Controller {
94
111
  }
95
112
  }
96
113
 
97
- document.querySelectorAll('form[action="/session"]').forEach((form) => {
98
- form.addEventListener('submit', () => window.localStorage.removeItem(SIZE_STORAGE_KEY))
114
+ document.querySelectorAll('form[action$="/session"]').forEach((form) => {
115
+ form.addEventListener('submit', () => {
116
+ this.formController?.discardDraft()
117
+ chatDrafts.clearAll()
118
+ try {
119
+ window.localStorage.removeItem(SIZE_STORAGE_KEY)
120
+ } catch {
121
+ // Storage can be unavailable on restricted origins; logout must continue.
122
+ }
123
+ })
99
124
  })
100
125
 
101
126
  if (this.element.dataset.autoFullscreen === 'true') {
@@ -115,6 +140,8 @@ export default class extends Controller {
115
140
  this._syncFullscreenUI(true)
116
141
  // Defer to ensure all sibling controllers are connected
117
142
  requestAnimationFrame(() => this.openForCreative())
143
+ } else if (this.isDocked()) {
144
+ this.enterDockedMode()
118
145
  } else {
119
146
  this.openFromUrl()
120
147
  }
@@ -123,6 +150,7 @@ export default class extends Controller {
123
150
  disconnect() {
124
151
  this._releaseWakeLock()
125
152
  this.clearPendingOpenFromUrl()
153
+ if (this.dockedOpenTimeout) window.clearTimeout(this.dockedOpenTimeout)
126
154
  document.removeEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
127
155
  document.removeEventListener(CREATIVE_DESTROYED_EVENT, this.handleCreativeDestroyed)
128
156
  document.removeEventListener('creative-editing:start', this.handleEditingStart)
@@ -133,6 +161,7 @@ export default class extends Controller {
133
161
  document.removeEventListener('visibilitychange', this.handleVisibilityChange)
134
162
  window.removeEventListener('popstate', this.handlePopState)
135
163
  document.removeEventListener('keydown', this.handleChatNavKeydown)
164
+ this.dockedMediaQuery?.removeEventListener('change', this.handleDockedMediaChange)
136
165
  document.removeEventListener('click', this.handleDropdownOutsideClick)
137
166
  this._clearLongPressTimer()
138
167
  if (this.hasHeaderTarget) {
@@ -184,15 +213,97 @@ export default class extends Controller {
184
213
  handleCreativeClick(event) {
185
214
  const button = event.detail?.button
186
215
  const creativeId = event.detail?.creativeId
216
+ const highlightId = event.detail?.highlightId
187
217
  if (!button) return
218
+ if (event.detail?.workspaceSync && !this.isDocked()) {
219
+ const nextCreativeId = creativeId || button.dataset.creativeId || ''
220
+ if (
221
+ this.element.style.display === 'flex' &&
222
+ this.element.dataset.creativeId !== String(nextCreativeId)
223
+ ) {
224
+ this.close()
225
+ }
226
+ return
227
+ }
228
+ if (!creativeId && button.dataset.workspaceNavigationState === 'true' && this.isDocked()) {
229
+ this.resetDockedToEmpty()
230
+ return
231
+ }
232
+ // A collapsed docked chat is a 3rem rail, so an explicit chat-icon click has
233
+ // to expand it. Workspace tree navigation must not: the user collapsed the
234
+ // chat on purpose and moving around the tree should leave it that way.
235
+ const userRequestedOpen = !event.detail?.workspaceSync
188
236
  if (
189
237
  this.element.style.display === 'flex' &&
190
238
  this.element.dataset.creativeId === (creativeId || button.dataset.creativeId)
191
239
  ) {
240
+ if (this.isDocked()) {
241
+ if (userRequestedOpen) {
242
+ // Already loaded for this creative — expand only, so the draft and
243
+ // subscriptions survive.
244
+ this.expandDocked()
245
+ } else if (highlightId) {
246
+ this.reloadDockedHighlight(creativeId, highlightId)
247
+ }
248
+ return
249
+ }
192
250
  this.close()
193
251
  return
194
252
  }
195
- this.open(button, { creativeId })
253
+ if (userRequestedOpen) this.expandDocked()
254
+ const openOptions = { creativeId }
255
+ if (highlightId) openOptions.highlightId = highlightId
256
+ this.open(button, openOptions)
257
+ }
258
+
259
+ reloadDockedHighlight(creativeId, highlightId) {
260
+ this.openGeneration += 1
261
+ const listController = this.listController
262
+ // This direct reload supersedes any full open that is still waiting for
263
+ // topics. That open will stop at its generation check, so release the
264
+ // suppression it installed before starting the replacement highlight load.
265
+ if (listController) listController.suppressTopicChangeLoad = false
266
+ const existingComment = document.getElementById(`comment_${highlightId}`)
267
+ if (existingComment && listController?.listTarget?.contains(existingComment)) {
268
+ listController.highlightComment(highlightId)
269
+ return
270
+ }
271
+
272
+ listController?.onPopupOpened({
273
+ creativeId,
274
+ highlightId,
275
+ topicId: this.topicsController?.currentTopicId,
276
+ })
277
+ }
278
+
279
+ resetDockedToEmpty() {
280
+ this.openGeneration += 1
281
+
282
+ if (this.isFullscreen()) this._exitFullscreenState()
283
+
284
+ if (this.dockedOpenTimeout) {
285
+ window.clearTimeout(this.dockedOpenTimeout)
286
+ this.dockedOpenTimeout = null
287
+ }
288
+
289
+ this.currentButton = null
290
+ this.element.dataset.creativeId = ''
291
+ this.element.dataset.canComment = 'false'
292
+ this.element.dataset.creativeSnippet = ''
293
+ this.titleTarget.textContent = this.element.dataset.defaultTitle || ''
294
+ this._clearChatActiveRow()
295
+ this._hideNavDropdown()
296
+
297
+ if (this.listController) this.listController.creativeId = null
298
+ this.closeChildControllers()
299
+ this.formController?.setCommentPermission(false)
300
+ this.element.querySelector('#comment-topics')?.replaceChildren()
301
+ if (this.hasListTarget) {
302
+ this.listTarget.classList.add('docked-empty')
303
+ this.listTarget.textContent = this.element.dataset.dockedEmptyText || ''
304
+ }
305
+ this.showPopup()
306
+ this.dispatchPopupClosed()
196
307
  }
197
308
 
198
309
  handleCreativeDestroyed(event) {
@@ -204,7 +315,11 @@ export default class extends Controller {
204
315
 
205
316
  if (this.element.style.display !== 'flex') return
206
317
  if (destroyedIds.includes(this.element.dataset.creativeId)) {
207
- this.close()
318
+ if (this.isDocked() && !this.isFullscreen()) {
319
+ this.resetDockedToEmpty()
320
+ } else {
321
+ this.close()
322
+ }
208
323
  }
209
324
  }
210
325
 
@@ -219,6 +334,8 @@ export default class extends Controller {
219
334
  }
220
335
 
221
336
  async open(button, { creativeId, highlightId } = {}) {
337
+ const openGeneration = ++this.openGeneration
338
+ if (this.hasListTarget) this.listTarget.classList.remove('docked-empty')
222
339
  this.currentButton = button
223
340
  const resolvedCreativeId = creativeId || button?.dataset.creativeId
224
341
  const canComment = button.dataset.canComment === 'true'
@@ -236,7 +353,13 @@ export default class extends Controller {
236
353
  this.showPopup()
237
354
  this.updatePosition()
238
355
 
239
- await this.notifyChildControllers({ creativeId: resolvedCreativeId, canComment, highlightId })
356
+ const opened = await this.notifyChildControllers({
357
+ creativeId: resolvedCreativeId,
358
+ canComment,
359
+ highlightId,
360
+ openGeneration,
361
+ })
362
+ if (!opened) return
240
363
 
241
364
  // Track in chat navigation history (skip if navigating via back/forward)
242
365
  if (!this._isNavigating) {
@@ -254,7 +377,9 @@ export default class extends Controller {
254
377
  }))
255
378
  }
256
379
 
257
- async openForCreative() {
380
+ async openForCreative({ highlightId } = {}) {
381
+ const openGeneration = ++this.openGeneration
382
+ if (this.hasListTarget) this.listTarget.classList.remove('docked-empty')
258
383
  const resolvedCreativeId = this.element.dataset.creativeId
259
384
  const canComment = this.element.dataset.canComment === 'true'
260
385
  const snippet = this.element.dataset.creativeSnippet || ''
@@ -269,7 +394,13 @@ export default class extends Controller {
269
394
 
270
395
  this.showPopup()
271
396
 
272
- await this.notifyChildControllers({ creativeId: resolvedCreativeId, canComment })
397
+ const opened = await this.notifyChildControllers({
398
+ creativeId: resolvedCreativeId,
399
+ canComment,
400
+ highlightId,
401
+ openGeneration,
402
+ })
403
+ if (!opened) return
273
404
 
274
405
  // Track in chat navigation history
275
406
  if (!this._isNavigating) {
@@ -287,7 +418,7 @@ export default class extends Controller {
287
418
  }))
288
419
  }
289
420
 
290
- async notifyChildControllers({ creativeId, canComment, highlightId }) {
421
+ async notifyChildControllers({ creativeId, canComment, highlightId, openGeneration }) {
291
422
  this.topicsController?.clearOverrideTopicId()
292
423
  // Drop the previous creative's topic selection from the form controller
293
424
  // synchronously, BEFORE topics loadTopics() dispatches `comments--topics:change`
@@ -295,6 +426,7 @@ export default class extends Controller {
295
426
  // formController.onPopupOpened, which runs after the topics await — would
296
427
  // erase the topic that restoreSelection() just restored from the server.
297
428
  if (this.formController) {
429
+ this.formController.onChatWillOpen?.({ creativeId })
298
430
  this.formController.currentTopicId = ''
299
431
  this.formController._mainTopicId = null
300
432
  }
@@ -307,18 +439,28 @@ export default class extends Controller {
307
439
  // Without this, the topic change event fires loadInitialComments() before
308
440
  // onPopupOpened sets highlightAfterLoad, causing a race where the non-highlight
309
441
  // load can overwrite the deep-link highlight load.
310
- if (this.listController) {
311
- this.listController.creativeId = creativeId
312
- this.listController.suppressTopicChangeLoad = true
442
+ const suppressedListController = this.listController
443
+ if (suppressedListController) {
444
+ suppressedListController.creativeId = creativeId
445
+ suppressedListController.suppressTopicChangeLoad = true
313
446
  }
314
447
 
315
448
  // Load topics first to establish context
316
- if (this.topicsController) {
317
- await this.topicsController.onPopupOpened({ creativeId })
449
+ try {
450
+ if (this.topicsController) {
451
+ await this.topicsController.onPopupOpened({ creativeId })
452
+ }
453
+ } catch (error) {
454
+ if (openGeneration === this.openGeneration && suppressedListController) {
455
+ suppressedListController.suppressTopicChangeLoad = false
456
+ }
457
+ throw error
318
458
  }
319
459
 
320
- if (this.listController) {
321
- this.listController.suppressTopicChangeLoad = false
460
+ if (openGeneration !== this.openGeneration) return false
461
+
462
+ if (suppressedListController) {
463
+ suppressedListController.suppressTopicChangeLoad = false
322
464
  }
323
465
 
324
466
  if (this.formController) {
@@ -340,63 +482,21 @@ export default class extends Controller {
340
482
  if (this.dropTriggerController) {
341
483
  this.dropTriggerController.onPopupOpened({ creativeId })
342
484
  }
485
+ return true
343
486
  }
344
487
 
345
488
  close() {
346
- if (this.presenceController) {
347
- this.presenceController.onPopupClosed()
348
- }
349
- if (this.formController) {
350
- this.formController.onPopupClosed()
351
- }
352
- if (this.listController) {
353
- this.listController.onPopupClosed()
354
- }
355
- if (this.mentionMenuController) {
356
- this.mentionMenuController.onPopupClosed()
357
- }
358
- if (this.topicsController) {
359
- this.topicsController.onPopupClosed()
360
- }
361
- if (this.contextsController) {
362
- this.contextsController.onPopupClosed()
363
- }
364
- if (this.dropTriggerController) {
365
- this.dropTriggerController.onPopupClosed()
489
+ if (this.isDocked() && !this.isFullscreen()) {
490
+ this.toggleDocked()
491
+ return
366
492
  }
367
493
 
368
- // Dispatch event for integrations
369
- this.element.dispatchEvent(new CustomEvent('comments-popup:closed', {
370
- bubbles: true,
371
- detail: {
372
- badgeContainer: this.element.querySelector('[data-integration-badges]')
373
- }
374
- }))
494
+ this.openGeneration += 1
375
495
 
376
- // Exit fullscreen state if active
377
- if (this.isFullscreen()) {
378
- this.element.dataset.fullscreen = 'false'
379
- document.body.classList.remove('chat-fullscreen')
380
- this._syncFullscreenUI(false)
381
- this._savedStyles = null
496
+ this.closeChildControllers()
497
+ this.dispatchPopupClosed()
382
498
 
383
- // Navigate back from fullscreen URL — use replaceState to consume the
384
- // fullscreen history entry instead of pushing a new one, preventing a
385
- // stale fullscreen entry from being reached via the Back button.
386
- const creativeId = this.element.dataset.creativeId
387
- const backUrl = this._previousUrl || (creativeId ? `/creatives/${creativeId}` : null)
388
- if (backUrl) {
389
- const url = new URL(backUrl, window.location.origin)
390
- // Strip comment auto-open markers so a refresh after close doesn't
391
- // re-open the popup (handles ?open_comments, ?comment_id, #comment_*).
392
- url.searchParams.delete('open_comments')
393
- url.searchParams.delete('comment_id')
394
- const cleanPath = url.pathname.replace(/\/comments\/\d+$/, '')
395
- url.hash = url.hash.replace(/^#comment_\d+$/, '')
396
- window.history.replaceState({ fullscreen: false }, '', cleanPath + url.search + url.hash)
397
- }
398
- this._previousUrl = null
399
- }
499
+ this._exitFullscreenState()
400
500
 
401
501
  this._clearChatActiveRow()
402
502
  this._hideNavDropdown()
@@ -414,7 +514,69 @@ export default class extends Controller {
414
514
  delete this.element.dataset.resized
415
515
  }
416
516
 
517
+ _exitFullscreenState() {
518
+ if (!this.isFullscreen()) return
519
+
520
+ if (this._enterCleanupTimer) {
521
+ window.clearTimeout(this._enterCleanupTimer)
522
+ this._enterCleanupTimer = null
523
+ }
524
+ if (this._enterCleanupFn) {
525
+ this.element.removeEventListener('transitionend', this._enterCleanupFn)
526
+ this._enterCleanupFn = null
527
+ }
528
+
529
+ this.element.dataset.fullscreen = 'false'
530
+ document.body.classList.remove('chat-fullscreen')
531
+ this._syncFullscreenUI(false)
532
+ this._savedStyles = null
533
+ this.element.style.transition = ''
534
+ this.element.style.position = ''
535
+ this.element.style.top = ''
536
+ this.element.style.left = ''
537
+ this.element.style.right = ''
538
+ this.element.style.bottom = ''
539
+ this.element.style.width = ''
540
+ this.element.style.height = ''
541
+ this.element.style.transform = ''
542
+
543
+ // Consume the fullscreen history entry and remove markers that could
544
+ // reopen the invalidated chat after a reset or close.
545
+ const creativeId = this.element.dataset.creativeId
546
+ const backUrl = this._previousUrl || (creativeId ? `/creatives/${creativeId}` : null)
547
+ if (backUrl) {
548
+ const url = new URL(backUrl, window.location.origin)
549
+ url.searchParams.delete('open_comments')
550
+ url.searchParams.delete('comment_id')
551
+ const cleanPath = url.pathname.replace(/\/comments\/\d+$/, '')
552
+ url.hash = url.hash.replace(/^#comment_\d+$/, '')
553
+ window.history.replaceState({ fullscreen: false }, '', cleanPath + url.search + url.hash)
554
+ }
555
+ this._previousUrl = null
556
+ }
557
+
558
+ closeChildControllers() {
559
+ this.presenceController?.onPopupClosed()
560
+ this.formController?.onPopupClosed()
561
+ this.listController?.onPopupClosed()
562
+ this.mentionMenuController?.onPopupClosed()
563
+ this.topicsController?.onPopupClosed()
564
+ this.contextsController?.onPopupClosed()
565
+ this.dropTriggerController?.onPopupClosed()
566
+ }
567
+
568
+ dispatchPopupClosed() {
569
+ this.element.dispatchEvent(new CustomEvent('comments-popup:closed', {
570
+ bubbles: true,
571
+ detail: {
572
+ badgeContainer: this.element.querySelector('[data-integration-badges]')
573
+ }
574
+ }))
575
+ }
576
+
417
577
  prepareSize() {
578
+ if (this.isDocked()) return
579
+
418
580
  const stored = window.localStorage.getItem(SIZE_STORAGE_KEY)
419
581
  if (!stored) return
420
582
  try {
@@ -432,10 +594,13 @@ export default class extends Controller {
432
594
 
433
595
  showPopup() {
434
596
  this.element.style.display = 'flex'
435
- if (this.isMobile()) {
597
+ if (this.isDocked()) {
598
+ this.element.classList.add('docked')
599
+ this.syncDockedUI()
600
+ } else if (this.isMobile()) {
436
601
  this.element.classList.add('open')
437
602
  }
438
- this._requestWakeLock()
603
+ this._syncWakeLock()
439
604
  }
440
605
 
441
606
  isFullscreen() {
@@ -446,8 +611,97 @@ export default class extends Controller {
446
611
  return window.innerWidth <= 600
447
612
  }
448
613
 
614
+ isDocked() {
615
+ return this.element.dataset.docked === 'true' && this.dockedMediaQuery?.matches === true
616
+ }
617
+
618
+ enterDockedMode() {
619
+ const el = this.element
620
+ if (this.dockedOpenTimeout) window.clearTimeout(this.dockedOpenTimeout)
621
+ el.classList.add('docked')
622
+ el.classList.remove('open')
623
+ el.style.position = ''
624
+ el.style.top = ''
625
+ el.style.left = ''
626
+ el.style.right = ''
627
+ el.style.bottom = ''
628
+ el.style.width = ''
629
+ el.style.height = ''
630
+ delete el.dataset.resized
631
+ this.showPopup()
632
+
633
+ if (el.dataset.creativeId) {
634
+ requestAnimationFrame(() => {
635
+ this.dockedOpenTimeout = window.setTimeout(() => {
636
+ this.dockedOpenTimeout = null
637
+ if (!this.element.isConnected || !this.isDocked()) return
638
+
639
+ this.openForCreative({ highlightId: this.commentIdFromUrl() })
640
+ }, 0)
641
+ })
642
+ } else if (this.hasListTarget) {
643
+ this.listTarget.classList.add('docked-empty')
644
+ this.listTarget.textContent = el.dataset.dockedEmptyText || ''
645
+ }
646
+ }
647
+
648
+ handleDockedMediaChange(event) {
649
+ if (event.matches) {
650
+ this.enterDockedMode()
651
+ } else {
652
+ this.element.classList.remove('docked', 'docked-collapsed')
653
+ this.syncDockedUI()
654
+ this.close()
655
+ }
656
+ }
657
+
658
+ toggleDocked() {
659
+ if (!this.isDocked()) return
660
+
661
+ if (this.element.classList.contains('docked-collapsed')) {
662
+ this.expandDocked()
663
+ return
664
+ }
665
+
666
+ this.element.classList.add('docked-collapsed')
667
+ this.syncDockedUI()
668
+ this._syncWakeLock()
669
+ }
670
+
671
+ expandDocked() {
672
+ if (!this.isDocked()) return
673
+ if (!this.element.classList.contains('docked-collapsed')) return
674
+
675
+ this.element.classList.remove('docked-collapsed')
676
+ this.syncDockedUI()
677
+ this._syncWakeLock()
678
+ requestAnimationFrame(() => this.listController?.scrollToBottom())
679
+ }
680
+
681
+ syncDockedUI() {
682
+ if (!this.hasCloseButtonTarget) return
683
+
684
+ if (!this.isDocked()) {
685
+ const label = this.element.dataset.closeLabel || ''
686
+ this.closeIconTarget.style.display = ''
687
+ this.expandDockedIconTarget.style.display = 'none'
688
+ this.closeButtonTarget.setAttribute('aria-label', label)
689
+ this.closeButtonTarget.title = label
690
+ return
691
+ }
692
+
693
+ const collapsed = this.element.classList.contains('docked-collapsed')
694
+ const label = collapsed
695
+ ? (this.element.dataset.expandDockedLabel || '')
696
+ : (this.element.dataset.collapseDockedLabel || '')
697
+ this.closeIconTarget.style.display = collapsed ? 'none' : ''
698
+ this.expandDockedIconTarget.style.display = collapsed ? '' : 'none'
699
+ this.closeButtonTarget.setAttribute('aria-label', label)
700
+ this.closeButtonTarget.title = label
701
+ }
702
+
449
703
  updatePosition() {
450
- if (this.isFullscreen() || !this.currentButton || this.isMobile() || this.element.dataset.resized === 'true') return
704
+ if (this.isDocked() || this.isFullscreen() || !this.currentButton || this.isMobile() || this.element.dataset.resized === 'true') return
451
705
  const rect = this.currentButton.getBoundingClientRect()
452
706
  const popupWidth = this.element.offsetWidth
453
707
  const popupHeight = this.element.offsetHeight
@@ -473,6 +727,8 @@ export default class extends Controller {
473
727
  }
474
728
 
475
729
  startResize(event, direction) {
730
+ if (this.isDocked()) return
731
+
476
732
  event.preventDefault()
477
733
  const rect = this.element.getBoundingClientRect()
478
734
  this.resizeStartX = event.clientX
@@ -542,7 +798,7 @@ export default class extends Controller {
542
798
  this.touchStartY = null
543
799
  return
544
800
  }
545
- if (event.target.closest('#comments-list') || event.target.closest('.chat-nav-dropdown')) {
801
+ if (event.target.closest('#comments-list, .chat-nav-dropdown, #topic-list-modal')) {
546
802
  this.touchStartY = null
547
803
  } else {
548
804
  this.touchStartY = event.touches[0].clientY
@@ -583,7 +839,7 @@ export default class extends Controller {
583
839
  if (!document.hidden && this.element.style.display === 'flex') {
584
840
  this.listController?.loadInitialComments()
585
841
  // Re-acquire wake lock — released automatically when tab loses visibility
586
- this._requestWakeLock()
842
+ this._syncWakeLock()
587
843
  }
588
844
  }
589
845
 
@@ -799,6 +1055,34 @@ export default class extends Controller {
799
1055
  return
800
1056
  }
801
1057
 
1058
+ if (this.isDocked()) {
1059
+ el.style.transition = 'none'
1060
+ el.dataset.fullscreen = 'false'
1061
+ document.body.classList.remove('chat-fullscreen')
1062
+ el.style.position = ''
1063
+ el.style.top = ''
1064
+ el.style.left = ''
1065
+ el.style.right = ''
1066
+ el.style.bottom = ''
1067
+ el.style.width = ''
1068
+ el.style.height = ''
1069
+ this._savedStyles = null
1070
+ this._syncFullscreenUI(false)
1071
+ this.syncDockedUI()
1072
+ el.offsetHeight // eslint-disable-line no-unused-expressions
1073
+ el.style.transition = ''
1074
+
1075
+ let backUrl = this._previousUrl || (creativeId ? `/creatives/${creativeId}` : null)
1076
+ if (backUrl) {
1077
+ const url = new URL(backUrl, window.location.origin)
1078
+ url.searchParams.set('open_comments', 'true')
1079
+ window.history.pushState({ fullscreen: false }, '', url.pathname + url.search)
1080
+ }
1081
+ this._previousUrl = null
1082
+ requestAnimationFrame(() => this.listController?.scrollToBottom())
1083
+ return
1084
+ }
1085
+
802
1086
  // Desktop: animated exit to target position
803
1087
  // Calculate target position using viewport-relative coords (popup is position: fixed)
804
1088
  let finalTop = '' // px string (viewport-relative)
@@ -899,7 +1183,15 @@ export default class extends Controller {
899
1183
  el.style.width = `${animWidth}px`
900
1184
  el.style.height = `${animHeight}px`
901
1185
 
1186
+ let cleanupTimer = null
1187
+ let cleanedUp = false
902
1188
  const cleanup = () => {
1189
+ if (cleanedUp) return
1190
+ cleanedUp = true
1191
+ if (cleanupTimer !== null) {
1192
+ clearTimeout(cleanupTimer)
1193
+ cleanupTimer = null
1194
+ }
903
1195
  el.removeEventListener('transitionend', cleanup)
904
1196
  // Popup is always position: fixed — just apply final coords
905
1197
  el.style.transition = 'none'
@@ -940,7 +1232,7 @@ export default class extends Controller {
940
1232
  this.topicsController?.scrollToActiveTopic()
941
1233
  }
942
1234
  el.addEventListener('transitionend', cleanup, { once: true })
943
- setTimeout(cleanup, 300)
1235
+ cleanupTimer = setTimeout(cleanup, 300)
944
1236
 
945
1237
  // Update URL — append open_comments=true so the popup stays open on refresh
946
1238
  let backUrl = this._previousUrl || (creativeId ? `/creatives/${creativeId}` : null)
@@ -976,6 +1268,10 @@ export default class extends Controller {
976
1268
  el.dataset.fullscreen = isFs ? 'true' : 'false'
977
1269
  document.body.classList.toggle('chat-fullscreen', isFs)
978
1270
  this._syncFullscreenUI(isFs)
1271
+ if (!isFs && this.isDocked()) {
1272
+ el.style.display = 'flex'
1273
+ this.syncDockedUI()
1274
+ }
979
1275
 
980
1276
  if (!isFs && this._savedStyles) {
981
1277
  Object.assign(el.style, this._savedStyles)
@@ -998,10 +1294,10 @@ export default class extends Controller {
998
1294
  this.exitFullscreenIconTarget.style.display = entering ? '' : 'none'
999
1295
  }
1000
1296
  if (this.hasLeftHandleTarget) {
1001
- this.leftHandleTarget.style.display = entering ? 'none' : ''
1297
+ this.leftHandleTarget.style.display = entering || this.isDocked() ? 'none' : ''
1002
1298
  }
1003
1299
  if (this.hasRightHandleTarget) {
1004
- this.rightHandleTarget.style.display = entering ? 'none' : ''
1300
+ this.rightHandleTarget.style.display = entering || this.isDocked() ? 'none' : ''
1005
1301
  }
1006
1302
  if (this.hasCloseButtonTarget) {
1007
1303
  this.closeButtonTarget.style.display = entering ? 'none' : ''
@@ -1012,11 +1308,11 @@ export default class extends Controller {
1012
1308
  : (this.element.dataset.fullscreenLabel || 'Full screen')
1013
1309
  this.fullscreenButtonTarget.setAttribute('aria-label', label)
1014
1310
  }
1311
+ if (!entering) this.syncDockedUI()
1015
1312
  }
1016
1313
 
1017
- openFromUrl() {
1314
+ commentIdFromUrl() {
1018
1315
  const params = new URLSearchParams(window.location.search)
1019
- const openComments = params.get('open_comments') === 'true'
1020
1316
  let commentId = params.get('comment_id')
1021
1317
  if (!commentId) {
1022
1318
  const pathCommentMatch = window.location.pathname.match(/\/creatives\/\d+\/comments\/(\d+)/)
@@ -1031,6 +1327,14 @@ export default class extends Controller {
1031
1327
  }
1032
1328
  }
1033
1329
 
1330
+ return commentId || undefined
1331
+ }
1332
+
1333
+ openFromUrl() {
1334
+ const params = new URLSearchParams(window.location.search)
1335
+ const openComments = params.get('open_comments') === 'true'
1336
+ const commentId = this.commentIdFromUrl()
1337
+
1034
1338
  let creativeId = params.get('id')
1035
1339
  if (!creativeId) {
1036
1340
  const pathCreativeMatch = window.location.pathname.match(/\/creatives\/(\d+)/)
@@ -1359,15 +1663,53 @@ export default class extends Controller {
1359
1663
  // is open. The browser automatically releases the lock when the tab
1360
1664
  // loses visibility, so we re-acquire it in handleVisibilityChange().
1361
1665
 
1666
+ _shouldHoldWakeLock() {
1667
+ if (this.element.style.display !== 'flex') return false
1668
+ if (this.isFullscreen()) return true
1669
+ if (!this.isDocked()) return true
1670
+
1671
+ return Boolean(this.element.dataset.creativeId) &&
1672
+ !this.element.classList.contains('docked-collapsed')
1673
+ }
1674
+
1675
+ _syncWakeLock() {
1676
+ if (this._shouldHoldWakeLock()) {
1677
+ this._requestWakeLock()
1678
+ } else {
1679
+ this._releaseWakeLock()
1680
+ }
1681
+ }
1682
+
1362
1683
  async _requestWakeLock() {
1363
- if (!('wakeLock' in navigator)) return
1684
+ if (
1685
+ !this._shouldHoldWakeLock() ||
1686
+ !('wakeLock' in navigator) ||
1687
+ this._wakeLock ||
1688
+ this._wakeLockRequest
1689
+ ) return
1364
1690
 
1691
+ let request
1365
1692
  try {
1366
- this._wakeLock = await navigator.wakeLock.request('screen')
1367
- this._wakeLock.addEventListener('release', () => {
1368
- this._wakeLock = null
1693
+ request = navigator.wakeLock.request('screen')
1694
+ this._wakeLockRequest = request
1695
+ const wakeLock = await request
1696
+ if (this._wakeLockRequest !== request) {
1697
+ wakeLock.release()
1698
+ return
1699
+ }
1700
+
1701
+ this._wakeLockRequest = null
1702
+ if (!this._shouldHoldWakeLock()) {
1703
+ wakeLock.release()
1704
+ return
1705
+ }
1706
+
1707
+ this._wakeLock = wakeLock
1708
+ wakeLock.addEventListener('release', () => {
1709
+ if (this._wakeLock === wakeLock) this._wakeLock = null
1369
1710
  })
1370
1711
  } catch (err) {
1712
+ if (this._wakeLockRequest === request) this._wakeLockRequest = null
1371
1713
  // Wake lock request can fail (e.g. low battery, browser policy).
1372
1714
  // This is non-critical — just log and continue.
1373
1715
  console.debug('[chat] Wake lock request failed:', err.message)
@@ -1375,9 +1717,9 @@ export default class extends Controller {
1375
1717
  }
1376
1718
 
1377
1719
  _releaseWakeLock() {
1378
- if (this._wakeLock) {
1379
- this._wakeLock.release()
1380
- this._wakeLock = null
1381
- }
1720
+ this._wakeLockRequest = null
1721
+ const wakeLock = this._wakeLock
1722
+ this._wakeLock = null
1723
+ wakeLock?.release()
1382
1724
  }
1383
1725
  }