collavre 0.22.0 → 0.24.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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -5,6 +5,22 @@ module Collavre
5
5
 
6
6
  self.table_name = "channels"
7
7
 
8
+ include Collavre::IndexedJsonColumns
9
+
10
+ # Denormalized columns that mirror same-named keys in `config`. They exist
11
+ # so the channels UNIQUE indexes are plain-column indexes (which dump
12
+ # identically to schema.rb on SQLite and PostgreSQL) instead of JSON
13
+ # expression indexes (json_extract vs config->>), which serialize
14
+ # per-adapter and crash `db:schema:load` on PostgreSQL. `config` stays the
15
+ # source of truth; these columns are re-derived on every save. Declared
16
+ # here beside the table whose unique indexes are defined in this engine's
17
+ # migration, so subtype engines need not know about the denormalization.
18
+ indexed_json_columns json: :config, columns: {
19
+ repo_full_name: "repo_full_name",
20
+ pr_number: "pr_number",
21
+ worktree_id: "worktree_id"
22
+ }
23
+
8
24
  belongs_to :topic, class_name: "Collavre::Topic"
9
25
 
10
26
  enum :state, { active: 0, detached: 1 }, default: :active
@@ -3,6 +3,22 @@ module Collavre
3
3
  module Approvable
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ # A message that renders an approval button (pending) or an
7
+ # approved/denied status label (decided) in the chat list — i.e. it
8
+ # carries an `action` JSON payload. Both the `has_pending_action`
9
+ # button (action.present? && action_executed_at.blank?) and the
10
+ # ✅ approved / 🚫 denied label (action_executed_at.present?, which implies
11
+ # action.present?) roll up to this single condition.
12
+ #
13
+ # Such a message is a HUMAN decision surface and must never be dispatched
14
+ # to an AI agent — regardless of who authored it or whether it has already
15
+ # been decided. This is the single source of truth for that invariant,
16
+ # enforced at every dispatch seam (Comment#dispatch_to_orchestration and
17
+ # AiAgent::A2aDispatcher#dispatch).
18
+ def approval_action?
19
+ action.present?
20
+ end
21
+
6
22
  def can_be_approved_by?(user)
7
23
  approval_status(user) == :ok
8
24
  end
@@ -3,6 +3,9 @@ module Collavre
3
3
  module Broadcastable
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ # The desktop and mobile inbox badge DOM ids kept in sync in real time.
7
+ INBOX_BADGE_TARGETS = %w[desktop-inbox-badge mobile-inbox-badge].freeze
8
+
6
9
  included do
7
10
  after_create_commit :broadcast_create
8
11
  after_update_commit :broadcast_update
@@ -112,21 +115,57 @@ module Collavre
112
115
  def broadcast_inbox_badge(inbox_creative, owner, count: nil)
113
116
  return unless inbox_creative && owner
114
117
 
115
- count ||= Collavre::Inbox::BadgeComponent.new(user: owner, creative: inbox_creative).count
118
+ count ||= inbox_badge_count(inbox_creative, owner)
116
119
 
117
- %w[desktop-inbox-badge mobile-inbox-badge].each do |target_id|
120
+ INBOX_BADGE_TARGETS.each do |target_id|
118
121
  Turbo::StreamsChannel.broadcast_replace_to(
119
122
  [ "inbox", owner ],
120
123
  target: target_id,
121
124
  partial: "inbox/badge_component/count",
122
- locals: {
123
- count: count,
124
- badge_id: target_id,
125
- show_zero: false
126
- }
125
+ locals: inbox_badge_locals(count, target_id)
127
126
  )
128
127
  end
129
128
  end
129
+
130
+ # Render the same inbox badge replacements as a Turbo Stream string so a
131
+ # channel can transmit them straight to its own confirmed subscriber
132
+ # (see InboxBadgeChannel), instead of re-broadcasting to the sibling
133
+ # ["inbox", user] stream and risking a reconnect race. Returns nil when
134
+ # there is nothing to render.
135
+ def inbox_badge_turbo_stream(inbox_creative, owner, count: nil)
136
+ return unless inbox_creative && owner
137
+
138
+ count ||= inbox_badge_count(inbox_creative, owner)
139
+
140
+ INBOX_BADGE_TARGETS.map do |target_id|
141
+ Turbo::StreamsChannel.turbo_stream_action_tag(
142
+ :replace,
143
+ target: target_id,
144
+ template: ApplicationController.render(
145
+ partial: "inbox/badge_component/count",
146
+ formats: [ :html ],
147
+ locals: inbox_badge_locals(count, target_id)
148
+ )
149
+ )
150
+ end.join.html_safe
151
+ end
152
+
153
+ private
154
+
155
+ # Inbox badge count when no caller-supplied count is available (e.g. the
156
+ # reconnect snapshot in InboxBadgeChannel). Mirrors broadcast_badge's
157
+ # suppression: a user actively viewing the inbox (present in
158
+ # CommentPresenceStore) sees 0, so a reconnect can't repaint unread items
159
+ # over the suppressed badge.
160
+ def inbox_badge_count(inbox_creative, owner)
161
+ return 0 if CommentPresenceStore.list(inbox_creative.id).include?(owner.id)
162
+
163
+ Collavre::Inbox::BadgeComponent.new(user: owner, creative: inbox_creative).count
164
+ end
165
+
166
+ def inbox_badge_locals(count, target_id)
167
+ { count: count, badge_id: target_id, show_zero: false }
168
+ end
130
169
  end
131
170
 
132
171
  private
@@ -118,7 +118,7 @@ module Collavre
118
118
  def notify_write_users
119
119
  return if private? || !user
120
120
  return if streaming_placeholder?
121
- return if creative&.inbox? # Don't notify about inbox comments
121
+ return if suppress_inbox_notification? # only the System topic is the alarm stream
122
122
  notification_recipients.each do |recipient|
123
123
  create_inbox_comment(
124
124
  recipient,
@@ -131,7 +131,7 @@ module Collavre
131
131
  def notify_mentions
132
132
  return if private?
133
133
  return if streaming_placeholder?
134
- return if creative&.inbox? # Don't notify about inbox comments
134
+ return if suppress_inbox_notification? # only the System topic is the alarm stream
135
135
  notify_mentioned_users
136
136
  end
137
137
 
@@ -146,7 +146,7 @@ module Collavre
146
146
  end
147
147
 
148
148
  def notify_ai_write_users
149
- return if creative&.inbox? # Don't notify about inbox comments
149
+ return if suppress_inbox_notification? # only the System topic is the alarm stream
150
150
  notification_recipients.each do |recipient|
151
151
  create_inbox_comment(
152
152
  recipient,
@@ -157,12 +157,22 @@ module Collavre
157
157
  end
158
158
 
159
159
  def notify_ai_mentions
160
- return if creative&.inbox? # Don't notify about inbox comments
160
+ return if suppress_inbox_notification? # only the System topic is the alarm stream
161
161
  notify_mentioned_users
162
162
  end
163
163
 
164
+ # #1301 made every inbox topic EXCEPT System dispatch like a normal topic;
165
+ # the alarm stream follows suit. Suppress notifications ONLY for the System
166
+ # topic itself (its system-authored notices must not cascade into more
167
+ # notices — a loop). Inbox#Main and other inbox topics notify normally, so
168
+ # an agent reply there reaches the absent owner. Mirrors the dispatch gate
169
+ # in Comment#dispatch_to_orchestration.
170
+ def suppress_inbox_notification?
171
+ creative&.inbox? && inbox_system_topic?
172
+ end
173
+
164
174
  def notify_approver
165
- return unless approver.present? && action.present?
175
+ return unless approver.present? && approval_action?
166
176
  return if approver == user
167
177
  return if creative&.inbox? # Don't notify about inbox comments
168
178
 
@@ -3,13 +3,55 @@ module Collavre
3
3
  self.table_name = "comments"
4
4
 
5
5
  STREAMING_PLACEHOLDER_CONTENT = "..."
6
+ # Authorless "⏳" waiting-notice system messages posted when an agent is
7
+ # deferred for topic concurrency. AgentOrchestrator.cleanup_waiting_notices!
8
+ # matches the same prefix to remove them once the waiter is dequeued.
9
+ WAITING_NOTICE_PREFIX = "⏳"
6
10
 
7
11
  # Use non-namespaced partial path for backward compatibility
8
12
  def to_partial_path
9
13
  "comments/comment"
10
14
  end
11
15
 
12
- belongs_to :creative, class_name: "Collavre::Creative"
16
+ # A system "" waiting notice (no author) telling a user their agent is
17
+ # deferred because another task holds the topic's running slot.
18
+ def waiting_notice?
19
+ user_id.nil? && content.to_s.start_with?(WAITING_NOTICE_PREFIX)
20
+ end
21
+
22
+ # The task holding this topic's concurrency slot — the blocker this waiting
23
+ # notice is about. Lets the notice render a stop button that cancels the
24
+ # blocker (freeing the topic so the deferred waiter proceeds) instead of
25
+ # being an anonymous dead end. Resolved at render time rather than stored on
26
+ # task_id, which Task#reply_comment keys on (a shared task_id would make the
27
+ # blocker's reply_comment ambiguous).
28
+ #
29
+ # Two gates keep the button honest:
30
+ # 1. Only THIS notice's own topic-concurrency defer qualifies. The same "⏳"
31
+ # notice is also posted for :delayed decisions (busy / rate_limited),
32
+ # which schedule a delayed job WITHOUT queuing a topic waiter —
33
+ # cancelling some unrelated running task would not unblock them.
34
+ # topic_concurrency_defer is set only on the :deferred path, so a
35
+ # :delayed notice never shows the button even when an unrelated queued
36
+ # waiter happens to share the topic. The queued_for_topic check then
37
+ # confirms a waiter is still actually pending on the slot.
38
+ # 2. Resolve the blocker over occupying_topic_slot, not just running/
39
+ # delegated: a holder paused on pending_approval still occupies the slot
40
+ # and is cancellable, so the button must stay visible for it.
41
+ # Returns nil once no slot holder remains — at which point the notice itself
42
+ # is cleaned up.
43
+ def topic_blocking_task
44
+ return @topic_blocking_task if defined?(@topic_blocking_task)
45
+
46
+ @topic_blocking_task =
47
+ if topic_concurrency_defer? && topic_id &&
48
+ Collavre::Task.queued_for_topic(topic_id, creative_id).exists?
49
+ Collavre::Task.occupying_topic_slot(topic_id, creative_id)
50
+ .includes(:agent).order(:created_at).first
51
+ end
52
+ end
53
+
54
+ belongs_to :creative, class_name: "Collavre::Creative", counter_cache: true
13
55
  belongs_to :user, class_name: Collavre.configuration.user_class_name, optional: true
14
56
  belongs_to :approver, class_name: Collavre.configuration.user_class_name, optional: true
15
57
  belongs_to :action_executed_by, class_name: Collavre.configuration.user_class_name, optional: true
@@ -19,6 +61,11 @@ module Collavre
19
61
 
20
62
  scope :public_only, -> { where(private: false) }
21
63
 
64
+ # SQL inverse of Comment#approval_action? (action.present?). Approval-surface
65
+ # messages must never reach an AI agent — not only at the dispatch seams but
66
+ # also as chat-history/trigger context — so agent-context queries exclude them.
67
+ scope :without_approval_action, -> { where("action IS NULL OR action = ''") }
68
+
22
69
  scope :visible_to, ->(user) {
23
70
  where(
24
71
  "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
@@ -50,6 +97,10 @@ module Collavre
50
97
 
51
98
  attribute :skip_default_user, :boolean, default: false
52
99
  attribute :skip_dispatch, :boolean, default: false
100
+ # Set by AgentOrchestrator.cleanup_waiting_notices! so destroying a notice as
101
+ # part of *promoting* a waiter does not run the user-delete cancel cascade
102
+ # (which would cancel other still-queued waiters in the same topic).
103
+ attribute :suppress_waiter_cancellation, :boolean, default: false
53
104
 
54
105
  before_validation :use_origin_creative
55
106
  before_validation :assign_default_user, on: :create
@@ -142,12 +193,18 @@ module Collavre
142
193
  Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
143
194
  end
144
195
 
145
- # Cancel queued tasks when their waiting notice (system comment) is deleted
146
- cancel_queued_tasks_for_waiting_notice if waiting_notice?
147
- end
148
-
149
- def waiting_notice?
150
- user_id.nil? && content&.start_with?("⏳")
196
+ # Cancel queued tasks when a user DELETES their waiting notice. Gated to
197
+ # topic_concurrency_defer notices: only the :deferred path queues a topic
198
+ # waiter, so a :delayed (busy / rate_limited) "⏳" notice — which shares the
199
+ # prefix but has no waiter of its own — must not cancel an unrelated queued
200
+ # waiter that happens to share the topic. Also skipped when the notice is
201
+ # removed by promotion cleanup (suppress_waiter_cancellation): there the
202
+ # waiter is being advanced, not abandoned, and cancelling other still-queued
203
+ # waiters would drop their work (multi-slot orphan recovery must not cancel
204
+ # the rest).
205
+ if waiting_notice? && topic_concurrency_defer? && !suppress_waiter_cancellation
206
+ cancel_queued_tasks_for_waiting_notice
207
+ end
151
208
  end
152
209
 
153
210
  def cancel_queued_tasks_for_waiting_notice
@@ -161,21 +218,25 @@ module Collavre
161
218
  return if private?
162
219
  return if skip_default_user # system notices should not trigger AI
163
220
  return if skip_dispatch # explicit opt-out (e.g., command processor responses)
221
+ return if approval_action? # approval button / approved message: human decision surface, never dispatch to an agent
164
222
  return unless user_id # nil user = system message
165
223
  return if user&.ai_user? # AI replies use A2aDispatcher, not this callback
166
224
  return unless creative
167
- # Inbox creatives hold the user's notifications/DMs and normally must not
168
- # trigger AI orchestration. Exception: a Claude Channel agent session
169
- # registers its topic *inside* the inbox (Creative.inbox_for) and depends
170
- # on the orchestration pipeline (Matcher Arbiter AiAgentService) to
171
- # deliver comments to the running session. Scope the exception to actual
172
- # Claude Channel session topics (primary_agent is a claude_channel_agent?).
173
- # An inbox topic can be given any ai_user as primary_agent via
174
- # TopicsController#set_primary_agent; gating on mere primary_agent presence
175
- # would leak ordinary inbox DMs to the live Claude session, which holds
176
- # inbox-wide :feedback + routing_expression="true" and would be selected by
177
- # the Matcher for any dispatched inbox comment.
178
- return if creative.inbox? && !claude_channel_session_topic?
225
+ # Inbox creatives hold the user's notifications AND ordinary conversations.
226
+ # Only the System topic is special: it carries alarms/notifications (stuck
227
+ # recovery, share notices, …) and must never trigger AI. Every OTHER inbox
228
+ # topic Main, Content, user threads, Claude Channel session topics — is an
229
+ # ordinary conversation surface and dispatches exactly like a normal
230
+ # (non-inbox) topic.
231
+ #
232
+ # A live Claude Channel session holds inbox-wide :feedback +
233
+ # routing_expression="true", so it would otherwise be selected by the
234
+ # Matcher for *every* dispatched inbox comment (leaking ordinary inbox
235
+ # threads into the live session). That confinement now lives in
236
+ # Orchestration::Matcher, which scopes a Claude session agent to its own
237
+ # registered session topic — keeping non-System topics truly identical to a
238
+ # normal topic whether or not a session is live.
239
+ return if creative.inbox? && inbox_system_topic?
179
240
 
180
241
  # A Claude Channel session suspended on a native tool-permission prompt
181
242
  # parks its in-flight dispatch as a `delegated` task carrying a
@@ -200,19 +261,12 @@ module Collavre
200
261
  raise # re-raise so calling jobs (e.g. DropTriggerJob) can retry
201
262
  end
202
263
 
203
- # True only when this comment's topic is an actual Claude Channel session
204
- # topic it carries the registration marker (session_id) AND its
205
- # primary_agent is a claude_channel_agent? (llm_model "claude-code"). Used to
206
- # scope the inbox dispatch exception so ordinary inbox threads stay local.
207
- #
208
- # session_id is required, not just the Claude primary_agent: a Claude
209
- # channel ai_user can be assigned as primary_agent on an ordinary inbox
210
- # topic via TopicsController#set_primary_agent without ever registering a
211
- # session. Gating on the agent alone would dispatch that ordinary thread and
212
- # leak it to the live Claude session. session_id is exactly what
213
- # ClaudeChannelAdapter#session_topic? keys on, so the two stay consistent.
214
- def claude_channel_session_topic?
215
- topic&.session_id.present? && topic&.primary_agent&.claude_channel_agent?
264
+ # The inbox System topic is the alarm/notification stream and must never
265
+ # trigger AI orchestration. Matched by name (Creative::SYSTEM_TOPIC_NAME),
266
+ # the same topic Creative#system_topic finds/creates and that stuck-recovery
267
+ # and share notices post into.
268
+ def inbox_system_topic?
269
+ topic&.name == Creative::SYSTEM_TOPIC_NAME
216
270
  end
217
271
 
218
272
  def assign_default_user
@@ -225,6 +279,7 @@ module Collavre
225
279
  def resume_trigger_loop_if_awaiting
226
280
  return unless user_id # must have a user (not system)
227
281
  return if user&.ai_user? # must be a human, not an AI agent
282
+ return if approval_action? # approval surface is not a user-resume signal; the resumed @agent turn would otherwise carry it into history
228
283
  return unless creative
229
284
 
230
285
  # Use pessimistic lock to prevent duplicate resume from concurrent comments
@@ -4,7 +4,7 @@ module Collavre
4
4
  class CommentVersion < ApplicationRecord
5
5
  self.table_name = "comment_versions"
6
6
 
7
- belongs_to :comment, class_name: "Collavre::Comment"
7
+ belongs_to :comment, class_name: "Collavre::Comment", counter_cache: true
8
8
  belongs_to :review_comment, class_name: "Collavre::Comment", optional: true
9
9
 
10
10
  validates :content, presence: true
@@ -5,6 +5,11 @@ module Collavre
5
5
 
6
6
  included do
7
7
  attr_accessor :content_type_input
8
+ # Which editing surface authored this Markdown: "rich" (Lexical) reopens
9
+ # in the rich editor, "source" (or absent/legacy) reopens in the advanced
10
+ # textarea. Stored in data["editor"]; decoupled from the storage format
11
+ # (content_type), which is now Markdown for both surfaces.
12
+ attr_accessor :markdown_editor
8
13
  attr_reader :markdown_source
9
14
 
10
15
  def markdown_source=(value)
@@ -13,7 +18,7 @@ module Collavre
13
18
 
14
19
  validates :description, presence: true, unless: -> { origin_id.present? }
15
20
  validate :description_cannot_change_if_has_origin, on: :update
16
- validate :description_cannot_change_if_github_source, on: :update
21
+ validate :description_cannot_change_if_read_only_source, on: :update
17
22
 
18
23
  before_validation :convert_markdown_to_html
19
24
  before_save :sanitize_description_html
@@ -24,7 +29,13 @@ module Collavre
24
29
  # Linked Creative의 description을 안전하게 반환
25
30
  def effective_description(variation_id = nil, html = true)
26
31
  if variation_id.present?
27
- variation_tag = tags.find_by(label_id: variation_id)
32
+ # `find_by` on a loaded association still round-trips; the browse tree
33
+ # preloads tags per level and reaches here once per node.
34
+ variation_tag = if tags.loaded?
35
+ tags.find { |tag| tag.label_id.to_s == variation_id.to_s }
36
+ else
37
+ tags.find_by(label_id: variation_id)
38
+ end
28
39
  return variation_tag.value if variation_tag&.value.present?
29
40
  end
30
41
  description_val = origin_id.nil? ? description : origin.description
@@ -39,11 +50,11 @@ module Collavre
39
50
  CGI.unescapeHTML(ActionController::Base.helpers.strip_tags(effective_origin.description || "")).truncate(24, omission: "...")
40
51
  end
41
52
 
42
- # GitHub-synced creatives reject any description change
43
- # (description_cannot_change_if_github_source), so embedding would raise
53
+ # Read-only-source creatives reject any description change
54
+ # (description_cannot_change_if_read_only_source), so embedding would raise
44
55
  # and orphan the blob. Callers MUST check this before creating the blob.
45
56
  def attachments_embeddable?
46
- !effective_origin.github_markdown?
57
+ !effective_origin.read_only_source?
47
58
  end
48
59
 
49
60
  # Append an attachment node and save; after_save reconcile attaches the
@@ -128,6 +139,9 @@ module Collavre
128
139
  prev_source = data["markdown_source"].to_s
129
140
  prev_type = data["content_type"]
130
141
  self.data["content_type"] = "markdown"
142
+ # Remember which surface authored this. Absent param (MCP/tool writes)
143
+ # keeps the prior choice, defaulting to the advanced textarea.
144
+ self.data["editor"] = markdown_editor.presence || data["editor"] || "source"
131
145
  if new_source != prev_source || prev_type != "markdown"
132
146
  # Rewrite inline data-URI images to freshly-uploaded blob paths
133
147
  # FIRST, then persist the rewritten source. Subsequent edits
@@ -149,6 +163,7 @@ module Collavre
149
163
  self.data ||= {}
150
164
  self.data.delete("content_type")
151
165
  self.data.delete("markdown_source")
166
+ self.data.delete("editor")
152
167
  elsif !new_record? && description_changed? && data&.dig("content_type") == "markdown"
153
168
  # Description was rewritten through a non-markdown path (tool/MCP
154
169
  # update, direct base.update(description: ...), etc.) on a creative
@@ -158,6 +173,7 @@ module Collavre
158
173
  # Demote to HTML mode so the persisted source matches description.
159
174
  self.data.delete("content_type")
160
175
  self.data.delete("markdown_source")
176
+ self.data.delete("editor")
161
177
  end
162
178
  end
163
179
 
@@ -181,21 +197,67 @@ module Collavre
181
197
  end
182
198
  end
183
199
 
200
+ # The Lexical editor stores text color / background as inline <span
201
+ # style="...">. Tighten every style attribute to ONLY validated color /
202
+ # background-color declarations BEFORE sanitization, so allowing `style`
203
+ # in the safelist can't smuggle layout/position/url() payloads.
204
+ scrub_inline_color_styles(scrubbed)
205
+
184
206
  self.description = ActionController::Base.helpers.sanitize(
185
207
  scrubbed.to_html,
186
208
  tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + table_tags + media_tags + %w[input],
187
- attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + task_list_attrs + media_attrs + %w[data-lexical]
209
+ attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + table_attrs + attachment_attrs + task_list_attrs + media_attrs + %w[data-lexical style]
188
210
  )
189
211
  end
190
212
 
191
- # Sync creative.files to exactly the blobs referenced in the description
192
- # HTML (attach new, detach removed). Must never raise during save —
193
- # malformed HTML yields [] and a no-op.
213
+ # Only these CSS color values may appear in a stored inline style — mirrors
214
+ # the JS markdown serializer's allowlist (markdown_serialize.js). Blocks
215
+ # url(), expression(), javascript:, and angle brackets.
216
+ SAFE_COLOR_VALUE = /\A(#[0-9a-fA-F]{3,8}|rgba?\([0-9.,%\s]+\)|hsla?\([0-9.,%\s]+\)|var\(--[a-zA-Z0-9\-_]+\)|[a-zA-Z]+)\z/
217
+
218
+ def scrub_inline_color_styles(fragment)
219
+ fragment.css("[style]").each do |node|
220
+ declarations = node["style"].to_s.split(";").filter_map do |decl|
221
+ key, value = decl.split(":", 2)
222
+ next if key.nil? || value.nil?
223
+
224
+ key = key.strip.downcase
225
+ value = value.strip
226
+ next unless %w[color background-color].include?(key)
227
+ next if value =~ /url\(|expression|javascript:|@import|[<>]/i
228
+ next unless value =~ SAFE_COLOR_VALUE
229
+
230
+ "#{key}: #{value}"
231
+ end
232
+
233
+ if declarations.any?
234
+ node["style"] = declarations.join("; ")
235
+ else
236
+ node.remove_attribute("style")
237
+ end
238
+ end
239
+ end
240
+
241
+ # Sync creative.files to the blobs referenced in the description HTML
242
+ # (attach new, detach removed). Must never raise during save — malformed
243
+ # HTML yields [] and a no-op.
244
+ #
245
+ # The rich (Lexical) editor is now Markdown-canonical and serializes
246
+ # inserted images/videos/files as raw blob-URL tags inside markdown_source,
247
+ # which markdown_to_html renders into `description`. So reconcile must run
248
+ # for markdown mode too, otherwise rich-editor uploads never reach
249
+ # creative.files (and the attachment list/remove services miss them).
194
250
  #
195
- # Markdown-mode creatives manage their own blobs via MarkdownConverter;
196
- # the embed paths demote markdown -> html first, so uploads still reconcile.
251
+ # Markdown detach is narrowed (not skipped): markdown creatives may carry
252
+ # legacy blobs attached directly but never embedded in the derived
253
+ # description (AttachmentBackfill skips markdown, so reconcile is their only
254
+ # touch point) — those must survive. But media the user actually removed
255
+ # in-editor WAS referenced in the prior description and no longer is; that
256
+ # must detach, or creative.files keeps listing a deleted file (the editor's
257
+ # purge DELETE can't compensate while the attachment still exists). So for
258
+ # markdown we detach only blobs that were previously referenced. HTML keeps
259
+ # full detach (backfill embeds its orphans, so none should linger).
197
260
  def reconcile_description_attachments
198
- return if data&.dig("content_type") == "markdown"
199
261
  # Linked creatives don't own their description (it lives on the origin)
200
262
  # and their own column is blank, so reconcile would treat every legacy
201
263
  # attachment as an orphan and purge it on the next save (e.g. a
@@ -212,8 +274,16 @@ module Collavre
212
274
  current = files.includes(:blob).to_a
213
275
  current_blob_ids = current.map(&:blob_id).to_set
214
276
 
277
+ markdown = data&.dig("content_type") == "markdown"
215
278
  to_attach = referenced.reject { |b| current_blob_ids.include?(b.id) }
216
- to_detach = current.reject { |a| referenced_ids.include?(a.blob_id) }
279
+ unreferenced = current.reject { |a| referenced_ids.include?(a.blob_id) }
280
+ to_detach =
281
+ if markdown
282
+ prev_referenced_ids = blob_ids_referenced_in(description_before_last_save)
283
+ unreferenced.select { |a| prev_referenced_ids.include?(a.blob_id) }
284
+ else
285
+ unreferenced
286
+ end
217
287
  return if to_attach.empty? && to_detach.empty?
218
288
 
219
289
  to_attach.each { |blob| files.attach(blob) }
@@ -266,9 +336,13 @@ module Collavre
266
336
  end
267
337
 
268
338
  def extract_signed_ids_from_description
269
- return [] if description.blank?
339
+ extract_signed_ids_from(description)
340
+ end
270
341
 
271
- html = description.to_s
342
+ def extract_signed_ids_from(html)
343
+ return [] if html.blank?
344
+
345
+ html = html.to_s
272
346
 
273
347
  ids = html.scan(%r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/?#]+)}).flatten
274
348
  ids += html.scan(%r{/rails/active_storage/blobs/([^/?#]+)}).flatten
@@ -277,18 +351,29 @@ module Collavre
277
351
  ids.uniq
278
352
  end
279
353
 
354
+ # Blob ids that `html` references, resolving each signed id to its blob.
355
+ # Used to tell user-removed media (was referenced, now gone) apart from
356
+ # legacy orphans (never referenced) during markdown reconcile.
357
+ def blob_ids_referenced_in(html)
358
+ extract_signed_ids_from(html).filter_map do |sid|
359
+ ActiveStorage::Blob.find_signed(sid)&.id
360
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
361
+ nil
362
+ end.to_set
363
+ end
364
+
280
365
  def description_cannot_change_if_has_origin
281
366
  if origin_id.present? && will_save_change_to_description?
282
367
  errors.add(:description, "cannot be changed directly when linked to an origin")
283
368
  end
284
369
  end
285
370
 
286
- def description_cannot_change_if_github_source
371
+ def description_cannot_change_if_read_only_source
287
372
  return unless will_save_change_to_description?
288
- return unless github_markdown?
289
- return if @skip_github_validation
373
+ return unless read_only_source?
374
+ return if skip_read_only_source_validation
290
375
 
291
- errors.add(:description, "cannot be changed directly for GitHub synced content")
376
+ errors.add(:description, I18n.t("collavre.creatives.errors.description_read_only_source"))
292
377
  end
293
378
  end
294
379
  end
@@ -7,6 +7,15 @@ module Collavre
7
7
  belongs_to :origin, class_name: "Collavre::Creative", optional: true
8
8
  has_many :linked_creatives, class_name: "Collavre::Creative", foreign_key: :origin_id, dependent: :destroy
9
9
 
10
+ # origin_id is immutable once a record is persisted. Permission
11
+ # resolution (PermissionChecker / PermissionFilter) treats a linked
12
+ # creative as its origin, and the permission cache has no invalidation
13
+ # path for a moved origin link. Promote the "origin_id is immutable"
14
+ # invariant from controller param-stripping to a model-level guard so
15
+ # no mutation path (mass-assignment, console, future controllers) can
16
+ # silently repoint a link and desync permissions.
17
+ attr_readonly :origin_id
18
+
10
19
  validate :origin_cannot_be_self
11
20
  before_validation :redirect_parent_to_origin
12
21
  end