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
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import {
5
+ creativeTreeContainer,
6
+ hideTreeEmptyState,
7
+ restoreTreeEmptyState,
8
+ PAGINATION_PENDING_ATTRIBUTE,
9
+ } from '../creative_tree_empty_state'
10
+
11
+ const EMPTY_HTML = '<div data-creatives-empty-state=""><p>No sub-creatives found.</p></div>'
12
+
13
+ const EMPTY_TEMPLATE = `<template id="creatives-empty-state-template">${EMPTY_HTML}</template>`
14
+
15
+ function renderEmptyTree() {
16
+ document.body.innerHTML = `<div id="creatives">${EMPTY_HTML}</div>`
17
+ return document.getElementById('creatives')
18
+ }
19
+
20
+ // The state after a client-side tree render: rows replaced the whole container,
21
+ // so the server-rendered placeholder is gone and only the template survives.
22
+ function renderRenderedTree(innerHtml = '') {
23
+ document.body.innerHTML = `${EMPTY_TEMPLATE}<div id="creatives">${innerHtml}</div>`
24
+ return document.getElementById('creatives')
25
+ }
26
+
27
+ function placeholder(container) {
28
+ return container.querySelector('[data-creatives-empty-state]')
29
+ }
30
+
31
+ afterEach(() => {
32
+ document.body.innerHTML = ''
33
+ })
34
+
35
+ test('creativeTreeContainer resolves the tree container', () => {
36
+ const container = renderEmptyTree()
37
+ expect(creativeTreeContainer()).toBe(container)
38
+ })
39
+
40
+ test('creativeTreeContainer returns null when the tree is not on the page', () => {
41
+ document.body.innerHTML = ''
42
+ expect(creativeTreeContainer()).toBeNull()
43
+ })
44
+
45
+ test('hideTreeEmptyState hides the placeholder', () => {
46
+ const container = renderEmptyTree()
47
+
48
+ hideTreeEmptyState(container)
49
+
50
+ expect(placeholder(container).hidden).toBe(true)
51
+ expect(placeholder(container).style.display).toBe('none')
52
+ })
53
+
54
+ test('hideTreeEmptyState defaults to #creatives when no container is passed', () => {
55
+ const container = renderEmptyTree()
56
+
57
+ hideTreeEmptyState()
58
+
59
+ expect(placeholder(container).hidden).toBe(true)
60
+ })
61
+
62
+ test('hideTreeEmptyState is a no-op without a tree container', () => {
63
+ document.body.innerHTML = ''
64
+ expect(() => hideTreeEmptyState()).not.toThrow()
65
+ expect(() => hideTreeEmptyState(null)).not.toThrow()
66
+ })
67
+
68
+ test('restoreTreeEmptyState shows the placeholder again once the tree is empty', () => {
69
+ const container = renderEmptyTree()
70
+ hideTreeEmptyState(container)
71
+
72
+ restoreTreeEmptyState(container)
73
+
74
+ expect(placeholder(container).hidden).toBe(false)
75
+ expect(placeholder(container).style.display).toBe('')
76
+ expect(placeholder(container).textContent).toContain('No sub-creatives found.')
77
+ })
78
+
79
+ test('restoreTreeEmptyState keeps the placeholder hidden while rows remain', () => {
80
+ const container = renderEmptyTree()
81
+ hideTreeEmptyState(container)
82
+ container.insertAdjacentHTML('beforeend', '<creative-tree-row creative-id="7"></creative-tree-row>')
83
+
84
+ restoreTreeEmptyState(container)
85
+
86
+ expect(placeholder(container).hidden).toBe(true)
87
+ })
88
+
89
+ test('restoreTreeEmptyState does not duplicate the placeholder', () => {
90
+ const container = renderEmptyTree()
91
+
92
+ restoreTreeEmptyState(container)
93
+
94
+ expect(container.querySelectorAll('[data-creatives-empty-state]')).toHaveLength(1)
95
+ })
96
+
97
+ test('restoreTreeEmptyState is a no-op when neither placeholder nor template exists', () => {
98
+ document.body.innerHTML = '<div id="creatives"></div>'
99
+ const container = document.getElementById('creatives')
100
+
101
+ restoreTreeEmptyState(container)
102
+
103
+ expect(container.innerHTML).toBe('')
104
+ })
105
+
106
+ test('restoreTreeEmptyState clones the template when the tree render wiped the container', () => {
107
+ const container = renderRenderedTree()
108
+
109
+ restoreTreeEmptyState(container)
110
+
111
+ const restored = placeholder(container)
112
+ expect(restored).not.toBeNull()
113
+ expect(restored.hidden).toBe(false)
114
+ expect(restored.textContent).toContain('No sub-creatives found.')
115
+ })
116
+
117
+ test('restoreTreeEmptyState does not clone the template while rows remain', () => {
118
+ const container = renderRenderedTree('<creative-tree-row creative-id="7"></creative-tree-row>')
119
+
120
+ restoreTreeEmptyState(container)
121
+
122
+ expect(placeholder(container)).toBeNull()
123
+ })
124
+
125
+ test('restoreTreeEmptyState does not clone the template when a placeholder is already present', () => {
126
+ document.body.innerHTML = `${EMPTY_TEMPLATE}<div id="creatives">${EMPTY_HTML}</div>`
127
+ const container = document.getElementById('creatives')
128
+ hideTreeEmptyState(container)
129
+
130
+ restoreTreeEmptyState(container)
131
+
132
+ expect(container.querySelectorAll('[data-creatives-empty-state]')).toHaveLength(1)
133
+ expect(placeholder(container).hidden).toBe(false)
134
+ })
135
+
136
+ test('cloning the template leaves the template itself untouched', () => {
137
+ const container = renderRenderedTree()
138
+
139
+ restoreTreeEmptyState(container)
140
+ hideTreeEmptyState(container)
141
+ container.replaceChildren()
142
+ restoreTreeEmptyState(container)
143
+
144
+ // A second restore after a second wipe must still produce a visible placeholder:
145
+ // hideTreeEmptyState() must not have mutated the template's own copy.
146
+ expect(placeholder(container).hidden).toBe(false)
147
+ })
148
+
149
+ test('restoreTreeEmptyState is a no-op without a tree container', () => {
150
+ document.body.innerHTML = ''
151
+ expect(() => restoreTreeEmptyState()).not.toThrow()
152
+ expect(() => restoreTreeEmptyState(null)).not.toThrow()
153
+ })
154
+
155
+ // Paginated "Chats" feed: an empty container is not an empty result set while the
156
+ // load-more sentinel still has pages to fetch.
157
+ test('restoreTreeEmptyState stays out of the way while more pages are queued', () => {
158
+ const container = renderRenderedTree()
159
+ container.setAttribute(PAGINATION_PENDING_ATTRIBUTE, 'true')
160
+
161
+ restoreTreeEmptyState(container)
162
+
163
+ expect(placeholder(container)).toBeNull()
164
+ })
165
+
166
+ test('restoreTreeEmptyState leaves an existing placeholder hidden while more pages are queued', () => {
167
+ const container = renderEmptyTree()
168
+ hideTreeEmptyState(container)
169
+ container.setAttribute(PAGINATION_PENDING_ATTRIBUTE, 'true')
170
+
171
+ restoreTreeEmptyState(container)
172
+
173
+ expect(placeholder(container).hidden).toBe(true)
174
+ })
175
+
176
+ test('restoreTreeEmptyState restores once the pending-pages marker is cleared', () => {
177
+ const container = renderRenderedTree()
178
+ container.setAttribute(PAGINATION_PENDING_ATTRIBUTE, 'true')
179
+ restoreTreeEmptyState(container)
180
+ container.removeAttribute(PAGINATION_PENDING_ATTRIBUTE)
181
+
182
+ restoreTreeEmptyState(container)
183
+
184
+ expect(placeholder(container)).not.toBeNull()
185
+ expect(placeholder(container).hidden).toBe(false)
186
+ })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals';
5
+ import { createListenerRegistry } from '../dom_listener_registry';
6
+
7
+ describe('createListenerRegistry', () => {
8
+ test('releases every registered listener', () => {
9
+ const registry = createListenerRegistry();
10
+ const documentHandler = jest.fn();
11
+ const windowHandler = jest.fn();
12
+ registry.add(document, 'registry-document', documentHandler);
13
+ registry.add(window, 'registry-window', windowHandler);
14
+
15
+ registry.releaseAll();
16
+ document.dispatchEvent(new Event('registry-document'));
17
+ window.dispatchEvent(new Event('registry-window'));
18
+
19
+ expect(documentHandler).not.toHaveBeenCalled();
20
+ expect(windowHandler).not.toHaveBeenCalled();
21
+ expect(registry.size).toBe(0);
22
+ });
23
+
24
+ test('rebinds a Turbo session without retaining the old handler', () => {
25
+ const registry = createListenerRegistry();
26
+ const firstSession = jest.fn();
27
+ const secondSession = jest.fn();
28
+ registry.add(document.body, 'click', firstSession);
29
+
30
+ registry.releaseAll();
31
+ registry.add(document.body, 'click', secondSession);
32
+ document.body.click();
33
+
34
+ expect(firstSession).not.toHaveBeenCalled();
35
+ expect(secondSession).toHaveBeenCalledTimes(1);
36
+ registry.releaseAll();
37
+ });
38
+
39
+ test('preserves listener options when removing a binding', () => {
40
+ const registry = createListenerRegistry();
41
+ const handler = jest.fn();
42
+ registry.add(document, 'registry-capture', handler, { capture: true });
43
+
44
+ registry.releaseAll();
45
+ document.dispatchEvent(new Event('registry-capture'));
46
+
47
+ expect(handler).not.toHaveBeenCalled();
48
+ });
49
+
50
+ test('ignores targets that cannot register listeners', () => {
51
+ const registry = createListenerRegistry();
52
+
53
+ expect(() => registry.add(null, 'click', jest.fn())).not.toThrow();
54
+ expect(() => registry.add({}, 'click', jest.fn())).not.toThrow();
55
+ expect(registry.size).toBe(0);
56
+ });
57
+
58
+ test('releaseAll is idempotent', () => {
59
+ const registry = createListenerRegistry();
60
+ registry.add(document, 'registry-idempotent', jest.fn());
61
+
62
+ registry.releaseAll();
63
+
64
+ expect(() => registry.releaseAll()).not.toThrow();
65
+ expect(registry.size).toBe(0);
66
+ });
67
+ });
@@ -0,0 +1,159 @@
1
+ // Shared DOM scaffolding for the inline row editor tests.
2
+ //
3
+ // initializeCreativeRowEditor() walks a large, fixed set of element ids that
4
+ // the ERB renders (see _inline_edit_form.html.erb) and bails out on the first
5
+ // missing one, so every suite that drives the real module needs the same
6
+ // skeleton. It lives here rather than in one of the suites so the empty-state
7
+ // recovery and removal tests stay in sync with each other.
8
+ //
9
+ // Not matched by jest's testMatch (`**/__tests__/**/*.test.js`), so it is not
10
+ // collected as a suite of its own.
11
+
12
+ export const BUTTON_IDS = [
13
+ 'inline-metadata-btn', 'inline-toggle-markdown', 'inline-move-up', 'inline-move-down',
14
+ 'inline-add', 'inline-level-down', 'inline-level-up', 'inline-archive', 'inline-delete',
15
+ 'inline-delete-with-children', 'inline-link', 'inline-unconvert', 'inline-unlink',
16
+ 'inline-close', 'metadata-popup-close', 'metadata-save-btn',
17
+ ]
18
+
19
+ export const HIDDEN_INPUT_IDS = [
20
+ 'inline-method', 'inline-creative-description', 'inline-content-type',
21
+ 'inline-markdown-editor', 'inline-markdown-source', 'inline-parent-id',
22
+ 'inline-before-id', 'inline-after-id', 'inline-child-id', 'inline-origin-id',
23
+ ]
24
+
25
+ export const DIV_IDS = [
26
+ 'markdown-editor-wrapper', 'markdown-preview', 'inline-progress-value',
27
+ 'inline-save-status', 'metadata-popup',
28
+ ]
29
+
30
+ export function buildEditorDom(container, {
31
+ saveFailedMessage,
32
+ archiveFailedMessage,
33
+ restoreFailedMessage,
34
+ } = {}) {
35
+ const template = document.createElement('div')
36
+ template.id = 'inline-edit-form'
37
+ template.style.display = 'none'
38
+
39
+ const form = document.createElement('form')
40
+ form.id = 'inline-edit-form-element'
41
+ form.action = '/creatives'
42
+ // Mirrors the ERB: plain JS can't call `t()`, so the localized save-failure
43
+ // message rides on the form's data-* attribute.
44
+ if (saveFailedMessage) form.dataset.saveFailedMessage = saveFailedMessage
45
+ template.appendChild(form)
46
+
47
+ HIDDEN_INPUT_IDS.forEach((id) => {
48
+ const input = document.createElement('input')
49
+ input.type = 'hidden'
50
+ input.id = id
51
+ form.appendChild(input)
52
+ })
53
+ const progress = document.createElement('input')
54
+ progress.type = 'checkbox'
55
+ progress.id = 'inline-creative-progress'
56
+ form.appendChild(progress)
57
+
58
+ const editorRoot = document.createElement('div')
59
+ editorRoot.id = 'lexical-inline-editor'
60
+ editorRoot.dataset.lexicalEditorRoot = ''
61
+ form.appendChild(editorRoot)
62
+
63
+ const markdownTextarea = document.createElement('textarea')
64
+ markdownTextarea.id = 'markdown-editor-textarea'
65
+ form.appendChild(markdownTextarea)
66
+ const metadataTextarea = document.createElement('textarea')
67
+ metadataTextarea.id = 'metadata-yaml-editor'
68
+ form.appendChild(metadataTextarea)
69
+ BUTTON_IDS.forEach((id) => {
70
+ const button = document.createElement('button')
71
+ button.type = 'button'
72
+ button.id = id
73
+ if (id === 'inline-archive') {
74
+ // Same reason as saveFailedMessage above: the archive/restore failure
75
+ // strings are localized in the ERB and read back off the button.
76
+ if (archiveFailedMessage) button.dataset.failureMessage = archiveFailedMessage
77
+ if (restoreFailedMessage) button.dataset.restoreFailureMessage = restoreFailedMessage
78
+ }
79
+ form.appendChild(button)
80
+ })
81
+ DIV_IDS.forEach((id) => {
82
+ const div = document.createElement('div')
83
+ div.id = id
84
+ form.appendChild(div)
85
+ })
86
+ container.appendChild(template)
87
+ return template
88
+ }
89
+
90
+ // The real <creative-tree-row> is a Lit component; the module only needs it to
91
+ // expose a `.creative-tree` child, which is what the browser component renders.
92
+ // A brand-new row has no `.creative-row` yet — that markup only appears after a
93
+ // successful save inserts it — so the stub deliberately omits it.
94
+ export function defineTreeRowStub() {
95
+ if (customElements.get('creative-tree-row')) return
96
+ customElements.define('creative-tree-row', class extends HTMLElement {
97
+ connectedCallback() {
98
+ if (this.querySelector('.creative-tree')) return
99
+ const tree = document.createElement('div')
100
+ tree.className = 'creative-tree'
101
+ this.appendChild(tree)
102
+ }
103
+ })
104
+ }
105
+
106
+ // The markup `_empty_state.html.erb` renders as the sole child of #creatives,
107
+ // reduced to the parts the editor interacts with.
108
+ //
109
+ // The `data-creatives-empty-state` wrapper is what makes this a placeholder as far
110
+ // as `creative_tree_empty_state.js` is concerned — that module hides and restores
111
+ // the wrapper, not the card inside it — so the fixture has to carry it or the
112
+ // hide/restore calls silently find nothing. `index.html.erb` also emits a pristine
113
+ // copy in a `<template>` outside the container, which `restoreTreeEmptyState()`
114
+ // clones when the tree render has wiped the original away; mirror that here so the
115
+ // restore path is exercised as it is in the browser.
116
+ export function renderEmptyState() {
117
+ const creatives = document.getElementById('creatives')
118
+ const markup = `
119
+ <div data-creatives-empty-state>
120
+ <div class="creative-empty-state">
121
+ <button type="button" class="new-root-creative-btn">Add</button>
122
+ </div>
123
+ </div>
124
+ `
125
+ creatives.innerHTML = markup
126
+
127
+ document.getElementById('creatives-empty-state-template')?.remove()
128
+ const template = document.createElement('template')
129
+ template.id = 'creatives-empty-state-template'
130
+ template.innerHTML = markup
131
+ document.body.appendChild(template)
132
+
133
+ return creatives.querySelector('[data-creatives-empty-state]')
134
+ }
135
+
136
+ // A row that already exists on the server, i.e. one that renders a
137
+ // `.creative-row` and carries a creative id.
138
+ export function appendExistingRow(id, container = document.getElementById('creatives')) {
139
+ const rowComponent = document.createElement('creative-tree-row')
140
+ rowComponent.setAttribute('creative-id', String(id))
141
+ rowComponent.dataset.descriptionRawHtml = '<p>existing</p>'
142
+ rowComponent.dataset.progressValue = '0'
143
+ // The stub only renders its .creative-tree child once connected.
144
+ container.appendChild(rowComponent)
145
+ const tree = rowComponent.querySelector('.creative-tree')
146
+ tree.id = `creative-${id}`
147
+ tree.dataset.id = String(id)
148
+ const row = document.createElement('div')
149
+ row.className = 'creative-row'
150
+ tree.appendChild(row)
151
+ return { rowComponent, tree, row }
152
+ }
153
+
154
+ // Lets queued microtasks and the rAF-deferred finalizeSetup() in startNew() run.
155
+ export function flush() {
156
+ return new Promise((resolve) => {
157
+ requestAnimationFrame(() => setTimeout(resolve, 0))
158
+ })
159
+ }
@@ -348,9 +348,6 @@ export default class CommandArgsForm {
348
348
  if (command.name === 'topic') {
349
349
  return this._buildTopicText(command, values)
350
350
  }
351
- if (command.name === 'work') {
352
- return this._buildWorkText(command, values)
353
- }
354
351
  if (command.name === 'compress') {
355
352
  return this._buildCompressText(command, values)
356
353
  }
@@ -375,15 +372,6 @@ export default class CommandArgsForm {
375
372
  return parts.join(' ')
376
373
  }
377
374
 
378
- _buildWorkText(command, values) {
379
- const parts = [command.label]
380
- if (values.agent_name) {
381
- parts.push(values.agent_name)
382
- }
383
- if (values.context) parts.push(values.context)
384
- return parts.join(' ')
385
- }
386
-
387
375
  _buildCompressText(command, values) {
388
376
  const parts = [command.label]
389
377
  if (values.instructions) parts.push(values.instructions)
@@ -1,8 +1,10 @@
1
1
  import CommonPopup from '../lib/common_popup'
2
- import { getCaretClientRect } from '../utils/caret_position'
2
+ import { caretAnchor } from '../utils/caret_position'
3
3
  import CommandArgsForm from './command_args_form'
4
+ import { openCreativeLinkPicker } from './creative_link_picker'
4
5
 
5
6
  let commandMenuInitialized = false
7
+ let commandSubmissionSequence = 0
6
8
 
7
9
  if (!commandMenuInitialized) {
8
10
  commandMenuInitialized = true
@@ -15,6 +17,7 @@ if (!commandMenuInitialized) {
15
17
 
16
18
  const list = menu.querySelector('[data-popup-list]')
17
19
  const commandCache = new Map()
20
+ let skipNextMenuInput = false
18
21
 
19
22
  const argsForm = new CommandArgsForm({
20
23
  container: popup,
@@ -33,12 +36,53 @@ if (!commandMenuInitialized) {
33
36
  },
34
37
  onSubmit: (commandText) => {
35
38
  popupMenu.hide()
39
+ // clearCommandText() stripped only the "/" token, so anything still in
40
+ // the box is the draft the user had typed before opening the menu. The
41
+ // command is submitted on its own, so the draft cannot ride along with
42
+ // it — hand it to the form controller, which puts it back once the send
43
+ // settles, rather than dropping it on the floor here.
44
+ const draft = textarea.value
45
+ if (draft.trim()) {
46
+ textarea.dispatchEvent(new CustomEvent('comments--form:stash-draft', {
47
+ bubbles: true,
48
+ detail: { draft }
49
+ }))
50
+ }
36
51
  textarea.value = commandText
52
+ // Keep the normal input listeners (drafts, resize, submit state) in
53
+ // sync without treating this programmatic command as a new typeahead
54
+ // query. A schema command with no entered values is just "/name", so
55
+ // the command menu would otherwise reopen as soon as the form closes.
56
+ skipNextMenuInput = true
37
57
  textarea.dispatchEvent(new Event('input', { bubbles: true }))
38
58
  // Trigger form submission directly
39
59
  const submitBtn = document.querySelector('#new-comment-form [data-comments--form-target="submit"]')
40
60
  if (submitBtn) {
61
+ const submissionId = String(++commandSubmissionSequence)
62
+ let submissionStarted = false
63
+ const markSubmission = (event) => { event.commandSubmissionId = submissionId }
64
+ const onSubmissionStarted = (event) => {
65
+ if (event.detail?.submissionId === submissionId) submissionStarted = true
66
+ }
67
+ // Keep focus out of the textarea while its value is still the command
68
+ // being sent. The form announces settlement after success/failure
69
+ // cleanup, so typing cannot append to the in-flight command.
70
+ const onSubmissionSettled = (event) => {
71
+ if (event.detail?.submissionId !== submissionId) return
72
+ popup.removeEventListener('comments--form:submit-settled', onSubmissionSettled)
73
+ const active = document.activeElement
74
+ if (!active || active === document.body) textarea.focus()
75
+ }
76
+ submitBtn.addEventListener('click', markSubmission, { capture: true, once: true })
77
+ popup.addEventListener('comments--form:submit-started', onSubmissionStarted)
78
+ popup.addEventListener('comments--form:submit-settled', onSubmissionSettled)
41
79
  submitBtn.click()
80
+ submitBtn.removeEventListener('click', markSubmission, true)
81
+ popup.removeEventListener('comments--form:submit-started', onSubmissionStarted)
82
+ if (!submissionStarted) {
83
+ popup.removeEventListener('comments--form:submit-settled', onSubmissionSettled)
84
+ textarea.focus()
85
+ }
42
86
  }
43
87
  },
44
88
  onCancel: () => {
@@ -115,18 +159,25 @@ if (!commandMenuInitialized) {
115
159
  textarea.setSelectionRange(cleaned.length, cleaned.length)
116
160
  }
117
161
 
162
+ // Cached by creative id, and cached as the *promise* rather than its result:
163
+ // the list is fetched on every keystroke, so caching only after the response
164
+ // lands would fire one request per character of "/task" before the first one
165
+ // returns. In-flight callers share the request instead.
118
166
  function fetchCommands(creativeId) {
119
167
  if (!creativeId) return Promise.resolve([])
120
- if (commandCache.has(creativeId)) return Promise.resolve(commandCache.get(creativeId))
168
+ if (commandCache.has(creativeId)) return commandCache.get(creativeId)
121
169
 
122
- return fetch(`/creatives/${creativeId}/comments/commands`, { headers: { Accept: 'application/json' } })
170
+ const request = fetch(`/creatives/${creativeId}/comments/commands`, { headers: { Accept: 'application/json' } })
123
171
  .then((response) => (response.ok ? response.json() : []))
124
- .then((data) => {
125
- const list = Array.isArray(data) ? data : []
126
- commandCache.set(creativeId, list)
127
- return list
172
+ .then((data) => (Array.isArray(data) ? data : []))
173
+ .catch(() => {
174
+ // Don't leave a failed lookup cached — the next keystroke retries.
175
+ commandCache.delete(creativeId)
176
+ return []
128
177
  })
129
- .catch(() => [])
178
+
179
+ commandCache.set(creativeId, request)
180
+ return request
130
181
  }
131
182
 
132
183
  function insert(command) {
@@ -158,45 +209,33 @@ if (!commandMenuInitialized) {
158
209
  }
159
210
 
160
211
  popupMenu.setItems(filtered)
161
- const caretRect = getCaretClientRect(textarea) || textarea.getBoundingClientRect()
162
- popupMenu.showAt(caretRect)
212
+ popupMenu.showAt(caretAnchor(textarea))
163
213
  }
164
214
 
165
215
  function openCreativePicker(textarea) {
166
- const modal = document.getElementById('link-creative-modal')
167
- if (!modal) return
168
-
169
- const controller = window.Stimulus?.getControllerForElementAndIdentifier(modal, 'link-creative')
170
- if (!controller) return
171
-
172
- // Clear the command text (e.g. "/crea", "/creative") from textarea
173
- clearCommandText()
174
-
175
- const caretRect = getCaretClientRect(textarea) || textarea.getBoundingClientRect()
176
- controller.open(
177
- caretRect,
178
- (item) => {
179
- // Insert markdown link at cursor position
180
- const link = `[${item.label}](/creatives/${item.id})`
181
- const curPos = textarea.selectionStart
182
- const beforeCur = textarea.value.slice(0, curPos)
183
- const afterCur = textarea.value.slice(curPos)
184
- textarea.value = beforeCur + link + ' ' + afterCur
185
- const newPos = curPos + link.length + 1
186
- textarea.setSelectionRange(newPos, newPos)
187
- textarea.focus()
188
- },
189
- () => {
190
- textarea.focus()
191
- }
192
- )
216
+ openCreativeLinkPicker(textarea, {
217
+ triggerRange: { start: 0, end: textarea.selectionStart },
218
+ })
193
219
  }
194
220
 
195
221
  textarea.addEventListener('keydown', function (event) {
196
222
  if (popupMenu.handleKey(event)) return
197
223
  })
198
224
 
225
+ // Bumped by every input event, so a lookup that resolves after the user has
226
+ // typed on (or deleted the "/" entirely) is dropped instead of rendering the
227
+ // menu from a query that no longer matches the box — the late response would
228
+ // otherwise pop the menu back up over an unrelated draft.
229
+ let queryToken = 0
230
+
199
231
  textarea.addEventListener('input', function () {
232
+ const token = ++queryToken
233
+ if (skipNextMenuInput) {
234
+ skipNextMenuInput = false
235
+ popupMenu.hide()
236
+ return
237
+ }
238
+
200
239
  // If args form is open, don't show command menu
201
240
  if (argsForm.isOpen()) return
202
241
 
@@ -213,7 +252,10 @@ if (!commandMenuInitialized) {
213
252
  const query = match[1]
214
253
 
215
254
  fetchCommands(creativeId)
216
- .then((commands) => show(commands, query))
255
+ .then((commands) => {
256
+ if (token !== queryToken) return
257
+ show(commands, query)
258
+ })
217
259
  })
218
260
  })
219
261
  }
@@ -0,0 +1,56 @@
1
+ import { caretAnchor } from '../utils/caret_position'
2
+
3
+ const MARKDOWN_SPECIAL = /[\\`*_\[\]()<>~]/g
4
+
5
+ export function escapeMarkdownLabel(label) {
6
+ return String(label || '').replace(MARKDOWN_SPECIAL, (character) => `\\${character}`)
7
+ }
8
+
9
+ export function markdownCreativeCommandRange(value, caretPosition) {
10
+ const beforeCaret = String(value || '').slice(0, caretPosition)
11
+ const match = beforeCaret.match(/(?:^|\n)(\/creative)$/)
12
+ if (!match) return null
13
+
14
+ return {
15
+ start: caretPosition - match[1].length,
16
+ end: caretPosition,
17
+ }
18
+ }
19
+
20
+ export function insertMarkdownCreativeLink(textarea, item) {
21
+ if (!textarea || !item?.id) return false
22
+
23
+ const position = textarea.selectionStart
24
+ const link = `[${escapeMarkdownLabel(item.label)}](/creatives/${item.id})`
25
+ textarea.setRangeText(`${link} `, position, textarea.selectionEnd, 'end')
26
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
27
+ textarea.focus()
28
+ return true
29
+ }
30
+
31
+ export function openCreativeLinkPicker(textarea, {
32
+ triggerRange = null,
33
+ allowCreate = true,
34
+ } = {}) {
35
+ if (!textarea) return false
36
+
37
+ const modal = document.getElementById('link-creative-modal')
38
+ const controller = modal && window.Stimulus?.getControllerForElementAndIdentifier(
39
+ modal,
40
+ 'link-creative'
41
+ )
42
+ if (!controller) return false
43
+
44
+ if (triggerRange) {
45
+ textarea.setRangeText('', triggerRange.start, triggerRange.end, 'end')
46
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
47
+ }
48
+
49
+ controller.open(
50
+ caretAnchor(textarea),
51
+ (item) => insertMarkdownCreativeLink(textarea, item),
52
+ () => textarea.focus(),
53
+ { allowCreate }
54
+ )
55
+ return true
56
+ }