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,58 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import {
3
+ cancelPendingLastVisitedCreative,
4
+ prepareLastVisitedCreativeNavigation,
5
+ rememberLastVisitedCreative,
6
+ } from '../lib/last_visited_creative'
7
+
8
+ // A Turbo restore can replace the entire body, so preserve the visit action
9
+ // outside an individual controller instance until the restored page reconnects.
10
+ let lastVisitAction = null
11
+
12
+ export default class extends Controller {
13
+ static values = {
14
+ url: String,
15
+ creativeId: String,
16
+ visitToken: String,
17
+ visitSequence: Number,
18
+ }
19
+
20
+ connect() {
21
+ this.handleVisit = this.handleVisit.bind(this)
22
+ this.handleRender = this.handleRender.bind(this)
23
+ this.handleFetchRequest = this.handleFetchRequest.bind(this)
24
+ document.addEventListener('turbo:visit', this.handleVisit)
25
+ document.addEventListener('turbo:render', this.handleRender)
26
+ document.addEventListener('turbo:before-fetch-request', this.handleFetchRequest)
27
+
28
+ if (lastVisitAction === 'restore') {
29
+ requestAnimationFrame(() => this.rememberRestoredCreative())
30
+ }
31
+ }
32
+
33
+ disconnect() {
34
+ document.removeEventListener('turbo:visit', this.handleVisit)
35
+ document.removeEventListener('turbo:render', this.handleRender)
36
+ document.removeEventListener('turbo:before-fetch-request', this.handleFetchRequest)
37
+ }
38
+
39
+ handleVisit(event) {
40
+ cancelPendingLastVisitedCreative()
41
+ lastVisitAction = event.detail?.action
42
+ }
43
+
44
+ handleRender() {
45
+ requestAnimationFrame(() => this.rememberRestoredCreative())
46
+ }
47
+
48
+ handleFetchRequest(event) {
49
+ prepareLastVisitedCreativeNavigation(event, this.urlValue, this.visitTokenValue)
50
+ }
51
+
52
+ rememberRestoredCreative() {
53
+ if (lastVisitAction !== 'restore' || !this.element.isConnected) return
54
+
55
+ rememberLastVisitedCreative(this.urlValue, this.creativeIdValue, this.visitTokenValue)
56
+ lastVisitAction = null
57
+ }
58
+ }
@@ -1,17 +1,11 @@
1
1
  import CommonPopupController from './common_popup_controller'
2
2
  import creativesApi from '../lib/api/creatives'
3
+ import { CHEVRON_COLLAPSED, CHEVRON_EXPANDED } from '../utils/chevron_icons'
3
4
 
4
5
  // Minimum characters before a text search fires. Below this the popup shows the
5
6
  // browsable mini-tree instead (empty input => tree, >= MIN_QUERY chars => search).
6
7
  const MIN_QUERY = 2
7
8
 
8
- // Chevron icons matched to the main creative tree (creative_tree_row.js#_toggleIcon)
9
- // so the mini-tree expand/collapse affordance is visually identical.
10
- const CHEVRON_COLLAPSED =
11
- '<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"><path d="M9 6L15 12L9 18"/></svg>'
12
- const CHEVRON_EXPANDED =
13
- '<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"><path d="M6 9L12 15L18 9"/></svg>'
14
-
15
9
  export default class extends CommonPopupController {
16
10
  static targets = ['input', 'list', 'close']
17
11
 
@@ -19,6 +13,7 @@ export default class extends CommonPopupController {
19
13
  super.connect()
20
14
  this._debounceTimer = null
21
15
  this._searchToken = 0
16
+ this._openGeneration = 0
22
17
  this._mode = 'tree'
23
18
  this._rootNodes = null
24
19
  this._activeEl = null
@@ -39,16 +34,19 @@ export default class extends CommonPopupController {
39
34
  super.disconnect()
40
35
  }
41
36
 
42
- open(anchorRect, onSelectCallback, onCloseCallback) {
37
+ open(anchor, onSelectCallback, onCloseCallback, { allowCreate = false } = {}) {
38
+ this._openGeneration++
43
39
  this.onSelectCallback = onSelectCallback
44
40
  this.onCloseCallback = onCloseCallback
41
+ this._allowCreate = allowCreate
42
+ this._creating = false
45
43
  this._mode = 'tree'
46
44
  this._rootNodes = null
47
45
  this._activeEl = null
48
46
  this.inputTarget.value = ''
49
47
  // Clear any CommonPopup item state; we render our own DOM into the list.
50
48
  this.popup.setItems([])
51
- super.open(anchorRect)
49
+ super.open(anchor)
52
50
 
53
51
  requestAnimationFrame(() => {
54
52
  this.inputTarget.focus()
@@ -58,6 +56,7 @@ export default class extends CommonPopupController {
58
56
  }
59
57
 
60
58
  close() {
59
+ this._openGeneration++
61
60
  this._clearDebounce()
62
61
  super.close()
63
62
  }
@@ -180,6 +179,16 @@ export default class extends CommonPopupController {
180
179
  }
181
180
  nodes.forEach((node) => this.listTarget.appendChild(this._buildTreeItem(node, 0)))
182
181
  this._resetActive()
182
+ this._reposition()
183
+ }
184
+
185
+ // The popup is positioned one frame after open(), while the list still shows
186
+ // "Loading…". Every render that changes the list's height has to re-run that
187
+ // placement, or the popup keeps the size the placeholder implied — which,
188
+ // with the caret near the bottom of the screen, leaves a single tree row
189
+ // visible under the search box.
190
+ _reposition() {
191
+ this.popup?.reposition()
183
192
  }
184
193
 
185
194
  _buildTreeItem(node, level) {
@@ -255,6 +264,7 @@ export default class extends CommonPopupController {
255
264
  if (childrenUl) childrenUl.hidden = true
256
265
  const toggle = li.querySelector(':scope > .link-tree-row > .link-tree-toggle')
257
266
  if (toggle && !toggle.classList.contains('link-tree-toggle-empty')) toggle.innerHTML = CHEVRON_COLLAPSED
267
+ this._reposition()
258
268
  }
259
269
 
260
270
  // Returns a promise that resolves once the node is expanded (children loaded
@@ -269,7 +279,10 @@ export default class extends CommonPopupController {
269
279
  childrenUl.hidden = false
270
280
  if (toggle && !toggle.classList.contains('link-tree-toggle-empty')) toggle.innerHTML = CHEVRON_EXPANDED
271
281
 
272
- if (li.dataset.loaded === '1') return Promise.resolve()
282
+ if (li.dataset.loaded === '1') {
283
+ this._reposition()
284
+ return Promise.resolve()
285
+ }
273
286
 
274
287
  const level = parseInt(li.dataset.level, 10) + 1
275
288
  li.dataset.loaded = '1'
@@ -281,13 +294,15 @@ export default class extends CommonPopupController {
281
294
  const list = Array.isArray(nodes) ? nodes : []
282
295
  if (list.length === 0) {
283
296
  childrenUl.innerHTML = `<li class="link-tree-empty">${this._escape(this._text('emptyText'))}</li>`
284
- return
297
+ } else {
298
+ list.forEach((child) => childrenUl.appendChild(this._buildTreeItem(child, level)))
285
299
  }
286
- list.forEach((child) => childrenUl.appendChild(this._buildTreeItem(child, level)))
300
+ this._reposition()
287
301
  })
288
302
  .catch(() => {
289
303
  li.dataset.loaded = '0'
290
304
  childrenUl.innerHTML = ''
305
+ this._reposition()
291
306
  })
292
307
  }
293
308
 
@@ -295,12 +310,11 @@ export default class extends CommonPopupController {
295
310
 
296
311
  _renderSearchResults(results) {
297
312
  this.listTarget.innerHTML = ''
298
- if (!results || results.length === 0) {
299
- this._renderMessage(this._text('noResultsText'))
300
- return
301
- }
313
+ const query = this.inputTarget.value.trim()
314
+ const list = Array.isArray(results) ? results : []
315
+ if (list.length === 0) this._appendMessage(this._text('noResultsText'))
302
316
 
303
- results.forEach((result) => {
317
+ list.forEach((result) => {
304
318
  const li = document.createElement('li')
305
319
  li.className = 'link-result-item'
306
320
  li.setAttribute('data-pick-row', '')
@@ -321,7 +335,33 @@ export default class extends CommonPopupController {
321
335
 
322
336
  this.listTarget.appendChild(li)
323
337
  })
338
+
339
+ const exactMatch = list.some((result) =>
340
+ String(result.description || '').trim().toLocaleLowerCase() === query.toLocaleLowerCase()
341
+ )
342
+ if (this._allowCreate && query && !exactMatch) {
343
+ this.listTarget.appendChild(this._buildCreateItem(query))
344
+ }
324
345
  this._resetActive()
346
+ this._reposition()
347
+ }
348
+
349
+ _buildCreateItem(query) {
350
+ const li = document.createElement('li')
351
+ li.className = 'link-result-item link-create-item'
352
+ li.setAttribute('data-pick-row', '')
353
+ li.dataset.create = 'true'
354
+ li.dataset.query = query
355
+
356
+ const label = document.createElement('div')
357
+ label.className = 'link-result-label'
358
+ label.textContent = this._text('createText').replace('%{query}', query)
359
+ li.appendChild(label)
360
+
361
+ li.addEventListener('mouseenter', () => this._setActive(li))
362
+ li.addEventListener('mousedown', (event) => event.preventDefault())
363
+ li.addEventListener('click', () => this._activateRow(li))
364
+ return li
325
365
  }
326
366
 
327
367
  _buildBreadcrumb(result) {
@@ -446,6 +486,10 @@ export default class extends CommonPopupController {
446
486
 
447
487
  _activateRow(row) {
448
488
  if (!row || !row.hasAttribute('data-pick-row')) return
489
+ if (row.dataset.create === 'true') {
490
+ this._createCreative(row.dataset.query)
491
+ return
492
+ }
449
493
  // For a linked-creative shell row, emit the effective origin id, not the
450
494
  // shell id: consumers use the selected id as the new link's origin, and
451
495
  // linking to the shell (rather than the real shared creative) would make
@@ -458,6 +502,25 @@ export default class extends CommonPopupController {
458
502
  this.select({ id, label })
459
503
  }
460
504
 
505
+ _createCreative(query) {
506
+ if (this._creating || !query) return
507
+ this._creating = true
508
+ const openGeneration = this._openGeneration
509
+ this._renderMessage(this._text('creatingText'))
510
+
511
+ creativesApi.createFromTitle(query)
512
+ .then((creative) => {
513
+ if (openGeneration !== this._openGeneration) return
514
+ if (!creative?.id) throw new Error('Creative creation returned no id')
515
+ this.select({ id: creative.id, label: query })
516
+ })
517
+ .catch(() => {
518
+ if (openGeneration !== this._openGeneration) return
519
+ this._creating = false
520
+ this._renderMessage(this._text('createFailedText'))
521
+ })
522
+ }
523
+
461
524
  // Override select to invoke callback
462
525
  select(item) {
463
526
  if (this.onSelectCallback) {
@@ -500,11 +563,16 @@ export default class extends CommonPopupController {
500
563
 
501
564
  _renderMessage(text) {
502
565
  this.listTarget.innerHTML = ''
566
+ this._appendMessage(text)
567
+ this._activeEl = null
568
+ this._reposition()
569
+ }
570
+
571
+ _appendMessage(text) {
503
572
  const li = document.createElement('li')
504
573
  li.className = 'link-popup-message'
505
574
  li.textContent = text
506
575
  this.listTarget.appendChild(li)
507
- this._activeEl = null
508
576
  }
509
577
 
510
578
  _text(key) {
@@ -513,6 +581,9 @@ export default class extends CommonPopupController {
513
581
  noResultsText: this.element.dataset.linkCreativeNoResultsText,
514
582
  emptyText: this.element.dataset.linkCreativeEmptyText,
515
583
  expandText: this.element.dataset.linkCreativeExpandText,
584
+ createText: this.element.dataset.linkCreativeCreateText,
585
+ creatingText: this.element.dataset.linkCreativeCreatingText,
586
+ createFailedText: this.element.dataset.linkCreativeCreateFailedText,
516
587
  }
517
588
  return map[key] || ''
518
589
  }
@@ -1,35 +1,25 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
+ import {
3
+ applyFilterParam,
4
+ buildFilterUrl,
5
+ syncFilterButtons,
6
+ visitFilterUrl
7
+ } from '../lib/utils/filter_navigation'
2
8
 
3
9
  export default class extends Controller {
4
10
  static targets = ['button']
11
+ static values = { indexPath: String, onIndex: Boolean }
5
12
 
6
13
  apply(event) {
7
14
  event.preventDefault()
8
15
  const filter = event.params.filter ?? event.currentTarget.dataset.filter
9
16
  if (!filter) return
10
17
 
11
- const url = new URL(window.location.href)
18
+ const url = buildFilterUrl(
19
+ { indexPath: this.indexPathValue, onIndex: this.onIndexValue },
20
+ (params) => applyFilterParam(params, filter)
21
+ )
12
22
 
13
- if (filter === 'comment') {
14
- if (url.searchParams.get('comment') === 'true') {
15
- url.searchParams.delete('comment')
16
- } else {
17
- url.searchParams.set('comment', 'true')
18
- }
19
- } else {
20
- url.searchParams.delete('min_progress')
21
- url.searchParams.delete('max_progress')
22
-
23
- if (filter === 'complete') {
24
- url.searchParams.set('min_progress', '1')
25
- url.searchParams.set('max_progress', '1')
26
- } else if (filter === 'incomplete') {
27
- url.searchParams.set('min_progress', '0')
28
- url.searchParams.set('max_progress', '0.99')
29
- }
30
- }
31
-
32
- const query = url.searchParams.toString()
33
- window.location.href = query ? `${url.pathname}?${query}` : url.pathname
23
+ if (visitFilterUrl(url)) syncFilterButtons(url)
34
24
  }
35
25
  }
@@ -1,7 +1,15 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
+ import {
3
+ WORKSPACE_FRAME_ID,
4
+ applyFilterParam,
5
+ buildFilterUrl,
6
+ syncFilterButtons,
7
+ visitFilterUrl
8
+ } from '../lib/utils/filter_navigation'
2
9
 
3
10
  export default class extends Controller {
4
11
  static targets = ['input', 'popup', 'overlay']
12
+ static values = { indexPath: String, onIndex: Boolean }
5
13
 
6
14
  connect() {
7
15
  this._escHandler = (e) => {
@@ -22,6 +30,18 @@ export default class extends Controller {
22
30
  }
23
31
  }
24
32
  document.addEventListener('keydown', this._shortcutHandler)
33
+
34
+ // The GNB survives workspace-frame visits. Tree navigation and frame
35
+ // history traversal bypass _navigate(), so keep its controls aligned with
36
+ // the browser URL whenever that frame loads or history changes.
37
+ this._frameLoadHandler = (event) => {
38
+ if (event.target.id !== WORKSPACE_FRAME_ID) return
39
+ this._syncFromLocation()
40
+ }
41
+ this._historyHandler = () => this._syncFromLocation()
42
+ document.addEventListener('turbo:frame-load', this._frameLoadHandler)
43
+ window.addEventListener('popstate', this._historyHandler)
44
+ this._syncFromLocation()
25
45
  }
26
46
 
27
47
  _isEditableTarget(target) {
@@ -38,6 +58,8 @@ export default class extends Controller {
38
58
  disconnect() {
39
59
  document.removeEventListener('keydown', this._escHandler)
40
60
  document.removeEventListener('keydown', this._shortcutHandler)
61
+ document.removeEventListener('turbo:frame-load', this._frameLoadHandler)
62
+ window.removeEventListener('popstate', this._historyHandler)
41
63
  }
42
64
 
43
65
  open() {
@@ -81,33 +103,13 @@ export default class extends Controller {
81
103
  const filter = event.currentTarget.dataset.filter
82
104
  if (!filter) return
83
105
 
84
- const url = new URL(window.location.href)
85
- url.searchParams.delete('min_progress')
86
- url.searchParams.delete('max_progress')
87
-
88
- if (filter === 'complete') {
89
- url.searchParams.set('min_progress', '1')
90
- url.searchParams.set('max_progress', '1')
91
- } else if (filter === 'incomplete') {
92
- url.searchParams.set('min_progress', '0')
93
- url.searchParams.set('max_progress', '0.99')
94
- }
95
-
96
- const query = url.searchParams.toString()
97
- window.location.href = query ? `${url.pathname}?${query}` : url.pathname
106
+ this._navigate((params) => applyFilterParam(params, filter))
98
107
  }
99
108
 
100
109
  // Apply comment filter
101
110
  applyCommentFilter(event) {
102
111
  event.preventDefault()
103
- const url = new URL(window.location.href)
104
- if (url.searchParams.get('comment') === 'true') {
105
- url.searchParams.delete('comment')
106
- } else {
107
- url.searchParams.set('comment', 'true')
108
- }
109
- const query = url.searchParams.toString()
110
- window.location.href = query ? `${url.pathname}?${query}` : url.pathname
112
+ this._navigate((params) => applyFilterParam(params, 'comment'))
111
113
  }
112
114
 
113
115
  // Toggle search mode (flat/tree)
@@ -116,39 +118,55 @@ export default class extends Controller {
116
118
  const mode = event.currentTarget.dataset.mode
117
119
  if (!mode) return
118
120
 
119
- const url = new URL(window.location.href)
120
- if (mode === 'tree') {
121
- url.searchParams.set('search_mode', 'tree')
122
- } else {
123
- url.searchParams.delete('search_mode')
124
- }
125
- const query = url.searchParams.toString()
126
- window.location.href = query ? `${url.pathname}?${query}` : url.pathname
121
+ this._navigate((params) => {
122
+ if (mode === 'tree') {
123
+ params.set('search_mode', 'tree')
124
+ } else {
125
+ params.delete('search_mode')
126
+ }
127
+ })
127
128
  }
128
129
 
129
130
  // Toggle archive visibility via URL parameter
130
131
  toggleArchive(event) {
131
132
  event.preventDefault()
132
- const url = new URL(window.location.href)
133
- if (url.searchParams.get('show_archived') === 'true') {
134
- url.searchParams.delete('show_archived')
135
- } else {
136
- url.searchParams.set('show_archived', 'true')
137
- }
138
- const query = url.searchParams.toString()
139
- window.location.href = query ? `${url.pathname}?${query}` : url.pathname
133
+ this._navigate((params) => {
134
+ if (params.get('show_archived') === 'true') {
135
+ params.delete('show_archived')
136
+ } else {
137
+ params.set('show_archived', 'true')
138
+ }
139
+ })
140
140
  }
141
141
 
142
142
  _applyFilters(overrides = {}) {
143
- const url = new URL(window.location.href)
144
- if ('search' in overrides) {
145
- if (overrides.search) {
146
- url.searchParams.set('search', overrides.search)
143
+ this._navigate((params) => {
144
+ if (!('search' in overrides)) return
145
+
146
+ const search = overrides.search
147
+ if (search.trim()) {
148
+ params.set('search', search)
147
149
  } else {
148
- url.searchParams.delete('search')
150
+ params.delete('search')
149
151
  }
150
- }
151
- const query = url.searchParams.toString()
152
- window.location.href = query ? `${url.pathname}?${query}` : url.pathname
152
+ })
153
+ }
154
+
155
+ // The popup lives in the GNB, outside the workspace frame, so a frame-only
156
+ // navigation leaves it open and showing the previous filter state.
157
+ _navigate(mutate) {
158
+ const url = buildFilterUrl(
159
+ { indexPath: this.indexPathValue, onIndex: this.onIndexValue },
160
+ mutate
161
+ )
162
+
163
+ this.close()
164
+ if (visitFilterUrl(url)) syncFilterButtons(url)
165
+ }
166
+
167
+ _syncFromLocation() {
168
+ const url = new URL(window.location.href)
169
+ syncFilterButtons(url)
170
+ this.inputTarget.value = url.searchParams.get('search') || ''
153
171
  }
154
172
  }
@@ -1,5 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
- import CommonPopup from '../lib/common_popup'
2
+ import CommonPopup, { elementAnchor } from '../lib/common_popup'
3
3
 
4
4
  export default class extends Controller {
5
5
  static targets = ['input']
@@ -78,7 +78,7 @@ export default class extends Controller {
78
78
 
79
79
  if (items.length > 0) {
80
80
  this.popup?.setItems(items)
81
- this.popup?.showAt(this.inputTarget.getBoundingClientRect())
81
+ this.popup?.showAt(elementAnchor(this.inputTarget))
82
82
  } else {
83
83
  this.popup?.hide()
84
84
  }
@@ -25,21 +25,55 @@ export default class extends CommonPopupController {
25
25
 
26
26
  openForTopics({ topics = [], archivedTopics = [], mainTopicId = null, allMessagesLabel = 'All Messages' }, anchorRect, onSelectCallback, boundsElement = null) {
27
27
  this.onSelectCallback = onSelectCallback
28
- this._allItems = this._buildItems({ topics, archivedTopics, mainTopicId, allMessagesLabel })
29
28
  this.inputTarget.value = ''
30
- this.setItems(this._allItems)
29
+ this.updateTopics({ topics, archivedTopics, mainTopicId, allMessagesLabel })
31
30
  super.open(anchorRect, boundsElement)
31
+ if (this.isMobile()) {
32
+ // Autofocusing the search box raises the virtual keyboard, which covers
33
+ // the very list the user just asked to see. Drop focus instead — tapping
34
+ // the box still opens the keyboard when the user actually wants to search.
35
+ this._blurActiveElement()
36
+ return
37
+ }
32
38
  requestAnimationFrame(() => this.inputTarget.focus())
33
39
  }
34
40
 
41
+ updateTopics({ topics = [], archivedTopics = [], mainTopicId = null, allMessagesLabel = 'All Messages' }) {
42
+ const activeItem = this.popup.items[this.popup.activeIndex]
43
+ this._allItems = this._buildItems({ topics, archivedTopics, mainTopicId, allMessagesLabel })
44
+ this._onInput()
45
+ if (!activeItem) return
46
+
47
+ const activeIndex = this.popup.items.findIndex(item => String(item.id) === String(activeItem.id))
48
+ if (activeIndex >= 0) this.popup.setActiveIndex(activeIndex)
49
+ }
50
+
51
+ isMobile() {
52
+ return window.innerWidth <= 600
53
+ }
54
+
55
+ _blurActiveElement() {
56
+ const active = document.activeElement
57
+ if (active && active !== document.body && typeof active.blur === 'function') active.blur()
58
+ }
59
+
35
60
  _buildItems({ topics, archivedTopics, mainTopicId, allMessagesLabel }) {
36
61
  const main = mainTopicId ? topics.find(t => String(t.id) === String(mainTopicId)) : null
37
62
  const others = topics.filter(t => !main || String(t.id) !== String(mainTopicId))
63
+ const topicItem = (topic, archived) => {
64
+ const unreadCount = Number(topic.unread_count)
65
+ return {
66
+ id: topic.id,
67
+ label: `#${topic.name}`,
68
+ archived,
69
+ unreadCount: Number.isFinite(unreadCount) ? unreadCount : 0
70
+ }
71
+ }
38
72
  const items = []
39
- if (main) items.push({ id: main.id, label: `#${main.name}`, archived: false })
40
- others.forEach(t => items.push({ id: t.id, label: `#${t.name}`, archived: false }))
73
+ if (main) items.push(topicItem(main, false))
74
+ others.forEach(t => items.push(topicItem(t, false)))
41
75
  items.push({ id: '', label: `📋 ${allMessagesLabel}`, archived: false })
42
- archivedTopics.forEach(t => items.push({ id: t.id, label: `#${t.name}`, archived: true }))
76
+ archivedTopics.forEach(t => items.push(topicItem(t, true)))
43
77
  return items
44
78
  }
45
79
 
@@ -65,10 +99,13 @@ export default class extends CommonPopupController {
65
99
  .replace(/&/g, '&amp;')
66
100
  .replace(/</g, '&lt;')
67
101
  .replace(/>/g, '&gt;')
102
+ const unreadBadge = item.unreadCount > 0
103
+ ? `<span class="topic-unread-badge">${item.unreadCount}</span>`
104
+ : ''
68
105
  if (item.archived) {
69
- return `<span class="topic-list-item topic-list-item--archived">${ICON_ARCHIVE} ${escaped}</span>`
106
+ return `<span class="topic-list-item topic-list-item--archived">${ICON_ARCHIVE}<span class="topic-list-item-label">${escaped}</span>${unreadBadge}</span>`
70
107
  }
71
- return `<span class="topic-list-item">${escaped}</span>`
108
+ return `<span class="topic-list-item"><span class="topic-list-item-label">${escaped}</span>${unreadBadge}</span>`
72
109
  }
73
110
 
74
111
  dispatchClose(reason) {