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
@@ -18,6 +18,7 @@ jest.unstable_mockModule('../../../utils/emoji_parser', () => ({
18
18
  const { Application } = await import('@hotwired/stimulus')
19
19
  const TreeController = (await import('../tree_controller')).default
20
20
  const { appendCreativeNodes } = await import('../../../creatives/tree_renderer')
21
+ const { restoreTreeEmptyState } = await import('../../../modules/creative_tree_empty_state')
21
22
 
22
23
  const TRANSIENT_RETRY_DELAYS = [200, 600]
23
24
 
@@ -32,6 +33,7 @@ const installController = () => {
32
33
  const container = document.createElement('div')
33
34
  container.setAttribute('data-controller', 'creatives--tree')
34
35
  container.setAttribute('data-creatives--tree-url-value', '/creatives?format=json&id=991')
36
+ container.setAttribute('data-creatives--tree-loading-text-value', 'Loading creatives')
35
37
  document.body.appendChild(container)
36
38
 
37
39
  const application = Application.start()
@@ -267,4 +269,607 @@ describe('CreativesTreeController Chats pagination (load more)', () => {
267
269
 
268
270
  application.stop()
269
271
  })
272
+
273
+ // Deleting every row that is currently on screen does not mean the feed is
274
+ // empty when further pages are still queued behind the sentinel. Restoring the
275
+ // placeholder there both lies and outlives the append, leaving "No creatives
276
+ // found." sitting above the rows the next page brings in.
277
+ describe('empty-state placeholder vs. queued pages', () => {
278
+ const installEmptyStateTemplate = () => {
279
+ const template = document.createElement('template')
280
+ template.id = 'creatives-empty-state-template'
281
+ template.innerHTML = '<p data-creatives-empty-state>No creatives found.</p>'
282
+ document.body.appendChild(template)
283
+ return template
284
+ }
285
+
286
+ test('suppresses the placeholder while the feed has more pages queued', async () => {
287
+ installEmptyStateTemplate()
288
+ global.fetch = jest.fn().mockResolvedValue({
289
+ ok: true,
290
+ json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
291
+ })
292
+
293
+ const { container, application } = installController()
294
+ await flush()
295
+ await flush()
296
+
297
+ expect(container.hasAttribute('data-creatives-pagination-pending')).toBe(true)
298
+
299
+ // The user batch-deletes every rendered row; removeTreeElement asks for the
300
+ // placeholder back.
301
+ restoreTreeEmptyState(container)
302
+
303
+ expect(container.querySelector('[data-creatives-empty-state]')).toBeNull()
304
+
305
+ application.stop()
306
+ })
307
+
308
+ test('restores the placeholder once the last page lands and nothing is left', async () => {
309
+ installEmptyStateTemplate()
310
+ global.fetch = jest
311
+ .fn()
312
+ .mockResolvedValueOnce({
313
+ ok: true,
314
+ json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
315
+ })
316
+ .mockResolvedValueOnce({
317
+ ok: true,
318
+ json: async () => ({ creatives: [], pagination: { has_more: false, next_page: null } }),
319
+ })
320
+
321
+ const { container, application } = installController()
322
+ await flush()
323
+ await flush()
324
+
325
+ restoreTreeEmptyState(container)
326
+ expect(container.querySelector('[data-creatives-empty-state]')).toBeNull()
327
+
328
+ MockIntersectionObserver.instances[0].triggerIntersect()
329
+ await flush()
330
+ await flush()
331
+
332
+ expect(container.hasAttribute('data-creatives-pagination-pending')).toBe(false)
333
+ const placeholder = container.querySelector('[data-creatives-empty-state]')
334
+ expect(placeholder).not.toBeNull()
335
+ expect(placeholder.hidden).toBe(false)
336
+
337
+ application.stop()
338
+ })
339
+
340
+ test('hides a placeholder that is on screen when the next page is appended', async () => {
341
+ installEmptyStateTemplate()
342
+ global.fetch = jest
343
+ .fn()
344
+ .mockResolvedValueOnce({
345
+ ok: true,
346
+ json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
347
+ })
348
+ .mockResolvedValueOnce({
349
+ ok: true,
350
+ json: async () => ({ creatives: [{ id: 2 }], pagination: { has_more: true, next_page: 3 } }),
351
+ })
352
+
353
+ const { container, application } = installController()
354
+ await flush()
355
+ await flush()
356
+
357
+ // Belt and braces: a placeholder that got in anyway (a path that predates
358
+ // the pending marker) must not survive the append.
359
+ const stray = document.createElement('p')
360
+ stray.setAttribute('data-creatives-empty-state', '')
361
+ container.appendChild(stray)
362
+
363
+ MockIntersectionObserver.instances[0].triggerIntersect()
364
+ await flush()
365
+ await flush()
366
+
367
+ expect(appendCreativeNodes).toHaveBeenCalledTimes(1)
368
+ expect(stray.hidden).toBe(true)
369
+
370
+ application.stop()
371
+ })
372
+
373
+ test('clears the pending marker when a fresh load tears pagination down', async () => {
374
+ global.fetch = jest.fn().mockResolvedValue({
375
+ ok: true,
376
+ json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
377
+ })
378
+
379
+ const { container, application } = installController()
380
+ await flush()
381
+ await flush()
382
+ expect(container.hasAttribute('data-creatives-pagination-pending')).toBe(true)
383
+
384
+ global.fetch = jest.fn().mockResolvedValue({
385
+ ok: true,
386
+ json: async () => ({ creatives: [{ id: 9 }] }),
387
+ })
388
+ application.getControllerForElementAndIdentifier(container, 'creatives--tree').load()
389
+ await flush()
390
+ await flush()
391
+
392
+ expect(container.hasAttribute('data-creatives-pagination-pending')).toBe(false)
393
+
394
+ application.stop()
395
+ })
396
+ })
397
+ })
398
+
399
+ describe('CreativesTreeController error state vs genuine-empty state', () => {
400
+ let originalFetch
401
+
402
+ const installControllerWithStates = () => {
403
+ // Mirrors index.html.erb: the actionable empty state lives in the server-rendered
404
+ // <template> that showEmptyState() clones, while the load-error fallback is a bare
405
+ // translated sentence the controller wraps in a <p> itself.
406
+ const template = document.createElement('template')
407
+ template.id = 'creatives-empty-state-template'
408
+ template.innerHTML =
409
+ '<div data-creatives-empty-state><div class="creative-empty-state">' +
410
+ '<button class="new-root-creative-btn">Add</button></div></div>'
411
+ document.body.appendChild(template)
412
+
413
+ const container = document.createElement('div')
414
+ container.setAttribute('data-controller', 'creatives--tree')
415
+ container.setAttribute('data-creatives--tree-url-value', '/creatives?format=json&id=991')
416
+ container.setAttribute('data-creatives--tree-loading-text-value', 'Loading creatives')
417
+ container.setAttribute(
418
+ 'data-creatives--tree-error-text-value',
419
+ 'Could not load the creative tree.'
420
+ )
421
+ document.body.appendChild(container)
422
+
423
+ const application = Application.start()
424
+ application.register('creatives--tree', TreeController)
425
+
426
+ return { container, application }
427
+ }
428
+
429
+ beforeEach(() => {
430
+ originalFetch = global.fetch
431
+ })
432
+
433
+ afterEach(() => {
434
+ global.fetch = originalFetch
435
+ document.body.innerHTML = ''
436
+ jest.restoreAllMocks()
437
+ })
438
+
439
+ test('a non-2xx response renders the distinct error state, not the actionable empty-state CTAs', async () => {
440
+ jest.spyOn(console, 'error').mockImplementation(() => {})
441
+ global.fetch = jest.fn().mockResolvedValue({
442
+ ok: false,
443
+ status: 500,
444
+ json: async () => ({}),
445
+ })
446
+
447
+ const { container, application } = installControllerWithStates()
448
+ await flush()
449
+ await flush()
450
+ await flush()
451
+
452
+ expect(container.querySelector('.creative-tree-error')).not.toBeNull()
453
+ expect(container.querySelector('.creative-empty-state')).toBeNull()
454
+ expect(container.querySelector('.new-root-creative-btn')).toBeNull()
455
+
456
+ application.stop()
457
+ })
458
+
459
+ test('a JSON parse failure renders the distinct error state, not the actionable empty-state CTAs', async () => {
460
+ jest.spyOn(console, 'error').mockImplementation(() => {})
461
+ global.fetch = jest.fn().mockResolvedValue({
462
+ ok: true,
463
+ json: async () => {
464
+ throw new SyntaxError('Unexpected token in JSON')
465
+ },
466
+ })
467
+
468
+ const { container, application } = installControllerWithStates()
469
+ await flush()
470
+ await flush()
471
+ await flush()
472
+
473
+ expect(container.querySelector('.creative-tree-error')).not.toBeNull()
474
+ expect(container.querySelector('.creative-empty-state')).toBeNull()
475
+ expect(container.querySelector('.new-root-creative-btn')).toBeNull()
476
+
477
+ application.stop()
478
+ })
479
+
480
+ test('the error fallback renders its value as text, never as markup', async () => {
481
+ jest.spyOn(console, 'error').mockImplementation(() => {})
482
+ global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({}) })
483
+
484
+ const { container, application } = installControllerWithStates()
485
+ container.setAttribute(
486
+ 'data-creatives--tree-error-text-value',
487
+ '<img src=x onerror="window.__xss = true">'
488
+ )
489
+ await flush()
490
+ await flush()
491
+ await flush()
492
+
493
+ const message = container.querySelector('.creative-tree-error')
494
+ expect(message).not.toBeNull()
495
+ // Parsed as markup this would be an <img>; as text it is just the literal string.
496
+ expect(message.querySelector('img')).toBeNull()
497
+ expect(message.textContent).toBe('<img src=x onerror="window.__xss = true">')
498
+
499
+ application.stop()
500
+ })
501
+
502
+ test('a genuinely empty tree (successful response, zero creatives) still shows the actionable empty state', async () => {
503
+ global.fetch = jest.fn().mockResolvedValue({
504
+ ok: true,
505
+ json: async () => ({ creatives: [] }),
506
+ })
507
+
508
+ const { container, application } = installControllerWithStates()
509
+ await flush()
510
+ await flush()
511
+
512
+ expect(container.querySelector('.new-root-creative-btn')).not.toBeNull()
513
+ expect(container.querySelector('.creative-tree-error')).toBeNull()
514
+
515
+ application.stop()
516
+ })
517
+ })
518
+
519
+ // The empty state used to be server-rendered inside #creatives, so the browser
520
+ // painted "No sub-creatives yet" the moment the HTML landed — before Stimulus had
521
+ // booted, let alone before the tree fetch had confirmed anything. index.html.erb
522
+ // now renders the loading placeholder there instead, and the controller adopts
523
+ // that node rather than replacing it.
524
+ describe('CreativesTreeController server-rendered loading placeholder', () => {
525
+ let originalFetch
526
+
527
+ const SERVER_PLACEHOLDER_HTML =
528
+ '<div class="creative-tree-loading-placeholder" data-creatives-tree-loading role="status"' +
529
+ ' aria-live="polite" aria-label="크리에이티브를 불러오는 중">' +
530
+ '<span class="creative-loading-indicator" aria-hidden="true">' +
531
+ '<span class="creative-loading-dot">.</span>' +
532
+ '<span class="creative-loading-dot">.</span>' +
533
+ '<span class="creative-loading-dot">.</span>' +
534
+ '</span></div>'
535
+
536
+ const installWithServerPlaceholder = ({ placeholder = true } = {}) => {
537
+ const template = document.createElement('template')
538
+ template.id = 'creatives-empty-state-template'
539
+ template.innerHTML =
540
+ '<div data-creatives-empty-state><div class="creative-empty-state">' +
541
+ '<button class="new-root-creative-btn">Add</button></div></div>'
542
+ document.body.appendChild(template)
543
+
544
+ const container = document.createElement('div')
545
+ container.setAttribute('data-controller', 'creatives--tree')
546
+ container.setAttribute('data-creatives--tree-url-value', '/creatives?format=json&id=991')
547
+ container.setAttribute('data-creatives--tree-loading-text-value', '크리에이티브를 불러오는 중')
548
+ if (placeholder) container.innerHTML = SERVER_PLACEHOLDER_HTML
549
+ document.body.appendChild(container)
550
+
551
+ const serverNode = container.querySelector('[data-creatives-tree-loading]')
552
+
553
+ const application = Application.start()
554
+ application.register('creatives--tree', TreeController)
555
+
556
+ return { container, application, serverNode }
557
+ }
558
+
559
+ beforeEach(() => {
560
+ originalFetch = global.fetch
561
+ })
562
+
563
+ afterEach(() => {
564
+ global.fetch = originalFetch
565
+ document.body.innerHTML = ''
566
+ jest.restoreAllMocks()
567
+ })
568
+
569
+ test('adopts the server-rendered placeholder instead of rebuilding it', async () => {
570
+ let resolveFetch
571
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
572
+
573
+ const { container, application, serverNode } = installWithServerPlaceholder()
574
+ await flush()
575
+
576
+ const live = container.querySelector('[data-creatives-tree-loading]')
577
+ // Same node, still attached: rebuilding would blank the container for a frame,
578
+ // which is precisely the flicker this arrangement removes.
579
+ expect(live).toBe(serverNode)
580
+ expect(container.children).toHaveLength(1)
581
+
582
+ resolveFetch({ ok: true, json: async () => ({ creatives: [] }) })
583
+ application.stop()
584
+ })
585
+
586
+ test('does not paint the empty state before the fetch resolves', async () => {
587
+ let resolveFetch
588
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
589
+
590
+ const { container, application } = installWithServerPlaceholder()
591
+ await flush()
592
+
593
+ expect(container.querySelector('[data-creatives-empty-state]')).toBeNull()
594
+ expect(container.querySelector('.new-root-creative-btn')).toBeNull()
595
+ expect(container.querySelector('[data-creatives-tree-loading]')).not.toBeNull()
596
+
597
+ resolveFetch({ ok: true, json: async () => ({ creatives: [] }) })
598
+ application.stop()
599
+ })
600
+
601
+ test('swaps the placeholder for the empty state only once zero rows are confirmed', async () => {
602
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ creatives: [] }) })
603
+
604
+ const { container, application } = installWithServerPlaceholder()
605
+ await flush()
606
+ await flush()
607
+
608
+ expect(container.querySelector('[data-creatives-tree-loading]')).toBeNull()
609
+ expect(container.querySelector('.new-root-creative-btn')).not.toBeNull()
610
+
611
+ application.stop()
612
+ })
613
+
614
+ test('re-attaches the adopted placeholder on a later reload', async () => {
615
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ creatives: [] }) })
616
+
617
+ const { container, application, serverNode } = installWithServerPlaceholder()
618
+ await flush()
619
+ await flush()
620
+ expect(container.querySelector('[data-creatives-tree-loading]')).toBeNull()
621
+
622
+ let resolveSecond
623
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSecond = resolve }))
624
+ const controller = application.getControllerForElementAndIdentifier(container, 'creatives--tree')
625
+ controller.load()
626
+
627
+ expect(container.querySelector('[data-creatives-tree-loading]')).toBe(serverNode)
628
+ expect(container.querySelector('.new-root-creative-btn')).toBeNull()
629
+
630
+ resolveSecond({ ok: true, json: async () => ({ creatives: [] }) })
631
+ application.stop()
632
+ })
633
+
634
+ test('builds its own placeholder when the container has none, labelled from i18n', async () => {
635
+ let resolveFetch
636
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
637
+
638
+ const { container, application } = installWithServerPlaceholder({ placeholder: false })
639
+ await flush()
640
+
641
+ const built = container.querySelector('[data-creatives-tree-loading]')
642
+ expect(built).not.toBeNull()
643
+ expect(built.getAttribute('aria-label')).toBe('크리에이티브를 불러오는 중')
644
+ expect(built.querySelectorAll('.creative-loading-dot')).toHaveLength(3)
645
+
646
+ resolveFetch({ ok: true, json: async () => ({ creatives: [] }) })
647
+ application.stop()
648
+ })
649
+
650
+ test('requires a translated label when no server placeholder is available', async () => {
651
+ let resolveFetch
652
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
653
+
654
+ const { container, application } = installWithServerPlaceholder({ placeholder: false })
655
+ await flush()
656
+ container.removeAttribute('data-creatives--tree-loading-text-value')
657
+ container.replaceChildren()
658
+ const controller = application.getControllerForElementAndIdentifier(container, 'creatives--tree')
659
+ controller.loadingIndicator = null
660
+ expect(() => controller.showLoadingIndicator()).toThrow(
661
+ 'creatives--tree requires a translated loadingText value when no server loading placeholder is present',
662
+ )
663
+ expect(container.querySelector('[data-creatives-tree-loading]')).toBeNull()
664
+
665
+ resolveFetch({ ok: true, json: async () => ({ creatives: [] }) })
666
+ application.stop()
667
+ })
668
+
669
+ test('never adopts the load-more indicator as the tree placeholder', async () => {
670
+ let resolveFetch
671
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
672
+
673
+ const { container, application } = installWithServerPlaceholder({ placeholder: false })
674
+ await flush()
675
+ // The paginated "Chats" feed appends its own .creative-tree-loading-placeholder;
676
+ // it carries no data-creatives-tree-loading, so the selector must miss it.
677
+ const loadMore = document.createElement('div')
678
+ loadMore.className = 'creative-tree-loading-placeholder creative-chats-load-more'
679
+ container.replaceChildren(loadMore)
680
+
681
+ const controller = application.getControllerForElementAndIdentifier(container, 'creatives--tree')
682
+ controller.loadingIndicator = null
683
+ controller.showLoadingIndicator()
684
+
685
+ expect(controller.loadingIndicator).not.toBe(loadMore)
686
+ expect(container.contains(loadMore)).toBe(false)
687
+
688
+ resolveFetch({ ok: true, json: async () => ({ creatives: [] }) })
689
+ application.stop()
690
+ })
691
+ })
692
+
693
+ // A reload replaces the whole container, so it takes the row an open editor is
694
+ // attached to out of the document — along with the unsaved draft inside it.
695
+ // Callers that reload in response to a request they fired earlier (archive /
696
+ // unarchive, delete-with-promoted-children) can land at any moment, including
697
+ // while the user has moved on and is editing a different row, so they go through
698
+ // requestReload() rather than load().
699
+ describe('CreativesTreeController requestReload', () => {
700
+ let originalFetch
701
+
702
+ // Stimulus connects asynchronously, so the controller instance only exists after
703
+ // a turn of the real event loop — hence fake timers are switched on afterwards,
704
+ // once connect()'s own initial load() is out of the way and can be stubbed out.
705
+ const installConnected = async () => {
706
+ const { container, application } = installController()
707
+ await flush()
708
+ const controller = application.getControllerForElementAndIdentifier(container, 'creatives--tree')
709
+ const load = jest.spyOn(controller, 'load').mockImplementation(() => {})
710
+ jest.useFakeTimers()
711
+ return { container, application, controller, load }
712
+ }
713
+
714
+ beforeEach(() => {
715
+ originalFetch = global.fetch
716
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ creatives: [] }) })
717
+ })
718
+
719
+ afterEach(() => {
720
+ jest.useRealTimers()
721
+ global.fetch = originalFetch
722
+ document.body.innerHTML = ''
723
+ jest.restoreAllMocks()
724
+ })
725
+
726
+ test('reloads when no editor is open', async () => {
727
+ const { application, controller, load } = await installConnected()
728
+
729
+ controller.requestReload()
730
+ jest.advanceTimersByTime(300)
731
+
732
+ expect(load).toHaveBeenCalledTimes(1)
733
+
734
+ application.stop()
735
+ })
736
+
737
+ test('defers the reload while a row is being edited', async () => {
738
+ const { application, controller, load } = await installConnected()
739
+
740
+ document.dispatchEvent(new CustomEvent('creative-editing:start'))
741
+ controller.requestReload()
742
+ jest.advanceTimersByTime(5000)
743
+
744
+ expect(load).not.toHaveBeenCalled()
745
+
746
+ application.stop()
747
+ })
748
+
749
+ test('runs the deferred reload once editing stops', async () => {
750
+ const { application, controller, load } = await installConnected()
751
+
752
+ document.dispatchEvent(new CustomEvent('creative-editing:start'))
753
+ controller.requestReload()
754
+ jest.advanceTimersByTime(5000)
755
+ expect(load).not.toHaveBeenCalled()
756
+
757
+ document.dispatchEvent(new CustomEvent('creative-editing:stop'))
758
+ jest.advanceTimersByTime(300)
759
+
760
+ expect(load).toHaveBeenCalledTimes(1)
761
+
762
+ application.stop()
763
+ })
764
+
765
+ test('does not reload on editing:stop when nothing was requested', async () => {
766
+ const { application, controller, load } = await installConnected()
767
+
768
+ document.dispatchEvent(new CustomEvent('creative-editing:start'))
769
+ document.dispatchEvent(new CustomEvent('creative-editing:stop'))
770
+ jest.advanceTimersByTime(5000)
771
+
772
+ expect(load).not.toHaveBeenCalled()
773
+ expect(controller).toBeDefined()
774
+
775
+ application.stop()
776
+ })
777
+
778
+ // Switching rows is a stop immediately followed by a start, so a refetch drained
779
+ // on the stop is still sitting in the 300ms debounce when the next row opens.
780
+ // Checking _editing only at requestReload() time would let that timer fire and
781
+ // re-render the tree out from under the newly opened editor, taking the row it
782
+ // is attached to — and the draft inside it — out of the document.
783
+ test('keeps the deferred reload pending across a row switch', async () => {
784
+ const { application, controller, load } = await installConnected()
785
+
786
+ document.dispatchEvent(new CustomEvent('creative-editing:start'))
787
+ controller.requestReload()
788
+
789
+ // The switch: the outgoing row stops, the incoming row starts, both well
790
+ // inside the debounce window.
791
+ document.dispatchEvent(new CustomEvent('creative-editing:stop'))
792
+ document.dispatchEvent(new CustomEvent('creative-editing:start'))
793
+ jest.advanceTimersByTime(5000)
794
+
795
+ expect(load).not.toHaveBeenCalled()
796
+
797
+ application.stop()
798
+ })
799
+
800
+ test('runs the reload once the row opened by the switch is closed', async () => {
801
+ const { application, controller, load } = await installConnected()
802
+
803
+ document.dispatchEvent(new CustomEvent('creative-editing:start'))
804
+ controller.requestReload()
805
+ document.dispatchEvent(new CustomEvent('creative-editing:stop'))
806
+ document.dispatchEvent(new CustomEvent('creative-editing:start'))
807
+ jest.advanceTimersByTime(5000)
808
+ expect(load).not.toHaveBeenCalled()
809
+
810
+ document.dispatchEvent(new CustomEvent('creative-editing:stop'))
811
+ jest.advanceTimersByTime(300)
812
+
813
+ // Still exactly once: re-pending must not double-book the refetch.
814
+ expect(load).toHaveBeenCalledTimes(1)
815
+
816
+ application.stop()
817
+ })
818
+
819
+ // The re-check must not swallow the request: an editor that opens after the
820
+ // timer has already fired is a separate matter, but one that opens during the
821
+ // window has to leave the refetch queued rather than cancelled.
822
+ test('re-arms the pending flag when the timer fires mid-edit', async () => {
823
+ const { application, controller, load } = await installConnected()
824
+
825
+ controller.requestReload()
826
+ document.dispatchEvent(new CustomEvent('creative-editing:start'))
827
+ jest.advanceTimersByTime(300)
828
+ expect(load).not.toHaveBeenCalled()
829
+
830
+ document.dispatchEvent(new CustomEvent('creative-editing:stop'))
831
+ jest.advanceTimersByTime(300)
832
+
833
+ expect(load).toHaveBeenCalledTimes(1)
834
+
835
+ application.stop()
836
+ })
837
+
838
+ test('keeps a pending reload held after editing stops until the operation finishes', async () => {
839
+ const { application, controller, load } = await installConnected()
840
+
841
+ document.dispatchEvent(new CustomEvent('creative-editing:start'))
842
+ controller.beginReloadHold()
843
+ controller.requestReload()
844
+ document.dispatchEvent(new CustomEvent('creative-editing:stop'))
845
+ jest.advanceTimersByTime(5000)
846
+
847
+ expect(load).not.toHaveBeenCalled()
848
+
849
+ controller.endReloadHold()
850
+ jest.advanceTimersByTime(300)
851
+
852
+ expect(load).toHaveBeenCalledTimes(1)
853
+
854
+ application.stop()
855
+ })
856
+
857
+ test('coalesces reloads across overlapping operation holds', async () => {
858
+ const { application, controller, load } = await installConnected()
859
+
860
+ controller.beginReloadHold()
861
+ controller.beginReloadHold()
862
+ controller.requestReload()
863
+ jest.advanceTimersByTime(5000)
864
+
865
+ controller.endReloadHold()
866
+ jest.advanceTimersByTime(5000)
867
+ expect(load).not.toHaveBeenCalled()
868
+
869
+ controller.endReloadHold()
870
+ jest.advanceTimersByTime(300)
871
+ expect(load).toHaveBeenCalledTimes(1)
872
+
873
+ application.stop()
874
+ })
270
875
  })