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,62 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import {
6
+ $createParagraphNode,
7
+ $createTextNode,
8
+ $getRoot,
9
+ createEditor,
10
+ } from 'lexical'
11
+ import { $createLinkNode, LinkNode } from '@lexical/link'
12
+ import {
13
+ $createCreativeLinkNode,
14
+ $isCreativeLinkNode,
15
+ CreativeLinkNode,
16
+ } from '../creative_link_node'
17
+ import { $createToolbarLinkNode, $findLinkNode } from '../link_toolbar'
18
+
19
+ function createTestEditor() {
20
+ return createEditor({
21
+ namespace: 'link-toolbar-test',
22
+ nodes: [LinkNode, CreativeLinkNode],
23
+ onError(error) {
24
+ throw error
25
+ },
26
+ })
27
+ }
28
+
29
+ describe('link toolbar helpers', () => {
30
+ test('finds regular and Creative links from their text children', () => {
31
+ const editor = createTestEditor()
32
+
33
+ editor.update(() => {
34
+ const paragraph = $createParagraphNode()
35
+ const regularText = $createTextNode('External')
36
+ const creativeText = $createTextNode('Creative')
37
+ const regularLink = $createLinkNode('https://example.com').append(regularText)
38
+ const creativeLink = $createCreativeLinkNode('/creatives/42', 42).append(creativeText)
39
+ paragraph.append(regularLink, creativeLink)
40
+ $getRoot().append(paragraph)
41
+
42
+ expect($findLinkNode([regularText])).toBe(regularLink)
43
+ expect($findLinkNode([creativeText])).toBe(creativeLink)
44
+ expect($findLinkNode([creativeLink])).toBe(creativeLink)
45
+ expect($findLinkNode([paragraph])).toBeNull()
46
+ }, { discrete: true })
47
+ })
48
+
49
+ test('preserves Creative identity for canonical URLs and converts external URLs', () => {
50
+ const editor = createTestEditor()
51
+
52
+ editor.update(() => {
53
+ const creativeLink = $createToolbarLinkNode('/creatives/73')
54
+ const regularLink = $createToolbarLinkNode('https://example.com')
55
+
56
+ expect($isCreativeLinkNode(creativeLink)).toBe(true)
57
+ expect(creativeLink.getCreativeId()).toBe(73)
58
+ expect($isCreativeLinkNode(regularLink)).toBe(false)
59
+ expect(regularLink.getType()).toBe('link')
60
+ }, { discrete: true })
61
+ })
62
+ })
@@ -0,0 +1,101 @@
1
+ import { LinkNode } from "@lexical/link"
2
+ import { $applyNodeReplacement } from "lexical"
3
+
4
+ const CREATIVE_PATH = /^\/creatives\/(\d+)(?:[/?#]|$)/
5
+
6
+ export function creativeIdFromUrl(url) {
7
+ const match = String(url || "").match(CREATIVE_PATH)
8
+ return match ? Number(match[1]) : null
9
+ }
10
+
11
+ export class CreativeLinkNode extends LinkNode {
12
+ __creativeId
13
+
14
+ static getType() {
15
+ return "creative-link"
16
+ }
17
+
18
+ static clone(node) {
19
+ return new CreativeLinkNode(
20
+ node.__url,
21
+ node.__creativeId,
22
+ { rel: node.__rel, target: node.__target, title: node.__title },
23
+ node.__key
24
+ )
25
+ }
26
+
27
+ constructor(url = "", creativeId = null, attributes = {}, key) {
28
+ super(url, attributes, key)
29
+ this.__creativeId = Number(creativeId || creativeIdFromUrl(url)) || null
30
+ }
31
+
32
+ createDOM(config) {
33
+ const element = super.createDOM(config)
34
+ if (this.__creativeId) element.dataset.creativeId = String(this.__creativeId)
35
+ return element
36
+ }
37
+
38
+ updateDOM(prevNode, element, config) {
39
+ super.updateDOM(prevNode, element, config)
40
+ const creativeId = this.getCreativeId()
41
+ if (creativeId) {
42
+ element.dataset.creativeId = String(creativeId)
43
+ } else {
44
+ delete element.dataset.creativeId
45
+ }
46
+ return false
47
+ }
48
+
49
+ static importDOM() {
50
+ return {
51
+ a: (element) => {
52
+ const creativeId = Number(element.dataset.creativeId) || creativeIdFromUrl(element.getAttribute("href"))
53
+ if (!creativeId) return null
54
+
55
+ return {
56
+ conversion: () => ({
57
+ node: $createCreativeLinkNode(
58
+ element.getAttribute("href") || `/creatives/${creativeId}`,
59
+ creativeId,
60
+ {
61
+ rel: element.getAttribute("rel"),
62
+ target: element.getAttribute("target"),
63
+ title: element.getAttribute("title")
64
+ }
65
+ )
66
+ }),
67
+ priority: 2
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ static importJSON(serializedNode) {
74
+ return $createCreativeLinkNode(
75
+ serializedNode.url,
76
+ serializedNode.creativeId,
77
+ serializedNode
78
+ ).updateFromJSON(serializedNode)
79
+ }
80
+
81
+ exportJSON() {
82
+ return {
83
+ ...super.exportJSON(),
84
+ creativeId: this.getCreativeId(),
85
+ type: "creative-link",
86
+ version: 1
87
+ }
88
+ }
89
+
90
+ getCreativeId() {
91
+ return this.getLatest().__creativeId
92
+ }
93
+ }
94
+
95
+ export function $createCreativeLinkNode(url, creativeId, attributes) {
96
+ return $applyNodeReplacement(new CreativeLinkNode(url, creativeId, attributes))
97
+ }
98
+
99
+ export function $isCreativeLinkNode(node) {
100
+ return node instanceof CreativeLinkNode
101
+ }
@@ -0,0 +1,98 @@
1
+ import {
2
+ $createRangeSelection,
3
+ $createTextNode,
4
+ $getNodeByKey,
5
+ $getSelection,
6
+ $isRangeSelection,
7
+ $isTextNode,
8
+ $setSelection,
9
+ } from "lexical"
10
+ import { $createCreativeLinkNode } from "./creative_link_node"
11
+
12
+ function currentTrigger() {
13
+ const selection = $getSelection()
14
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null
15
+
16
+ const node = selection.anchor.getNode()
17
+ if (!$isTextNode(node)) return null
18
+ if (["code", "link", "creative-link"].includes(node.getParent()?.getType())) return null
19
+
20
+ const offset = selection.anchor.offset
21
+ if (node.getTextContent().slice(0, offset).slice(-2) !== "[[") return null
22
+
23
+ return { key: node.getKey(), start: offset - 2, end: offset }
24
+ }
25
+
26
+ function selectionRect(editor) {
27
+ const domSelection = window.getSelection?.()
28
+ if (domSelection?.rangeCount) {
29
+ const rect = domSelection.getRangeAt(0).getBoundingClientRect?.()
30
+ if (rect) return rect
31
+ }
32
+ return editor.getRootElement()?.getBoundingClientRect?.() || null
33
+ }
34
+
35
+ export function insertCreativeLinkAtTrigger(editor, trigger, item) {
36
+ if (!trigger || !item?.id) return false
37
+ let inserted = false
38
+
39
+ editor.update(() => {
40
+ const node = $getNodeByKey(trigger.key)
41
+ if (!$isTextNode(node)) return
42
+ if (node.getTextContent().slice(trigger.start, trigger.end) !== "[[") return
43
+
44
+ const selection = $createRangeSelection()
45
+ selection.anchor.set(trigger.key, trigger.start, "text")
46
+ selection.focus.set(trigger.key, trigger.end, "text")
47
+ $setSelection(selection)
48
+
49
+ const link = $createCreativeLinkNode(`/creatives/${item.id}`, item.id)
50
+ link.append($createTextNode(item.label || String(item.id)))
51
+ const trailingSpace = $createTextNode(" ")
52
+ selection.insertNodes([link, trailingSpace])
53
+ trailingSpace.selectEnd()
54
+ inserted = true
55
+ }, { discrete: true })
56
+
57
+ if (inserted) editor.focus()
58
+ return inserted
59
+ }
60
+
61
+ export function registerCreativeLinkTrigger(editor, openPicker) {
62
+ let pickerOpen = false
63
+ let dismissedTrigger = null
64
+
65
+ return editor.registerUpdateListener(({ editorState }) => {
66
+ if (pickerOpen) return
67
+
68
+ let trigger = null
69
+ editorState.read(() => {
70
+ trigger = currentTrigger()
71
+ })
72
+ if (!trigger) {
73
+ dismissedTrigger = null
74
+ return
75
+ }
76
+ if (
77
+ dismissedTrigger &&
78
+ dismissedTrigger.key === trigger.key &&
79
+ dismissedTrigger.start === trigger.start &&
80
+ dismissedTrigger.end === trigger.end
81
+ ) return
82
+
83
+ pickerOpen = true
84
+ const opened = openPicker({
85
+ anchorRect: selectionRect(editor),
86
+ onSelect: (item) => {
87
+ insertCreativeLinkAtTrigger(editor, trigger, item)
88
+ pickerOpen = false
89
+ },
90
+ onClose: () => {
91
+ dismissedTrigger = trigger
92
+ pickerOpen = false
93
+ editor.focus()
94
+ }
95
+ })
96
+ if (opened === false) pickerOpen = false
97
+ })
98
+ }
@@ -0,0 +1,23 @@
1
+ import { $createLinkNode, $isLinkNode } from '@lexical/link'
2
+ import {
3
+ $createCreativeLinkNode,
4
+ creativeIdFromUrl,
5
+ } from './creative_link_node'
6
+
7
+ export function $findLinkNode(nodes) {
8
+ for (const node of nodes) {
9
+ if ($isLinkNode(node)) return node
10
+
11
+ const parent = node.getParent()
12
+ if ($isLinkNode(parent)) return parent
13
+ }
14
+
15
+ return null
16
+ }
17
+
18
+ export function $createToolbarLinkNode(url) {
19
+ const creativeId = creativeIdFromUrl(url)
20
+ return creativeId
21
+ ? $createCreativeLinkNode(url, creativeId)
22
+ : $createLinkNode(url)
23
+ }
@@ -1,5 +1,6 @@
1
1
  import { Turbo } from "@hotwired/turbo-rails"
2
- import { createRow, applyRowProperties } from "../creatives/tree_renderer"
2
+ import { createRow, applyRowProperties, replaceProgressControl, updateProgressHtml } from "../creatives/tree_renderer"
3
+ import { hideTreeEmptyState, restoreTreeEmptyState } from "../modules/creative_tree_empty_state"
3
4
 
4
5
  // Register custom actions on both the imported Turbo and the global window.Turbo
5
6
  function registerStreamAction(name, handler) {
@@ -48,6 +49,7 @@ registerStreamAction("refresh_creative_tree", function () {
48
49
 
49
50
  // Update ancestor progress for all actions
50
51
  updateAncestorProgress(creative.ancestors)
52
+ document.dispatchEvent(new CustomEvent('workspace-tree:invalidate'))
51
53
  })
52
54
 
53
55
  function handleCreated(creative) {
@@ -83,6 +85,8 @@ function handleCreated(creative) {
83
85
 
84
86
  const newRow = createRow(adjustedCreative)
85
87
  insertAtCorrectPosition(newRow, adjustedCreative, targetContainer)
88
+ // The tree is no longer empty — drop the "no sub-creatives" placeholder.
89
+ hideTreeEmptyState(treeContainer)
86
90
  }
87
91
  // If no targetContainer found, the creative is relevant but we can't determine
88
92
  // the exact insertion point — it will appear on next page load.
@@ -289,6 +293,14 @@ function handleUpdated(creative) {
289
293
  }
290
294
 
291
295
  function handleDestroyed(creative) {
296
+ const creativeIds = [creative.id, creative.origin_id]
297
+ .filter((id) => id != null)
298
+ .map(String)
299
+
300
+ document.dispatchEvent(new CustomEvent('creative-destroyed', {
301
+ detail: { creativeIds: [...new Set(creativeIds)] }
302
+ }))
303
+
292
304
  const rows = findRowsForCreative(creative.id, creative.origin_id)
293
305
 
294
306
  if (rows.length === 0) return // Row not visible on this page
@@ -315,32 +327,27 @@ function handleDestroyed(creative) {
315
327
  }
316
328
  }
317
329
 
330
+ // Last row gone — bring the placeholder back.
331
+ restoreTreeEmptyState()
318
332
  }
319
333
 
320
- function updateProgressForRow(row, progress, progressText) {
334
+ export function updateProgressForRow(row, progress, progressText, progressHtml = null) {
321
335
  if (progress == null) return
322
336
  const pct = Math.round(progress * 100)
323
- const cssClass = pct >= 100 ? 'creative-progress-complete' : 'creative-progress-incomplete'
324
337
  // progressText from server: completion mark string, empty string (=complete but no mark), or null
325
338
  const displayText = progressText != null ? (progressText || '\u00a0\u00a0') : `${pct}%`
326
339
 
327
340
  row.dataset.progressValue = String(progress)
328
341
 
329
342
  if (row.progressHtml) {
330
- // Try regex replacement on existing progress HTML (preserves chat buttons etc.)
331
- const regex = /(<span[^>]*class="creative-progress-(?:in)?complete"[^>]*>)[^<]*(<\/span>)/
332
- const updated = row.progressHtml.replace(regex, `$1${displayText}$2`)
343
+ const updated = progressHtml && !row.selectMode
344
+ ? replaceProgressControl(row.progressHtml, progressHtml)
345
+ : updateProgressHtml(row.progressHtml, progress, displayText)
346
+
333
347
  if (updated !== row.progressHtml) {
334
- // Also update ONLY the first progress class (not chat buttons etc.)
335
- const classUpdated = updated.replace(
336
- /class="creative-progress-(?:in)?complete"/,
337
- `class="${cssClass}"`
338
- )
339
- row.progressHtml = classUpdated
340
- row.dataset.progressHtml = classUpdated
348
+ row.progressHtml = updated
349
+ row.dataset.progressHtml = updated
341
350
  }
342
- // If regex didn't match, do NOT create fresh HTML — preserve existing progressHtml
343
- // (it contains chat buttons, comment badges, etc.)
344
351
  }
345
352
  }
346
353
 
@@ -349,7 +356,7 @@ function updateAncestorProgress(ancestors) {
349
356
  ancestors.forEach(anc => {
350
357
  // findRowsForCreative already handles origin_id fallback for linked creatives
351
358
  const rows = findRowsForCreative(anc.id, anc.origin_id)
352
- if (rows[0]) updateProgressForRow(rows[0], anc.progress, anc.progress_text)
359
+ if (rows[0]) updateProgressForRow(rows[0], anc.progress, anc.progress_text, anc.progress_html)
353
360
  })
354
361
  }
355
362
 
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+ import {
6
+ WORKSPACE_FRAME_ID,
7
+ applyFilterParam,
8
+ buildFilterUrl,
9
+ syncFilterButtons,
10
+ visitFilterUrl
11
+ } from '../filter_navigation'
12
+
13
+ // jsdom's window.location is unforgeable, so drive it through history instead
14
+ // of replacing the object.
15
+ function setLocation(href) {
16
+ window.history.replaceState({}, '', href)
17
+ }
18
+
19
+ describe('buildFilterUrl', () => {
20
+ test('keeps the current URL and its params when already on the index', () => {
21
+ setLocation('http://localhost/creatives?id=7&search=hi')
22
+
23
+ const url = buildFilterUrl({ indexPath: '/creatives', onIndex: true }, (params) => {
24
+ params.set('comment', 'true')
25
+ })
26
+
27
+ expect(url.pathname).toBe('/creatives')
28
+ expect(url.searchParams.get('id')).toBe('7')
29
+ expect(url.searchParams.get('search')).toBe('hi')
30
+ expect(url.searchParams.get('comment')).toBe('true')
31
+ })
32
+
33
+ test('preserves the root route pathname when the index is served at "/"', () => {
34
+ setLocation('http://localhost/?tags=a')
35
+
36
+ const url = buildFilterUrl({ indexPath: '/creatives', onIndex: true }, (params) => {
37
+ params.set('comment', 'true')
38
+ })
39
+
40
+ expect(url.pathname).toBe('/')
41
+ expect(url.searchParams.get('tags')).toBe('a')
42
+ })
43
+
44
+ test('redirects to the index and drops foreign params when elsewhere', () => {
45
+ setLocation('http://localhost/settings?tab=profile')
46
+
47
+ const url = buildFilterUrl({ indexPath: '/creatives', onIndex: false }, (params) => {
48
+ params.set('comment', 'true')
49
+ })
50
+
51
+ expect(url.pathname).toBe('/creatives')
52
+ expect(url.searchParams.get('tab')).toBeNull()
53
+ expect(url.searchParams.get('comment')).toBe('true')
54
+ })
55
+
56
+ test('falls back to the root path when no index path was provided', () => {
57
+ setLocation('http://localhost/settings')
58
+
59
+ const url = buildFilterUrl({ indexPath: '', onIndex: false }, () => {})
60
+
61
+ expect(url.pathname).toBe('/')
62
+ })
63
+ })
64
+
65
+ describe('applyFilterParam', () => {
66
+ test('turns the comment filter on when absent', () => {
67
+ const params = new URLSearchParams()
68
+ applyFilterParam(params, 'comment')
69
+ expect(params.get('comment')).toBe('true')
70
+ })
71
+
72
+ test('turns the comment filter off when already on', () => {
73
+ const params = new URLSearchParams('comment=true')
74
+ applyFilterParam(params, 'comment')
75
+ expect(params.has('comment')).toBe(false)
76
+ })
77
+
78
+ test('sets the complete progress range', () => {
79
+ const params = new URLSearchParams()
80
+ applyFilterParam(params, 'complete')
81
+ expect(params.get('min_progress')).toBe('1')
82
+ expect(params.get('max_progress')).toBe('1')
83
+ })
84
+
85
+ test('sets the incomplete progress range', () => {
86
+ const params = new URLSearchParams()
87
+ applyFilterParam(params, 'incomplete')
88
+ expect(params.get('min_progress')).toBe('0')
89
+ expect(params.get('max_progress')).toBe('0.99')
90
+ })
91
+
92
+ test('clears the progress range for "all" without touching other filters', () => {
93
+ const params = new URLSearchParams('min_progress=1&max_progress=1&comment=true')
94
+ applyFilterParam(params, 'all')
95
+ expect(params.has('min_progress')).toBe(false)
96
+ expect(params.has('max_progress')).toBe(false)
97
+ expect(params.get('comment')).toBe('true')
98
+ })
99
+ })
100
+
101
+ describe('visitFilterUrl', () => {
102
+ let visit
103
+
104
+ beforeEach(() => {
105
+ document.body.innerHTML = ''
106
+ setLocation('http://localhost/creatives')
107
+ visit = jest.fn()
108
+ window.Turbo = { visit }
109
+ })
110
+
111
+ afterEach(() => {
112
+ delete window.Turbo
113
+ document.body.innerHTML = ''
114
+ })
115
+
116
+ test('replaces only the workspace frame when it is present', () => {
117
+ document.body.innerHTML = `<turbo-frame id="${WORKSPACE_FRAME_ID}"></turbo-frame>`
118
+
119
+ const result = visitFilterUrl(new URL('http://localhost/creatives?comment=true'))
120
+
121
+ expect(visit).toHaveBeenCalledWith('/creatives?comment=true', {
122
+ action: 'advance',
123
+ frame: WORKSPACE_FRAME_ID
124
+ })
125
+ expect(result).toBe(true)
126
+ })
127
+
128
+ test('performs a full Turbo visit when there is no workspace frame', () => {
129
+ const result = visitFilterUrl(new URL('http://localhost/creatives?comment=true'))
130
+
131
+ expect(visit).toHaveBeenCalledWith('/creatives?comment=true', { action: 'advance' })
132
+ expect(result).toBe(false)
133
+ })
134
+
135
+ test('omits the query string entirely when no filters remain', () => {
136
+ visitFilterUrl(new URL('http://localhost/creatives'))
137
+
138
+ expect(visit).toHaveBeenCalledWith('/creatives', { action: 'advance' })
139
+ })
140
+ })
141
+
142
+ describe('syncFilterButtons', () => {
143
+ beforeEach(() => {
144
+ document.body.innerHTML = `
145
+ <button data-filter-state="any-filter" class="active"></button>
146
+ <button data-filter-state="progress:all"></button>
147
+ <button data-filter-state="progress:incomplete" class="active"></button>
148
+ <button data-filter-state="progress:complete"></button>
149
+ <button data-filter-state="comment"></button>
150
+ <button data-filter-state="archived" data-label-on="Hide archived" data-label-off="Show archived">Show archived</button>
151
+ `
152
+ })
153
+
154
+ afterEach(() => {
155
+ document.body.innerHTML = ''
156
+ })
157
+
158
+ function activeStates() {
159
+ return Array.from(document.querySelectorAll('[data-filter-state].active')).map(
160
+ (el) => el.dataset.filterState
161
+ )
162
+ }
163
+
164
+ test('activates the comment button and resets a stale progress selection', () => {
165
+ syncFilterButtons(new URL('http://localhost/creatives?comment=true'))
166
+
167
+ expect(activeStates().sort()).toEqual(['comment', 'progress:all'])
168
+ })
169
+
170
+ test('does not treat the comment filter as a generic "any filter"', () => {
171
+ syncFilterButtons(new URL('http://localhost/creatives?comment=true'))
172
+
173
+ expect(document.querySelector('[data-filter-state="any-filter"]').classList).not.toContain(
174
+ 'active'
175
+ )
176
+ })
177
+
178
+ test('marks the search trigger active for progress, archive and search filters', () => {
179
+ syncFilterButtons(new URL('http://localhost/creatives?min_progress=1&max_progress=1'))
180
+ expect(activeStates()).toContain('any-filter')
181
+
182
+ syncFilterButtons(new URL('http://localhost/creatives?show_archived=true'))
183
+ expect(activeStates()).toContain('any-filter')
184
+
185
+ syncFilterButtons(new URL('http://localhost/creatives?search=abc'))
186
+ expect(activeStates()).toContain('any-filter')
187
+ })
188
+
189
+ test('ignores blank and whitespace-only filter values, matching server-side .present?', () => {
190
+ syncFilterButtons(new URL('http://localhost/creatives?search=%20%20%20&min_progress='))
191
+
192
+ expect(activeStates()).toEqual(['progress:all'])
193
+ })
194
+
195
+ test('recognizes the incomplete progress range', () => {
196
+ syncFilterButtons(new URL('http://localhost/creatives?min_progress=0&max_progress=0.99'))
197
+
198
+ expect(activeStates().sort()).toEqual(['any-filter', 'progress:incomplete'])
199
+ })
200
+
201
+ test('swaps the archive button label along with its active state', () => {
202
+ const button = document.querySelector('[data-filter-state="archived"]')
203
+
204
+ syncFilterButtons(new URL('http://localhost/creatives?show_archived=true'))
205
+ expect(button.textContent).toBe('Hide archived')
206
+
207
+ syncFilterButtons(new URL('http://localhost/creatives'))
208
+ expect(button.textContent).toBe('Show archived')
209
+ })
210
+ })
@@ -151,3 +151,47 @@ describe("highlightCodeBlocks", () => {
151
151
  expect(el.querySelector("pre code").innerHTML).toBe(first)
152
152
  })
153
153
  })
154
+
155
+ // Server-generated inbox/system comments embed a creative's plain-text label in
156
+ // a markdown link: `[#{label}](#{path})`. Collavre::HtmlText.markdown_label
157
+ // backslash-escapes the label before that interpolation; these cases pin down
158
+ // what those escapes have to survive on the rendering side.
159
+ describe("generated creative links tolerate hostile labels once escaped", () => {
160
+ const link = (label) => renderCommentMarkdown(`[${label}](/creatives/1)`)
161
+ const hrefOf = (html) => {
162
+ const el = document.createElement("div")
163
+ el.innerHTML = html
164
+ return el.querySelector("a")?.getAttribute("href")
165
+ }
166
+ const textOf = (html) => {
167
+ const el = document.createElement("div")
168
+ el.innerHTML = html
169
+ return el.querySelector("a")?.textContent
170
+ }
171
+
172
+ it("keeps an unescaped angle-bracket label from being dropped once escaped", () => {
173
+ // Raw `<x>` reaches marked as inline HTML and the sanitizer deletes it.
174
+ expect(textOf(link("A <x> tag"))).toBe("A tag")
175
+ // `\<x\>` renders the brackets as literal text instead.
176
+ expect(textOf(link("A \\<x\\> tag"))).toBe("A <x> tag")
177
+ })
178
+
179
+ it("keeps a bracket-injecting label from hijacking the link destination", () => {
180
+ // Unescaped, the label closes the link early and supplies its own href.
181
+ expect(hrefOf(link("x](https://evil.example)"))).toBe("https://evil.example")
182
+ // Escaped, the destination stays the creative path and the label is literal.
183
+ const safe = link("x\\]\\(https://evil.example\\)")
184
+ expect(hrefOf(safe)).toBe("/creatives/1")
185
+ expect(textOf(safe)).toBe("x](https://evil.example)")
186
+ })
187
+
188
+ it("renders escaped emphasis and code characters as literal label text", () => {
189
+ expect(textOf(link("a\\*b\\*c \\_d\\_ \\`e\\` \\~f\\~"))).toBe("a*b*c _d_ `e` ~f~")
190
+ })
191
+
192
+ it("leaves an ordinary label untouched", () => {
193
+ const html = link("버그: 채팅 컨텍스트 라벨")
194
+ expect(hrefOf(html)).toBe("/creatives/1")
195
+ expect(textOf(html)).toBe("버그: 채팅 컨텍스트 라벨")
196
+ })
197
+ })