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,346 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+
6
+ // The real inline editor is a JSX module (untransformed in Jest), so it is
7
+ // stubbed. Everything else — the delete/archive button handlers, the empty
8
+ // state bookkeeping — is the real module.
9
+ const createInlineEditorMock = jest.fn()
10
+ const destroyMock = jest.fn()
11
+ const archiveMock = jest.fn()
12
+ const unarchiveMock = jest.fn()
13
+
14
+ jest.unstable_mockModule('../lexical_inline_editor', () => ({
15
+ createInlineEditor: createInlineEditorMock,
16
+ }))
17
+ jest.unstable_mockModule('../../lib/api/creatives', () => ({
18
+ default: {
19
+ save: jest.fn(() => Promise.resolve({ ok: true })),
20
+ get: jest.fn(() => Promise.resolve({})),
21
+ children: jest.fn(() => Promise.resolve([])),
22
+ destroy: destroyMock,
23
+ archive: archiveMock,
24
+ unarchive: unarchiveMock,
25
+ },
26
+ }))
27
+ jest.unstable_mockModule('../../lib/utils/dialog', () => ({
28
+ alertDialog: jest.fn(() => Promise.resolve()),
29
+ confirmDialog: jest.fn(() => Promise.resolve(true)),
30
+ promptDialog: jest.fn(() => Promise.resolve(null)),
31
+ }))
32
+
33
+ const { initializeCreativeRowEditor } = await import('../creative_row_editor')
34
+ const {
35
+ buildEditorDom, defineTreeRowStub, renderEmptyState, appendExistingRow, flush,
36
+ } = await import('./support/inline_editor_dom')
37
+
38
+ // A workspace that loaded with rows has no placeholder left inside #creatives —
39
+ // the tree render wiped it. All that survives is the pristine copy in the
40
+ // server-rendered <template>, which restoreTreeEmptyState() clones. Set that up
41
+ // and then clear the container, so only the template is available.
42
+ //
43
+ // The label is deliberately not English: markup assembled inside the module
44
+ // rather than cloned from the server's template would fail this assertion.
45
+ function installTemplateOnly() {
46
+ renderEmptyState()
47
+ const template = document.getElementById('creatives-empty-state-template')
48
+ template.innerHTML =
49
+ '<div data-creatives-empty-state><div class="creative-empty-state">' +
50
+ '<button type="button" class="new-root-creative-btn">추가</button></div></div>'
51
+ document.getElementById('creatives').innerHTML = ''
52
+ }
53
+
54
+ // Stands in for the connected creatives--tree controller so the root-level refetch
55
+ // can be observed. Resolved through window.Stimulus on each call, as the archive
56
+ // handler does.
57
+ function stubTreeController() {
58
+ const calls = { load: 0, requestReload: 0 }
59
+ const controller = {
60
+ load() { calls.load += 1 },
61
+ requestReload() { calls.requestReload += 1 },
62
+ }
63
+ window.Stimulus = {
64
+ getControllerForElementAndIdentifier: (element, identifier) => (
65
+ element === document.getElementById('creatives') && identifier === 'creatives--tree' ? controller : null
66
+ ),
67
+ }
68
+ return calls
69
+ }
70
+
71
+ // The children container for `parentId`, plus the `has-children` flag the server
72
+ // puts on the row itself. TreeBuilder emits both together: the flag records that
73
+ // children exist, the container is where they render.
74
+ function childrenContainerFor(parentId) {
75
+ const rowComponent = document.getElementById(`creative-${parentId}`).closest('creative-tree-row')
76
+ rowComponent.hasChildren = true
77
+ rowComponent.setAttribute('has-children', '')
78
+
79
+ let children = document.getElementById(`creative-children-${parentId}`)
80
+ if (!children) {
81
+ children = document.createElement('div')
82
+ children.className = 'creative-children'
83
+ children.id = `creative-children-${parentId}`
84
+ children.dataset.expanded = 'true'
85
+ children.dataset.loaded = 'true'
86
+ document.getElementById(`creative-${parentId}`).appendChild(children)
87
+ }
88
+ return children
89
+ }
90
+
91
+ // A child row nested under `parentId`, in the children container the delete path
92
+ // inspects.
93
+ function appendChildRow(parentId, childId) {
94
+ return appendExistingRow(childId, childrenContainerFor(parentId))
95
+ }
96
+
97
+ // What TreeBuilder#children_container_payload emits for a row whose children have
98
+ // never been expanded: `has-children` on the row, but a container with
99
+ // `loaded: false` and no rows inside it yet.
100
+ function appendCollapsedChildren(parentId) {
101
+ const children = childrenContainerFor(parentId)
102
+ children.dataset.expanded = 'false'
103
+ children.dataset.loaded = 'false'
104
+ children.dataset.loadUrl = `/creatives/${parentId}/children`
105
+ children.style.display = 'none'
106
+ return children
107
+ }
108
+
109
+ async function openEditorOn(tree) {
110
+ document.dispatchEvent(new CustomEvent('creative-edit-click', { detail: { treeElement: tree } }))
111
+ await flush()
112
+ }
113
+
114
+ describe('empty state after the last creative is removed', () => {
115
+ beforeAll(() => {
116
+ defineTreeRowStub()
117
+ createInlineEditorMock.mockImplementation(() => ({
118
+ destroy: jest.fn(),
119
+ load: jest.fn(),
120
+ focus: jest.fn(),
121
+ reset: jest.fn(),
122
+ getDeletedAttachments: jest.fn(() => []),
123
+ }))
124
+ })
125
+
126
+ beforeEach(() => {
127
+ document.body.innerHTML = '<div id="creatives"></div><div id="center-frame"></div>'
128
+ delete window.Stimulus
129
+ destroyMock.mockReset()
130
+ destroyMock.mockImplementation(() => Promise.resolve({ ok: true }))
131
+ archiveMock.mockReset()
132
+ archiveMock.mockImplementation(() => Promise.resolve({ ok: true }))
133
+ unarchiveMock.mockReset()
134
+ unarchiveMock.mockImplementation(() => Promise.resolve({ ok: true }))
135
+ buildEditorDom(document.getElementById('center-frame'))
136
+ initializeCreativeRowEditor()
137
+ document.getElementById('metadata-popup').style.display = 'none'
138
+ })
139
+
140
+ test('restores the hidden card when the first creative is deleted again', async () => {
141
+ // The card is still in #creatives, hidden by the inline-create flow that
142
+ // produced this creative in the first place.
143
+ const emptyState = renderEmptyState()
144
+ emptyState.style.display = 'none'
145
+ const { tree } = appendExistingRow(42)
146
+
147
+ await openEditorOn(tree)
148
+ document.getElementById('inline-delete').click()
149
+ await flush()
150
+
151
+ expect(destroyMock).toHaveBeenCalledWith('42', false)
152
+ expect(document.querySelectorAll('#creatives creative-tree-row')).toHaveLength(0)
153
+ expect(emptyState.style.display).toBe('')
154
+ })
155
+
156
+ test('restores the hidden card when the last creative is archived', async () => {
157
+ const emptyState = renderEmptyState()
158
+ emptyState.style.display = 'none'
159
+ const { tree } = appendExistingRow(42)
160
+
161
+ await openEditorOn(tree)
162
+ document.getElementById('inline-archive').click()
163
+ await flush()
164
+
165
+ expect(archiveMock).toHaveBeenCalledWith('42')
166
+ expect(document.querySelectorAll('#creatives creative-tree-row')).toHaveLength(0)
167
+ expect(emptyState.style.display).toBe('')
168
+ })
169
+
170
+ test('clones the server-rendered template when no card is left in the DOM', async () => {
171
+ installTemplateOnly()
172
+ const { tree } = appendExistingRow(42)
173
+
174
+ await openEditorOn(tree)
175
+ document.getElementById('inline-delete').click()
176
+ await flush()
177
+
178
+ const restored = document.querySelector('#creatives .creative-empty-state')
179
+ expect(restored).not.toBeNull()
180
+ expect(restored.querySelector('.new-root-creative-btn').textContent).toBe('추가')
181
+ })
182
+
183
+ test('leaves the tree alone while other rows remain', async () => {
184
+ installTemplateOnly()
185
+ const { tree } = appendExistingRow(42)
186
+ appendExistingRow(43)
187
+
188
+ await openEditorOn(tree)
189
+ document.getElementById('inline-delete').click()
190
+ await flush()
191
+
192
+ expect(document.querySelectorAll('#creatives creative-tree-row')).toHaveLength(1)
193
+ expect(document.querySelector('#creatives .creative-empty-state')).toBeNull()
194
+ })
195
+
196
+ test('does not re-render the empty state when the archive request fails', async () => {
197
+ installTemplateOnly()
198
+ archiveMock.mockImplementation(() => Promise.resolve({ ok: false, status: 500 }))
199
+ const { tree } = appendExistingRow(42)
200
+
201
+ await openEditorOn(tree)
202
+ document.getElementById('inline-archive').click()
203
+ await flush()
204
+
205
+ // The row is still there, so there is nothing to replace it with.
206
+ expect(document.querySelectorAll('#creatives creative-tree-row')).toHaveLength(1)
207
+ expect(document.querySelector('#creatives .creative-empty-state')).toBeNull()
208
+ })
209
+
210
+ test('closes the editor when the deleted row was the last one', async () => {
211
+ // move(1) has no row to move to here, so it used to leave currentTree on the
212
+ // row being detached with the template still displayed. Hold the element:
213
+ // removeTreeElement() takes the row away with the template inside it, so
214
+ // getElementById would stop finding it either way.
215
+ renderEmptyState()
216
+ const { tree } = appendExistingRow(42)
217
+
218
+ await openEditorOn(tree)
219
+ const editor = document.getElementById('inline-edit-form')
220
+ expect(editor.style.display).toBe('block')
221
+
222
+ document.getElementById('inline-delete').click()
223
+ await flush()
224
+
225
+ expect(document.querySelectorAll('#creatives creative-tree-row')).toHaveLength(0)
226
+ expect(editor.style.display).toBe('none')
227
+ })
228
+
229
+ test('the restored Add button creates a row on the first click after a delete', async () => {
230
+ // The symptom of leaving the editor open on the deleted row: the delegated
231
+ // handler reads a displayed template as "close the open editor", so the first
232
+ // click on the restored CTA did nothing but flush a save for a creative that
233
+ // no longer exists.
234
+ renderEmptyState()
235
+ const { tree } = appendExistingRow(42)
236
+
237
+ await openEditorOn(tree)
238
+ document.getElementById('inline-delete').click()
239
+ await flush()
240
+
241
+ document.querySelector('#creatives .new-root-creative-btn').click()
242
+ await flush()
243
+
244
+ expect(document.querySelectorAll('#creatives creative-tree-row')).toHaveLength(1)
245
+ })
246
+
247
+ test('refetches the root tree when "delete only this" promotes children to the root', async () => {
248
+ // DestroyService#reparent_children moves the children up to the deleted
249
+ // creative's parent — the root here, since this row is top level. The only
250
+ // copy of them in the DOM is inside the children container the delete path
251
+ // drops, so without a refetch the tree would show the "no creatives yet" card
252
+ // while the server still holds the promoted rows.
253
+ renderEmptyState()
254
+ const calls = stubTreeController()
255
+ const { tree } = appendExistingRow(42)
256
+ appendChildRow(42, 43)
257
+
258
+ await openEditorOn(tree)
259
+ document.getElementById('inline-delete').click()
260
+ await flush()
261
+
262
+ expect(destroyMock).toHaveBeenCalledWith('42', false)
263
+ // requestReload(), not load(): move(1) has just put the editor on the next
264
+ // row, so an unconditional re-render would detach it mid-edit.
265
+ expect(calls.requestReload).toBe(1)
266
+ expect(calls.load).toBe(0)
267
+ })
268
+
269
+ test('refetches the root tree when the promoted children were never expanded', async () => {
270
+ // A collapsed row still gets a children container, but TreeBuilder marks it
271
+ // `loaded: false` and leaves it empty — the children only arrive when the user
272
+ // expands it. The relationship is recorded on the row's `has-children` flag
273
+ // instead, so that is what the delete path has to consult. Looking for a
274
+ // rendered `creative-tree-row` sees nothing here and would skip the refetch,
275
+ // leaving the actionable empty card over children the server just promoted.
276
+ renderEmptyState()
277
+ const calls = stubTreeController()
278
+ const { tree } = appendExistingRow(42)
279
+ appendCollapsedChildren(42)
280
+
281
+ await openEditorOn(tree)
282
+ document.getElementById('inline-delete').click()
283
+ await flush()
284
+
285
+ expect(destroyMock).toHaveBeenCalledWith('42', false)
286
+ expect(calls.requestReload).toBe(1)
287
+ expect(calls.load).toBe(0)
288
+ })
289
+
290
+ test('does not refetch when the deleted row has no children to promote', async () => {
291
+ renderEmptyState()
292
+ const calls = stubTreeController()
293
+ const { tree } = appendExistingRow(42)
294
+
295
+ await openEditorOn(tree)
296
+ document.getElementById('inline-delete').click()
297
+ await flush()
298
+
299
+ expect(calls.requestReload).toBe(0)
300
+ })
301
+
302
+ test('does not refetch when "delete with children" removes them too', async () => {
303
+ // Nothing is promoted in this case: the children are gone from the server.
304
+ renderEmptyState()
305
+ const calls = stubTreeController()
306
+ const { tree } = appendExistingRow(42)
307
+ appendChildRow(42, 43)
308
+
309
+ await openEditorOn(tree)
310
+ document.getElementById('inline-delete-with-children').click()
311
+ await flush()
312
+
313
+ expect(destroyMock).toHaveBeenCalledWith('42', true)
314
+ expect(calls.requestReload).toBe(0)
315
+ })
316
+
317
+ test('does not refetch when "delete with children" removes collapsed children', async () => {
318
+ // Same as above, but reached through the `has-children` flag rather than
319
+ // rendered rows: the flag must not pull the refetch into a branch that has
320
+ // nothing to preserve.
321
+ renderEmptyState()
322
+ const calls = stubTreeController()
323
+ const { tree } = appendExistingRow(42)
324
+ appendCollapsedChildren(42)
325
+
326
+ await openEditorOn(tree)
327
+ document.getElementById('inline-delete-with-children').click()
328
+ await flush()
329
+
330
+ expect(destroyMock).toHaveBeenCalledWith('42', true)
331
+ expect(calls.requestReload).toBe(0)
332
+ })
333
+
334
+ test('keeps the editor open on the next row when one remains', async () => {
335
+ renderEmptyState()
336
+ const { tree } = appendExistingRow(42)
337
+ appendExistingRow(43)
338
+
339
+ await openEditorOn(tree)
340
+ document.getElementById('inline-delete').click()
341
+ await flush()
342
+
343
+ // move(1) had somewhere to go, so the editor follows it rather than closing.
344
+ expect(document.getElementById('inline-edit-form').style.display).toBe('block')
345
+ })
346
+ })
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+
6
+ // The real inline editor is a JSX module (untransformed in Jest) and the
7
+ // delegated click handler is exercised by its own test file — both are
8
+ // mocked here so this file can observe the session lifecycle itself.
9
+ const createInlineEditorMock = jest.fn()
10
+ const createDelegatedClickHandlerMock = jest.fn()
11
+
12
+ jest.unstable_mockModule('../lexical_inline_editor', () => ({
13
+ createInlineEditor: createInlineEditorMock,
14
+ }))
15
+ jest.unstable_mockModule('../creative_row_editor_delegated_clicks', () => ({
16
+ createDelegatedClickHandler: createDelegatedClickHandlerMock,
17
+ }))
18
+
19
+ const { initializeCreativeRowEditor } = await import('../creative_row_editor')
20
+ const { buildEditorDom } = await import('./support/inline_editor_dom')
21
+
22
+ function swapCenterContent() {
23
+ const container = document.getElementById('center-frame')
24
+ container.innerHTML = ''
25
+ return buildEditorDom(container)
26
+ }
27
+
28
+ describe('creative row editor session lifecycle', () => {
29
+ beforeAll(() => {
30
+ document.body.innerHTML = '<div id="creatives"></div><div id="center-frame"></div>'
31
+ createInlineEditorMock.mockImplementation(() => ({
32
+ destroy: jest.fn(),
33
+ load: jest.fn(),
34
+ focus: jest.fn(),
35
+ getDeletedAttachments: jest.fn(() => []),
36
+ }))
37
+ createDelegatedClickHandlerMock.mockImplementation(() => jest.fn())
38
+ })
39
+
40
+ test('builds a session for the mounted template on first initialization', () => {
41
+ const template = swapCenterContent()
42
+
43
+ initializeCreativeRowEditor()
44
+
45
+ expect(createDelegatedClickHandlerMock).toHaveBeenCalledTimes(1)
46
+ expect(createDelegatedClickHandlerMock.mock.calls[0][0].template).toBe(template)
47
+ })
48
+
49
+ test('stays idempotent while the same template remains mounted', () => {
50
+ const calls = createDelegatedClickHandlerMock.mock.calls.length
51
+
52
+ initializeCreativeRowEditor()
53
+ document.dispatchEvent(new Event('turbo:load'))
54
+
55
+ expect(createDelegatedClickHandlerMock).toHaveBeenCalledTimes(calls)
56
+ })
57
+
58
+ test('rebuilds the session against the new template after a center frame swap', () => {
59
+ const previousEditor = createInlineEditorMock.mock.results.at(-1).value
60
+ const newTemplate = swapCenterContent()
61
+
62
+ // Non-promoted frame navigations fire no turbo:load; the Stimulus wrapper
63
+ // reconnecting inside the swapped frame content is the only signal.
64
+ initializeCreativeRowEditor()
65
+
66
+ const session = createDelegatedClickHandlerMock.mock.calls.at(-1)[0]
67
+ expect(session.template).toBe(newTemplate)
68
+ expect(previousEditor.destroy).toHaveBeenCalledTimes(1)
69
+ })
70
+
71
+ test('rebuilds the session from turbo:load after a full page navigation', () => {
72
+ const calls = createDelegatedClickHandlerMock.mock.calls.length
73
+ const newTemplate = swapCenterContent()
74
+
75
+ document.dispatchEvent(new Event('turbo:load'))
76
+
77
+ expect(createDelegatedClickHandlerMock).toHaveBeenCalledTimes(calls + 1)
78
+ expect(createDelegatedClickHandlerMock.mock.calls.at(-1)[0].template).toBe(newTemplate)
79
+ })
80
+
81
+ test('tears down the session when no template is mounted', () => {
82
+ const previousEditor = createInlineEditorMock.mock.results.at(-1).value
83
+ document.getElementById('center-frame').innerHTML = ''
84
+
85
+ document.dispatchEvent(new Event('turbo:load'))
86
+
87
+ expect(previousEditor.destroy).toHaveBeenCalledTimes(1)
88
+ // A later mount recovers with a fresh session.
89
+ const template = swapCenterContent()
90
+ initializeCreativeRowEditor()
91
+ expect(createDelegatedClickHandlerMock.mock.calls.at(-1)[0].template).toBe(template)
92
+ })
93
+
94
+ test('stops the orphaned editing ping when a frame swap discards a session with the editor open', () => {
95
+ jest.useFakeTimers()
96
+ const stopSpy = jest.fn()
97
+ const startSpy = jest.fn()
98
+ document.addEventListener('creative-editing:stop', stopSpy)
99
+ document.addEventListener('creative-editing:start', startSpy)
100
+
101
+ try {
102
+ // Fresh session against a mounted template, same as a real workspace frame load.
103
+ swapCenterContent()
104
+ initializeCreativeRowEditor()
105
+ // The cached-data fast path in loadCreative() bypasses the network only when
106
+ // the popup is hidden; otherwise applyCreativeData() would fetch metadata.
107
+ document.getElementById('metadata-popup').style.display = 'none'
108
+
109
+ // A tree row with inline dataset (id/description/progress) so opening it
110
+ // takes the synchronous cached-data path instead of an API call.
111
+ const treeRow = document.createElement('creative-tree-row')
112
+ treeRow.dataset.descriptionRawHtml = 'hello'
113
+ treeRow.dataset.progressValue = '0'
114
+ const tree = document.createElement('div')
115
+ tree.className = 'creative-tree'
116
+ tree.id = 'creative-42'
117
+ tree.dataset.id = '42'
118
+ treeRow.appendChild(tree)
119
+ document.getElementById('center-frame').appendChild(treeRow)
120
+
121
+ document.dispatchEvent(new CustomEvent('creative-edit-click', { detail: { treeElement: tree } }))
122
+
123
+ expect(startSpy).toHaveBeenCalledTimes(1)
124
+ expect(startSpy.mock.calls[0][0].detail.creativeId).toBe(42)
125
+
126
+ // Workspace frame swap: center content is replaced wholesale and the
127
+ // Stimulus wrapper reconnects, without the outgoing session ever calling
128
+ // hideCurrent()/move() on itself.
129
+ swapCenterContent()
130
+ initializeCreativeRowEditor()
131
+
132
+ expect(stopSpy).toHaveBeenCalledTimes(1)
133
+ expect(stopSpy.mock.calls[0][0].detail.creativeId).toBe(42)
134
+
135
+ startSpy.mockClear()
136
+ jest.advanceTimersByTime(10000)
137
+ expect(startSpy).not.toHaveBeenCalled()
138
+ } finally {
139
+ document.removeEventListener('creative-editing:stop', stopSpy)
140
+ document.removeEventListener('creative-editing:start', startSpy)
141
+ jest.useRealTimers()
142
+ }
143
+ })
144
+ })
@@ -422,4 +422,34 @@ describe('removeTreeElement', () => {
422
422
  test('no-ops on nullish tree', () => {
423
423
  expect(() => removeTreeElement(null)).not.toThrow();
424
424
  });
425
+
426
+ function addHiddenEmptyState() {
427
+ root.insertAdjacentHTML(
428
+ 'beforeend',
429
+ '<div data-creatives-empty-state="" hidden><p>No sub-creatives found.</p></div>'
430
+ );
431
+ return root.querySelector('[data-creatives-empty-state]');
432
+ }
433
+
434
+ test('restores the empty-state placeholder once the last row is gone', () => {
435
+ const placeholder = addHiddenEmptyState();
436
+ const { row, tree } = makeRow(1);
437
+ root.appendChild(row);
438
+
439
+ removeTreeElement(tree);
440
+
441
+ expect(placeholder.hidden).toBe(false);
442
+ });
443
+
444
+ test('leaves the placeholder hidden while other rows remain', () => {
445
+ const placeholder = addHiddenEmptyState();
446
+ const { row: first, tree } = makeRow(1);
447
+ const { row: second } = makeRow(2);
448
+ root.appendChild(first);
449
+ root.appendChild(second);
450
+
451
+ removeTreeElement(tree);
452
+
453
+ expect(placeholder.hidden).toBe(true);
454
+ });
425
455
  });