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
@@ -52,9 +52,13 @@ module Collavre
52
52
  elsif can_feedback
53
53
  origin = creative.effective_origin
54
54
  comments_count = origin.comments_count
55
- unread_count = unread_count_for(origin, comments_count) if unread_count.nil?
56
- if Current.user && CommentPresenceStore.list(origin.id).include?(Current.user.id)
57
- unread_count = 0
55
+ # A batched count already has presence suppression applied by
56
+ # CommentBadgeIndex; re-checking here would be one cache read per node,
57
+ # which is exactly what the batch exists to avoid.
58
+ if unread_count.nil?
59
+ badge_index = Creatives::CommentBadgeIndex.new(user: Current.user)
60
+ badge_index.index([ origin ])
61
+ unread_count = badge_index.unread_count_for(origin)
58
62
  end
59
63
  classes = [ "comments-btn", "creative-action-btn" ]
60
64
  classes << "no-comments" if comments_count.zero?
@@ -82,11 +86,13 @@ module Collavre
82
86
  end
83
87
  is_leaf = has_children.nil? ? !creative.children.exists? : !has_children
84
88
  can_write = creative.has_permission?(Current.user, :write) if can_write.nil?
85
- progress_part = if is_leaf && can_write && !select_mode
86
- render_progress_toggle(creative, progress_value)
87
- else
88
- render_progress_value(progress_value)
89
- end
89
+ progress_part = render_progress_control(
90
+ creative,
91
+ progress_value,
92
+ has_children: !is_leaf,
93
+ can_write: can_write,
94
+ select_mode: select_mode
95
+ )
90
96
 
91
97
  safe_join([
92
98
  progress_part,
@@ -97,17 +103,18 @@ module Collavre
97
103
  end
98
104
  end
99
105
 
100
- # Unread comments on `origin` for the current user. With no read pointer,
101
- # every comment is unread. Batched by CommentBadgeIndex for the browse tree;
102
- # this is the single-creative path.
103
- def unread_count_for(origin, comments_count)
104
- pointer = CommentReadPointer.find_by(user: Current.user, creative: origin)
105
- last_read_id = pointer&.last_read_comment_id
106
- return comments_count unless last_read_id
107
-
108
- origin.comments.where("id > ? and private = ?", last_read_id, false).count
106
+ def render_progress_control(creative, value, has_children:, can_write:, select_mode: false)
107
+ if !has_children && can_write && !select_mode && progress_toggleable?(value)
108
+ render_progress_toggle(creative, value)
109
+ else
110
+ render_progress_value(value)
111
+ end
109
112
  end
110
113
 
114
+ # Rendered when the completion mark is blank, so the completed state keeps a
115
+ # stable baseline and a tappable hit area.
116
+ NBSP = "\u00A0"
117
+
111
118
  def render_progress_toggle(creative, value)
112
119
  complete = value == 1
113
120
  new_value = complete ? 0 : 1
@@ -118,8 +125,13 @@ module Collavre
118
125
  data: {
119
126
  progress_toggle: true,
120
127
  creative_id: creative.id,
121
- current_progress: value,
122
- new_progress: new_value
128
+ # progress is a decimal, so the raw value serializes as "1.0" and the
129
+ # CSS/JS state checks against "1" would never match. Both sides of the
130
+ # toggle only ever mean 0 or 1, so emit it as an integer.
131
+ current_progress: complete ? 1 : 0,
132
+ new_progress: new_value,
133
+ mark_complete: t("collavre.creatives.index.mark_complete"),
134
+ mark_incomplete: t("collavre.creatives.index.mark_incomplete")
123
135
  },
124
136
  title: tooltip
125
137
  ) do
@@ -127,16 +139,29 @@ module Collavre
127
139
  type: "checkbox",
128
140
  checked: complete || nil,
129
141
  class: "progress-toggle-checkbox",
130
- tabindex: -1,
131
142
  "aria-label": tooltip
132
143
  )
133
- safe_join([ render_progress_value(value), checkbox ])
144
+ # A completed leaf reads as the admin completion mark (blank by default),
145
+ # the same way parent rows already render 100%. CSS swaps the mark back to
146
+ # the checked box on hover/focus so a mis-click is undone in place instead
147
+ # of through the inline editor. The checkbox stays the accessible control;
148
+ # the mark is decorative.
149
+ checkbox + tag.span(completion_mark_display, class: "progress-toggle-mark", aria: { hidden: true })
134
150
  end
135
151
  end
136
152
 
153
+ # Blank (the default) collapses to a non-breaking space so the completed state
154
+ # keeps a stable baseline and a tappable hit area inside the toggle.
155
+ def completion_mark_display
156
+ completion_mark.to_s.presence || NBSP
157
+ end
158
+
159
+ def progress_toggleable?(value)
160
+ value == 0 || value == 1
161
+ end
162
+
137
163
  def render_progress_value(value)
138
164
  text = number_to_percentage(value * 100, precision: 0)
139
- completion_mark = Collavre::SystemSetting.completion_mark
140
165
  if value == 1 && !completion_mark.nil?
141
166
  text = completion_mark
142
167
  end
@@ -148,6 +173,15 @@ module Collavre
148
173
  )
149
174
  end
150
175
 
176
+ # A tree renders this helper once per node. Keep the setting lookup scoped to
177
+ # the request's view context rather than depending on a particular cache
178
+ # store's local-cache middleware to collapse repeated reads.
179
+ def completion_mark
180
+ return @completion_mark if defined?(@completion_mark)
181
+
182
+ @completion_mark = Collavre::SystemSetting.completion_mark
183
+ end
184
+
151
185
  def render_creative_tree_markdown(creatives, level = 1, with_progress = false, max_depth: nil)
152
186
  return "" if creatives.blank?
153
187
  md = ""
@@ -61,6 +61,18 @@ module Collavre
61
61
  value.is_a?(Proc) ? instance_exec(&value) : value
62
62
  end
63
63
 
64
+ # Destination for the Home button.
65
+ #
66
+ # Authenticated users are sent to the configured home page directly instead
67
+ # of through "/". "/" means "app launch" and is rewritten to the last
68
+ # visited Creative, which made Home a no-op for anyone who had opened one.
69
+ # Guests keep "/" so the middleware rewrite still applies.
70
+ def home_navigation_path
71
+ return main_app.root_path unless authenticated?
72
+
73
+ HomePagePath.absolute(script_name: request.script_name) || main_app.root_path
74
+ end
75
+
64
76
  private
65
77
 
66
78
  def render_nav_button(item)
@@ -5,12 +5,10 @@
5
5
  import "./modules/creatives"
6
6
  import "./modules/creative_row_swipe"
7
7
  import "./modules/mention_menu"
8
- import "./modules/typo_corrector"
9
8
  import "./modules/command_menu"
10
9
  import "./modules/modal_dialog"
11
10
  import "./modules/export_to_markdown"
12
11
  import "./modules/inbox_panel"
13
- import "./modules/creative_guide"
14
12
  import "./modules/creative_row_editor"
15
13
  import "./modules/slide_view"
16
14
 
@@ -19,6 +17,7 @@ import "./components/creative_tree_row"
19
17
 
20
18
  // Import and re-export lib utilities
21
19
  import "./lib/apply_lexical_styles"
20
+ import "./lib/creative_link_prefetch"
22
21
  import "./lib/turbo_stream_actions"
23
22
  import "./lib/turbo_confirm"
24
23
 
@@ -19,7 +19,7 @@ import {
19
19
  registerCodeHighlighting
20
20
  } from "@lexical/code"
21
21
  import { ListItemNode, ListNode, $isListItemNode, $isListNode, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list"
22
- import { $createLinkNode, LinkNode, AutoLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"
22
+ import { LinkNode, AutoLinkNode } from "@lexical/link"
23
23
  import { TableNode, TableRowNode, TableCellNode, INSERT_TABLE_COMMAND } from "@lexical/table"
24
24
  import { TablePlugin } from "@lexical/react/LexicalTablePlugin"
25
25
  import TableHoverActionsPlugin from "./plugins/table_hover_actions_plugin"
@@ -68,6 +68,9 @@ import { $convertToMarkdownString } from "@lexical/markdown"
68
68
  import { updateResponsiveImages } from "../lib/responsive_images"
69
69
  import { CODE_TOKEN_THEME } from "../lib/editor/code_token_theme"
70
70
  import { detectCodeLanguage, normalizeFenceLang, bridgeCodeFenceLanguages, markLanguageResolved, isLanguageResolved, clearLanguageResolved } from "../lib/editor/code_languages"
71
+ import { CreativeLinkNode } from "../lib/lexical/creative_link_node"
72
+ import { registerCreativeLinkTrigger } from "../lib/lexical/creative_link_trigger"
73
+ import { $createToolbarLinkNode, $findLinkNode } from "../lib/lexical/link_toolbar"
71
74
 
72
75
  const URL_MATCHERS = [
73
76
  createLinkMatcherWithRegExp(/https?:\/\/[^\s<]+/gi, (text) => text)
@@ -292,6 +295,24 @@ function LinkAttributesPlugin() {
292
295
  return null
293
296
  }
294
297
 
298
+ function CreativeLinkTriggerPlugin() {
299
+ const [editor] = useLexicalComposerContext()
300
+
301
+ useEffect(() => registerCreativeLinkTrigger(editor, ({ anchorRect, onSelect, onClose }) => {
302
+ const modal = document.getElementById("link-creative-modal")
303
+ const controller = modal && window.Stimulus?.getControllerForElementAndIdentifier(
304
+ modal,
305
+ "link-creative"
306
+ )
307
+ if (!controller) return false
308
+
309
+ controller.open(anchorRect, onSelect, onClose, { allowCreate: true })
310
+ return true
311
+ }), [editor])
312
+
313
+ return null
314
+ }
315
+
295
316
  function CodeHighlightingPlugin() {
296
317
  const [editor] = useLexicalComposerContext()
297
318
 
@@ -559,7 +580,6 @@ function Toolbar() {
559
580
  }, [editor])
560
581
 
561
582
  const toggleLink = useCallback(() => {
562
- let hasLink = false
563
583
  let selectionText = ""
564
584
  let isRange = false
565
585
  let url = ""
@@ -571,17 +591,10 @@ function Toolbar() {
571
591
  isRange = true
572
592
  selectionText = selection.getTextContent()
573
593
 
574
- const nodes = selection.getNodes()
575
- const nodeWithLink = nodes.find((node) => {
576
- if (node.getType() === "link") return true
577
- const parent = node.getParent()
578
- return parent?.getType() === "link"
579
- })
594
+ const nodeWithLink = $findLinkNode(selection.getNodes())
580
595
 
581
596
  if (nodeWithLink) {
582
- hasLink = true
583
- const linkNode = nodeWithLink.getType() === "link" ? nodeWithLink : nodeWithLink.getParent()
584
- url = linkNode.getURL()
597
+ url = nodeWithLink.getURL()
585
598
  }
586
599
  })
587
600
 
@@ -860,23 +873,17 @@ function Toolbar() {
860
873
  if (!selection) return
861
874
 
862
875
  // Check if we are editing an existing link
863
- const nodes = selection.getNodes()
864
- const nodeWithLink = nodes.find((node) => {
865
- if (node.getType() === "link") return true
866
- const parent = node.getParent()
867
- return parent?.getType() === "link"
868
- })
876
+ const nodeWithLink = $findLinkNode(selection.getNodes())
869
877
 
870
878
  if (nodeWithLink) {
871
879
  // UPDATE MODE
872
- const linkNode = nodeWithLink.getType() === "link" ? nodeWithLink : nodeWithLink.getParent()
873
- const newLink = $createLinkNode(finalUrl)
880
+ const newLink = $createToolbarLinkNode(finalUrl)
874
881
  newLink.append($createTextNode(finalLabel))
875
- linkNode.replace(newLink)
882
+ nodeWithLink.replace(newLink)
876
883
  newLink.select()
877
884
  } else {
878
885
  // CREATE MODE
879
- const newLink = $createLinkNode(finalUrl)
886
+ const newLink = $createToolbarLinkNode(finalUrl)
880
887
  newLink.append($createTextNode(finalLabel))
881
888
 
882
889
  if ($isRangeSelection(selection) && !selection.isCollapsed()) {
@@ -1013,6 +1020,7 @@ function EditorInner({
1013
1020
  <TrailingParagraphPlugin />
1014
1021
  <InitialContentPlugin html={initialHtml} />
1015
1022
  <LinkAttributesPlugin />
1023
+ <CreativeLinkTriggerPlugin />
1016
1024
  <ReadyPlugin onReady={onReady} />
1017
1025
  <FileUploadPlugin
1018
1026
  onUploadStateChange={onUploadStateChange}
@@ -1081,6 +1089,7 @@ export default function InlineLexicalEditor({
1081
1089
  ListNode,
1082
1090
  LinkNode,
1083
1091
  AutoLinkNode,
1092
+ CreativeLinkNode,
1084
1093
  ImageNode,
1085
1094
  AttachmentNode,
1086
1095
  VideoNode,
@@ -14,17 +14,19 @@
14
14
  // The component calls `customElements.define(...)` at module-eval time and reads
15
15
  // `customElements` as a bare global. The jest environment only exposes it on
16
16
  // `window`, so bridge it before importing the component.
17
+ import { jest } from '@jest/globals'
18
+
17
19
  beforeAll(() => {
18
20
  if (typeof globalThis.customElements === "undefined") {
19
21
  globalThis.customElements = window.customElements;
20
22
  }
21
23
  });
22
24
 
23
- async function mountRow(props) {
25
+ async function mountRow(props, parent = document.body) {
24
26
  await import("../creative_tree_row.js");
25
27
  const el = document.createElement("creative-tree-row");
26
28
  Object.assign(el, props);
27
- document.body.appendChild(el);
29
+ parent.appendChild(el);
28
30
  await el.updateComplete;
29
31
  return el;
30
32
  }
@@ -39,6 +41,17 @@ afterEach(() => {
39
41
  });
40
42
 
41
43
  describe("creative-tree-row parentId convention", () => {
44
+ test("uses the shared chevrons for collapsed and expanded branches", async () => {
45
+ const el = await mountRow({ creativeId: "8", hasChildren: true, expanded: true });
46
+
47
+ expect(el.querySelector(".creative-toggle-btn path").getAttribute("d")).toBe("M6 9L12 15L18 9");
48
+
49
+ el.expanded = false;
50
+ await el.updateComplete;
51
+
52
+ expect(el.querySelector(".creative-toggle-btn path").getAttribute("d")).toBe("M9 6L15 12L9 18");
53
+ });
54
+
42
55
  test("non-title row under a page-title parent keeps the parent id", async () => {
43
56
  const el = await mountRow({ creativeId: "8", parentId: "5", level: 2 });
44
57
  const tree = treeOf(el);
@@ -84,4 +97,21 @@ describe("creative-tree-row parentId convention", () => {
84
97
  const derivedParentId = prev.dataset.parentId || "";
85
98
  expect(derivedParentId).toBe("5"); // sibling inherits the page-title parent
86
99
  });
100
+
101
+ test("content navigation stays inside the persistent workspace frame", async () => {
102
+ const frame = document.createElement("turbo-frame");
103
+ frame.id = "creative-workspace-content";
104
+ document.body.appendChild(frame);
105
+ const visit = jest.fn();
106
+ window.Turbo = { visit };
107
+ const el = await mountRow({ creativeId: "8", linkUrl: "/creatives/8" }, frame);
108
+
109
+ el.querySelector(".creative-content").dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
110
+
111
+ expect(visit).toHaveBeenCalledWith("/creatives/8", {
112
+ action: "advance",
113
+ frame: "creative-workspace-content",
114
+ });
115
+ delete window.Turbo;
116
+ });
87
117
  });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+
6
+ const csrfFetch = jest.fn()
7
+
8
+ jest.unstable_mockModule('../../lib/api/csrf_fetch', () => ({
9
+ default: csrfFetch,
10
+ }))
11
+
12
+ beforeAll(() => {
13
+ if (typeof globalThis.customElements === 'undefined') {
14
+ globalThis.customElements = window.customElements
15
+ }
16
+ })
17
+
18
+ async function mountRow(progressHtml) {
19
+ await import('../creative_tree_row.js')
20
+ const row = document.createElement('creative-tree-row')
21
+ row.creativeId = '7'
22
+ row.progressHtml = progressHtml
23
+ document.body.appendChild(row)
24
+ await row.updateComplete
25
+ return row
26
+ }
27
+
28
+ const INCOMPLETE_TOGGLE = '<span class="progress-toggle-wrap" data-progress-toggle="true" data-creative-id="7" data-current-progress="0" data-new-progress="1" data-mark-complete="Mark complete" data-mark-incomplete="Mark incomplete" title="Mark complete"><input type="checkbox" class="progress-toggle-checkbox" aria-label="Mark complete"><span class="progress-toggle-mark" aria-hidden="true"> </span></span>'
29
+
30
+ const COMPLETE_TOGGLE = INCOMPLETE_TOGGLE
31
+ .replace('data-current-progress="0" data-new-progress="1"', 'data-current-progress="1" data-new-progress="0"')
32
+ .replace('title="Mark complete"', 'title="Mark incomplete"')
33
+ .replace('<input type="checkbox"', '<input type="checkbox" checked')
34
+ .replace('aria-label="Mark complete"', 'aria-label="Mark incomplete"')
35
+
36
+ afterEach(() => {
37
+ csrfFetch.mockReset()
38
+ document.body.innerHTML = ''
39
+ })
40
+
41
+ test('updates checkbox metadata and ancestor progress after a successful toggle', async () => {
42
+ csrfFetch.mockResolvedValue({
43
+ ok: true,
44
+ json: async () => ({
45
+ progress: 1,
46
+ progress_html: INCOMPLETE_TOGGLE.replace('data-current-progress="0"', 'data-current-progress="1"'),
47
+ has_children: false,
48
+ ancestors: [{
49
+ id: '42',
50
+ progress: 0.5,
51
+ progress_html: '<span class="creative-progress-incomplete">50%</span>',
52
+ }],
53
+ }),
54
+ })
55
+ const ancestor = document.createElement('creative-tree-row')
56
+ ancestor.setAttribute('creative-id', '42')
57
+ document.body.appendChild(ancestor)
58
+ const row = await mountRow(INCOMPLETE_TOGGLE)
59
+
60
+ row.querySelector('[data-progress-toggle]').click()
61
+ await Promise.resolve()
62
+ await row.updateComplete
63
+
64
+ expect(csrfFetch).toHaveBeenCalledWith('/creatives/7', expect.objectContaining({ method: 'PATCH' }))
65
+ expect(row.dataset.progressValue).toBe('1')
66
+ expect(ancestor.progressHtml).toContain('50%')
67
+ })
68
+
69
+ test('flips the completion state optimistically so the mark shows before the request settles', async () => {
70
+ let resolveFetch
71
+ csrfFetch.mockReturnValue(new Promise((resolve) => { resolveFetch = resolve }))
72
+ const row = await mountRow(INCOMPLETE_TOGGLE)
73
+ const toggle = row.querySelector('[data-progress-toggle]')
74
+
75
+ toggle.click()
76
+
77
+ // CSS keys the mark/checkbox swap off data-current-progress, so the completed
78
+ // row reads as done immediately instead of waiting on the PATCH.
79
+ expect(toggle.dataset.currentProgress).toBe('1')
80
+ expect(toggle.dataset.newProgress).toBe('0')
81
+ expect(toggle.title).toBe('Mark incomplete')
82
+ expect(toggle.querySelector('.progress-toggle-mark')).not.toBeNull()
83
+
84
+ resolveFetch({ ok: true, json: async () => ({ progress: 1 }) })
85
+ await Promise.resolve()
86
+ await row.updateComplete
87
+ })
88
+
89
+ // Space (and a click landing on the box itself) runs the input's own activation:
90
+ // the browser flips `checked` before the click reaches this handler. Reading the
91
+ // checkbox instead of the dataset would invert the state back while the PATCH is
92
+ // in flight, so a completed row would still read as checked. `checkbox.click()`
93
+ // drives the same activation path Space does — jsdom has no Space-to-click
94
+ // default action. Its canceled-activation restore inverts the current value
95
+ // where browsers reassign the saved one, so the browser-faithful check lives in
96
+ // test/system/creative_progress_toggle_keyboard_test.rb.
97
+ test('unchecks the box when the completed checkbox itself is activated', async () => {
98
+ let resolveFetch
99
+ csrfFetch.mockReturnValue(new Promise((resolve) => { resolveFetch = resolve }))
100
+ const row = await mountRow(COMPLETE_TOGGLE)
101
+ const toggle = row.querySelector('[data-progress-toggle]')
102
+ const checkbox = toggle.querySelector('input')
103
+
104
+ checkbox.click()
105
+
106
+ expect(toggle.dataset.currentProgress).toBe('0')
107
+ expect(toggle.dataset.newProgress).toBe('1')
108
+ expect(checkbox.checked).toBe(false)
109
+
110
+ resolveFetch({ ok: true, json: async () => ({ progress: 0 }) })
111
+ await Promise.resolve()
112
+ await row.updateComplete
113
+ })
114
+
115
+ test('rolls the box back to unchecked when activating it directly fails', async () => {
116
+ jest.spyOn(console, 'error').mockImplementation(() => {})
117
+ csrfFetch.mockResolvedValue({ ok: false, status: 422 })
118
+ const row = await mountRow(INCOMPLETE_TOGGLE)
119
+ const toggle = row.querySelector('[data-progress-toggle]')
120
+ const checkbox = toggle.querySelector('input')
121
+
122
+ checkbox.click()
123
+ await Promise.resolve()
124
+ await row.updateComplete
125
+
126
+ expect(toggle.dataset.currentProgress).toBe('0')
127
+ expect(checkbox.checked).toBe(false)
128
+ })
129
+
130
+ // `pointer-events: none` on the saving row stops the mouse but not a checkbox
131
+ // that already holds focus, so Space can re-enter the handler with the
132
+ // optimistically inverted dataset and PATCH the opposite value. Two requests in
133
+ // flight can settle out of order and leave the row on the losing one.
134
+ test('ignores repeat activation while the PATCH is in flight', async () => {
135
+ csrfFetch.mockReturnValue(new Promise(() => {}))
136
+ const row = await mountRow(COMPLETE_TOGGLE)
137
+ const toggle = row.querySelector('[data-progress-toggle]')
138
+ const checkbox = toggle.querySelector('input')
139
+
140
+ checkbox.click()
141
+ checkbox.click()
142
+
143
+ expect(csrfFetch).toHaveBeenCalledTimes(1)
144
+ expect(toggle.dataset.currentProgress).toBe('0')
145
+ expect(toggle.dataset.newProgress).toBe('1')
146
+ expect(checkbox.checked).toBe(false)
147
+ })
148
+
149
+ // The server's progress_html replaces the toggle through unsafeHTML, which
150
+ // discards the focused checkbox. Without restoring focus a keyboard user
151
+ // toggles once and then Space scrolls the page instead of toggling back.
152
+ test('keeps focus on the checkbox after the server re-renders the toggle', async () => {
153
+ csrfFetch.mockResolvedValue({
154
+ ok: true,
155
+ json: async () => ({ progress: 1, progress_html: COMPLETE_TOGGLE }),
156
+ })
157
+ const row = await mountRow(INCOMPLETE_TOGGLE)
158
+ const checkbox = row.querySelector('.progress-toggle-checkbox')
159
+ checkbox.focus()
160
+
161
+ checkbox.click()
162
+ await Promise.resolve()
163
+ await row.updateComplete
164
+ await row.updateComplete
165
+
166
+ const rendered = row.querySelector('.progress-toggle-checkbox')
167
+ expect(document.activeElement).toBe(rendered)
168
+ expect(rendered.checked).toBe(true)
169
+ })
170
+
171
+ test('restores checkbox metadata when a toggle request fails', async () => {
172
+ jest.spyOn(console, 'error').mockImplementation(() => {})
173
+ csrfFetch.mockResolvedValue({ ok: false, status: 422 })
174
+ const row = await mountRow(INCOMPLETE_TOGGLE)
175
+ const toggle = row.querySelector('[data-progress-toggle]')
176
+
177
+ toggle.click()
178
+ await Promise.resolve()
179
+ await row.updateComplete
180
+
181
+ expect(toggle.dataset.currentProgress).toBe('0')
182
+ expect(toggle.dataset.newProgress).toBe('1')
183
+ expect(toggle.title).toBe('Mark complete')
184
+ expect(toggle.querySelector('input').getAttribute('aria-label')).toBe('Mark complete')
185
+ })
@@ -1,5 +1,6 @@
1
- import { LitElement, html, svg, nothing } from "lit";
1
+ import { LitElement, html, nothing } from "lit";
2
2
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
3
+ import { CHEVRON_COLLAPSED, CHEVRON_EXPANDED } from "../utils/chevron_icons";
3
4
  import { parseEmojis } from "../utils/emoji_parser";
4
5
  import { highlightCodeBlocks } from "../lib/utils/markdown";
5
6
  import { addCreativeTableDownloadButtons } from "../lib/utils/table_download";
@@ -77,9 +78,25 @@ class CreativeTreeRow extends LitElement {
77
78
  this._extractTemplates();
78
79
  }
79
80
 
81
+ // `progressHtml` is committed through unsafeHTML, so every render that changes
82
+ // it discards the toggle — including the broadcast that lands after the PATCH
83
+ // this row already applied. A keyboard user would toggle once and then Space
84
+ // would scroll the page, so remember focus here and restore it in updated().
85
+ update(changedProperties) {
86
+ this._progressHadFocus = this._progressToggle != null &&
87
+ this._progressToggle.contains(document.activeElement);
88
+ super.update(changedProperties);
89
+ }
90
+
80
91
  updated(changedProperties) {
81
92
  this._attachHandlers();
82
93
 
94
+ if (this._progressHadFocus) {
95
+ this._progressHadFocus = false;
96
+ const checkbox = this.querySelector(".progress-toggle-checkbox");
97
+ if (checkbox && checkbox !== document.activeElement) checkbox.focus();
98
+ }
99
+
83
100
  // Re-tokenize the server-rendered description code blocks with hljs so they
84
101
  // match the editor's palette and follow light/dark theme. Idempotent: only
85
102
  // unmarked blocks are processed, and lit re-renders description DOM (dropping
@@ -485,14 +502,7 @@ class CreativeTreeRow extends LitElement {
485
502
  }
486
503
 
487
504
  _toggleIcon() {
488
- if (this.expanded) {
489
- return svg`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
490
- <path d="M6 9L12 15L18 9"/>
491
- </svg>`;
492
- }
493
- return svg`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
494
- <path d="M9 6L15 12L9 18"/>
495
- </svg>`;
505
+ return unsafeHTML(this.expanded ? CHEVRON_EXPANDED : CHEVRON_COLLAPSED);
496
506
  }
497
507
 
498
508
  _handleToggleClick(event) {
@@ -546,20 +556,45 @@ class CreativeTreeRow extends LitElement {
546
556
  }
547
557
 
548
558
  async _handleProgressToggle(event) {
549
- event.preventDefault();
550
- event.stopPropagation();
551
559
  const wrap = event.currentTarget;
560
+ const checkbox = wrap.querySelector(".progress-toggle-checkbox");
561
+ // Activating the input itself (Space, or a click landing on the box) flips
562
+ // `checked` before the click reaches this handler, and preventDefault would
563
+ // flip it back once dispatch finishes. Leave that activation alone and set
564
+ // the state explicitly below; cancel the default for every other target.
565
+ const nativeActivation = checkbox != null && event.target === checkbox;
566
+ if (!nativeActivation) event.preventDefault();
567
+ event.stopPropagation();
568
+ // `pointer-events: none` on the saving row stops the mouse but not a
569
+ // checkbox that already holds focus, so Space can re-enter here while a
570
+ // PATCH is in flight. The dataset is already inverted at that point, so a
571
+ // second request would carry the opposite value and the two could settle
572
+ // out of order. Drop the activation and put the box back on the dataset.
573
+ if (wrap.classList.contains("progress-toggle-saving")) {
574
+ if (checkbox) checkbox.checked = wrap.dataset.currentProgress === "1";
575
+ return;
576
+ }
552
577
  const creativeId = wrap.dataset.creativeId;
553
578
  const newProgress = wrap.dataset.newProgress;
554
- if (!creativeId || newProgress == null) return;
579
+ // The dataset is the source of truth: `checked` may already be flipped.
580
+ const wasComplete = wrap.dataset.currentProgress === "1";
581
+ if (!creativeId || newProgress == null) {
582
+ if (checkbox) checkbox.checked = wasComplete;
583
+ return;
584
+ }
555
585
 
556
- // Optimistic UI: toggle checkbox and class immediately
557
- const checkbox = wrap.querySelector(".progress-toggle-checkbox");
558
- const progressSpan = wrap.querySelector("[class^='creative-progress-']");
559
- const wasComplete = checkbox?.checked;
586
+ // Optimistic UI: toggle the always-visible checkbox immediately.
587
+ const previousCurrentProgress = wrap.dataset.currentProgress;
588
+ const previousNewProgress = wrap.dataset.newProgress;
589
+ const previousTitle = wrap.title;
560
590
  if (checkbox) checkbox.checked = !wasComplete;
561
- if (progressSpan) {
562
- progressSpan.className = newProgress === "1" ? "creative-progress-complete" : "creative-progress-incomplete";
591
+ const complete = newProgress === "1";
592
+ wrap.dataset.currentProgress = newProgress;
593
+ wrap.dataset.newProgress = complete ? "0" : "1";
594
+ const label = complete ? wrap.dataset.markIncomplete : wrap.dataset.markComplete;
595
+ if (label) {
596
+ wrap.title = label;
597
+ if (checkbox) checkbox.setAttribute("aria-label", label);
563
598
  }
564
599
  wrap.classList.add("progress-toggle-saving");
565
600
 
@@ -604,9 +639,10 @@ class CreativeTreeRow extends LitElement {
604
639
  } catch (err) {
605
640
  // Revert optimistic UI
606
641
  if (checkbox) checkbox.checked = wasComplete;
607
- if (progressSpan) {
608
- progressSpan.className = wasComplete ? "creative-progress-complete" : "creative-progress-incomplete";
609
- }
642
+ wrap.dataset.currentProgress = previousCurrentProgress;
643
+ wrap.dataset.newProgress = previousNewProgress;
644
+ wrap.title = previousTitle;
645
+ if (checkbox) checkbox.setAttribute("aria-label", previousTitle);
610
646
  console.error("Progress toggle failed:", err);
611
647
  } finally {
612
648
  wrap.classList.remove("progress-toggle-saving");
@@ -635,7 +671,11 @@ class CreativeTreeRow extends LitElement {
635
671
  // If not interactive, navigate to the linkUrl
636
672
  if (this.linkUrl && this.linkUrl !== "#") {
637
673
  if (window.Turbo) {
638
- window.Turbo.visit(this.linkUrl);
674
+ const workspaceFrame = this.closest("turbo-frame#creative-workspace-content");
675
+ const options = workspaceFrame
676
+ ? { action: "advance", frame: workspaceFrame.id }
677
+ : undefined;
678
+ window.Turbo.visit(this.linkUrl, options);
639
679
  } else {
640
680
  window.location.href = this.linkUrl;
641
681
  }