collavre 0.22.0 → 0.23.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -10,6 +10,11 @@ module Creatives
10
10
  # than its entire match set.
11
11
  PERMISSION_FILTER_BATCH = 200
12
12
 
13
+ # Page size for the top-nav "Chats" feed (the comment filter). The list is
14
+ # unbounded by nature (it grows with every commented creative), so it is
15
+ # paginated instead of materialized whole.
16
+ COMMENT_CHATS_PER_PAGE = 30
17
+
13
18
  Result = Struct.new(
14
19
  :creatives,
15
20
  :parent_creative,
@@ -18,6 +23,7 @@ module Creatives
18
23
  :overall_progress,
19
24
  :allowed_creative_ids,
20
25
  :progress_map,
26
+ :pagination,
21
27
  keyword_init: true
22
28
  )
23
29
 
@@ -38,7 +44,8 @@ module Creatives
38
44
  shared_list: shared_list,
39
45
  overall_progress: result[:overall_progress] || 0,
40
46
  allowed_creative_ids: result[:allowed_ids],
41
- progress_map: result[:progress_map]
47
+ progress_map: result[:progress_map],
48
+ pagination: result[:pagination]
42
49
  )
43
50
  end
44
51
 
@@ -83,29 +90,46 @@ module Creatives
83
90
 
84
91
  result = pipeline.call
85
92
 
86
- return empty_result if result.matched_ids.empty?
93
+ # The comment ("Chats") path still returns a pagination object on an empty
94
+ # match so the client gets consistent metadata; other filters short-circuit.
95
+ return empty_result if result.matched_ids.empty? && params[:comment] != "true"
87
96
 
88
97
  # For search/comment filters, return matched items directly (flat results sorted by relevance)
89
98
  # Unless search_mode=tree is specified, which returns tree structure instead
90
99
  # For other filters (tags, progress), return tree start nodes
91
100
  if (params[:search].present? || params[:comment] == "true") && params[:search_mode] != "tree"
92
- matched_creatives = Creative.where(id: result.matched_ids.to_a)
93
- .order(:sequence)
94
- .select { |c| readable?(c) }
101
+ parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
95
102
 
96
- # Sort by comment updated_at for comment filter
97
103
  if params[:comment] == "true"
98
- matched_creatives = matched_creatives.sort_by { |c| c.comments.maximum(:updated_at) || c.updated_at }.reverse
104
+ # Top-nav "Chats" list: one page of commented creatives, newest chat
105
+ # first. The matched set is permission-filtered in a single batch and
106
+ # the recency ordering is computed by the DB (LIMIT/OFFSET over a
107
+ # grouped MAX(comments.updated_at)), so response cost is a fixed page
108
+ # regardless of how many chats exist. This replaces an unbounded load
109
+ # that ran a per-row readable? query and a per-row MAX(updated_at)
110
+ # aggregate (two N+1s that grew with the chat count).
111
+ page = comment_chats_page(result.matched_ids)
112
+ {
113
+ creatives: page[:creatives],
114
+ parent: parent,
115
+ allowed_ids: result.allowed_ids,
116
+ overall_progress: result.overall_progress,
117
+ progress_map: result.progress_map,
118
+ pagination: page[:pagination]
119
+ }
120
+ else
121
+ matched_creatives = Creative.where(id: result.matched_ids.to_a)
122
+ .order(:sequence)
123
+ .select { |c| readable?(c) }
124
+
125
+ {
126
+ creatives: matched_creatives,
127
+ parent: parent,
128
+ allowed_ids: result.allowed_ids,
129
+ overall_progress: result.overall_progress,
130
+ progress_map: result.progress_map
131
+ }
99
132
  end
100
-
101
- parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
102
- {
103
- creatives: matched_creatives,
104
- parent: parent,
105
- allowed_ids: result.allowed_ids,
106
- overall_progress: result.overall_progress,
107
- progress_map: result.progress_map
108
- }
109
133
  else
110
134
  start_nodes = determine_start_nodes(result.allowed_ids)
111
135
  parent = params[:id] ? Creative.find_by(id: params[:id]) : nil
@@ -119,6 +143,51 @@ module Creatives
119
143
  end
120
144
  end
121
145
 
146
+ # One page of the "Chats" feed from the comment-matched id set, ordered by
147
+ # most-recent comment. Permission filtering is batched (PermissionFilter does
148
+ # a handful of IN queries, not one per row) and the recency sort + windowing
149
+ # happen in SQL, so neither cost scales with the total number of chats.
150
+ def comment_chats_page(matched_ids)
151
+ per_page = comment_chats_per_page
152
+ page = comment_chats_page_number
153
+ offset = (page - 1) * per_page
154
+
155
+ readable_ids = PermissionFilter.new(user: user).readable_ids(matched_ids.to_a)
156
+ return { creatives: [], pagination: pagination_meta(page, false) } if readable_ids.empty?
157
+
158
+ # Fetch one extra row to detect whether a further page exists. id is the
159
+ # tiebreaker so the order is total and stable across page boundaries.
160
+ page_ids = Creative.where(id: readable_ids)
161
+ .joins(:comments)
162
+ .group("creatives.id")
163
+ .order(Arel.sql("MAX(comments.updated_at) DESC, creatives.id DESC"))
164
+ .offset(offset)
165
+ .limit(per_page + 1)
166
+ .pluck("creatives.id")
167
+
168
+ has_more = page_ids.size > per_page
169
+ page_ids = page_ids.first(per_page)
170
+
171
+ by_id = Creative.where(id: page_ids).index_by(&:id)
172
+ creatives = page_ids.filter_map { |id| by_id[id] }
173
+
174
+ { creatives: creatives, pagination: pagination_meta(page, has_more) }
175
+ end
176
+
177
+ def comment_chats_per_page
178
+ per = params[:per_page].presence&.to_i || COMMENT_CHATS_PER_PAGE
179
+ per <= 0 || per > 100 ? COMMENT_CHATS_PER_PAGE : per
180
+ end
181
+
182
+ def comment_chats_page_number
183
+ page = params[:page].presence&.to_i || 1
184
+ page < 1 ? 1 : page
185
+ end
186
+
187
+ def pagination_meta(page, has_more)
188
+ { page: page, next_page: has_more ? page + 1 : nil, has_more: has_more }
189
+ end
190
+
122
191
  def simple_search?
123
192
  params[:search].present? && params[:simple].present? &&
124
193
  params[:search_mode] != "tree" && params[:comment] != "true"
@@ -195,7 +195,8 @@ module Creatives
195
195
  progress: creative.progress,
196
196
  origin_id: creative.origin_id,
197
197
  content_type: effective.data&.dig("content_type"),
198
- markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil
198
+ markdown_source: origin_writable ? effective.data&.dig("markdown_source") : nil,
199
+ markdown_editor: effective.data&.dig("editor")
199
200
  }
200
201
  end
201
202
 
@@ -55,7 +55,7 @@ module Collavre
55
55
  def default_client
56
56
  AiClient.new(
57
57
  vendor: "google",
58
- model: "gemini-3-flash-preview",
58
+ model: "gemini-3.1-flash-lite",
59
59
  system_prompt: nil
60
60
  )
61
61
  end
@@ -64,6 +64,11 @@ module Collavre
64
64
  content: @comment.content,
65
65
  user: @comment.user,
66
66
  quoted_comment: original,
67
+ # quoted_comment is set only for linkage to the message being answered.
68
+ # Mark it as a "question" so review_message? stays false — otherwise the
69
+ # agent's response would update the quoted comment in place (the review
70
+ # flow) instead of posting a new reply. See Comment#review_message?.
71
+ review_type: :question,
67
72
  private: false
68
73
  )
69
74
 
@@ -48,12 +48,22 @@ module Collavre
48
48
  source
49
49
  end
50
50
 
51
- # Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter)
51
+ # Render with commonmarker (GFM extensions: table, strikethrough, autolink, tasklist, tagfilter).
52
+ # hardbreaks: a single newline becomes <br>, mirroring the JS renderer (marked breaks:true)
53
+ # and the canonical markdown_source, which stores consecutive rich-editor lines one-per-line
54
+ # rather than separated by a blank line.
55
+ #
56
+ # syntax_highlighter: nil disables comrak's built-in syntect highlighter, which otherwise
57
+ # bakes a fixed base16-ocean.dark theme into the stored HTML as inline `style=` attributes
58
+ # (e.g. `<pre style="background-color:#2b303b"><span style="color:#bf616a">`). Those inline
59
+ # styles override our theme-aware code_highlight.css palette and are blind to light/dark mode.
60
+ # Disabling it emits a plain `<pre lang="ruby"><code>…</code></pre>`; the client re-tokenizes
61
+ # with highlight.js so the rendered creative matches the editor (and respects the theme).
52
62
  html = Commonmarker.to_html(input, options: {
53
63
  parse: { smart: true },
54
- render: { unsafe: true },
64
+ render: { unsafe: true, hardbreaks: true },
55
65
  extension: { table: true, strikethrough: true, autolink: true, tasklist: true, tagfilter: true }
56
- })
66
+ }, plugins: { syntax_highlighter: nil })
57
67
 
58
68
  html.strip!
59
69
  html
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Mobile
5
+ # Turns an approval/permission comment or an agent reply into a SHORT,
6
+ # decision-oriented line of speech. Deterministic (no LLM) so the hot path
7
+ # stays fast: it extracts the tool name / human description from the
8
+ # permission action payload and renders a localized, spoken-friendly summary.
9
+ class EventSummarizer
10
+ def initialize(locale:)
11
+ @locale = (locale.presence || I18n.default_locale).to_s
12
+ end
13
+
14
+ def approval_summary(comment:, label:)
15
+ payload = parse_action(comment)
16
+ I18n.with_locale(@locale) do
17
+ detail =
18
+ payload&.dig("description").presence ||
19
+ payload&.dig("tool_name").presence ||
20
+ I18n.t("collavre.mobile.summary.a_tool")
21
+ I18n.t("collavre.mobile.summary.approval", label: label, detail: flatten(detail))
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def flatten(text)
28
+ text.to_s.gsub(/\s+/, " ").strip
29
+ end
30
+
31
+ def parse_action(comment)
32
+ return nil if comment.action.blank?
33
+
34
+ JSON.parse(comment.action)
35
+ rescue JSON::ParserError
36
+ nil
37
+ end
38
+ end
39
+ end
40
+ end
@@ -45,7 +45,12 @@ module Collavre
45
45
 
46
46
  Comment.where(creative_id: creative_id, topic_id: topic_id, user_id: nil)
47
47
  .where("content LIKE ?", "⏳%")
48
- .destroy_all
48
+ .find_each do |notice|
49
+ # System promotion, not user abandonment: do not let the destroy
50
+ # callback cancel other still-queued waiters in this topic.
51
+ notice.suppress_waiter_cancellation = true
52
+ notice.destroy
53
+ end
49
54
  end
50
55
  private_class_method :cleanup_waiting_notices!
51
56
 
@@ -184,17 +189,38 @@ module Collavre
184
189
  creative = Creative.find_by(id: creative_id)
185
190
  return unless creative
186
191
 
187
- reason_key = decision[:reason] || :unknown
188
- reason_text = I18n.t(
189
- "collavre.orchestration.waiting_reasons.#{reason_key}",
190
- default: reason_key.to_s.humanize
191
- )
192
+ reason_text = waiting_reason_text(decision[:reason] || :unknown, topic_id, creative_id)
192
193
 
193
194
  creative.comments.create!(
194
195
  content: I18n.t("collavre.orchestration.waiting_notice", reason: reason_text),
195
196
  topic_id: topic_id,
196
197
  private: false,
197
- skip_default_user: true
198
+ skip_default_user: true,
199
+ # Only :deferred queues a topic waiter; mark it so its stop button can
200
+ # target the blocker. :delayed (busy / rate_limited) notices stay false.
201
+ topic_concurrency_defer: decision[:timing] == :deferred
202
+ )
203
+ end
204
+
205
+ # Human-readable reason for the "⏳" waiting notice. For topic-concurrency
206
+ # deferrals, name the agent(s) actually holding the topic's running slot so
207
+ # a waiting user can see *who* is blocking them (and reach that task's stop
208
+ # button) rather than an anonymous "another task is running" dead end.
209
+ def waiting_reason_text(reason_key, topic_id, creative_id)
210
+ if reason_key == :topic_concurrency && topic_id
211
+ names = Task.running_for_topic(topic_id, creative_id)
212
+ .includes(:agent).filter_map { |t| t.agent&.name }.uniq
213
+ if names.any?
214
+ return I18n.t(
215
+ "collavre.orchestration.waiting_reasons.topic_concurrency_with_agent",
216
+ agent: names.join(", ")
217
+ )
218
+ end
219
+ end
220
+
221
+ I18n.t(
222
+ "collavre.orchestration.waiting_reasons.#{reason_key}",
223
+ default: reason_key.to_s.humanize
198
224
  )
199
225
  end
200
226
  end
@@ -25,6 +25,13 @@ module Collavre
25
25
  def select(candidates)
26
26
  return [] if candidates.empty?
27
27
 
28
+ # Review feedback is forced routing: the Matcher already restricts candidates
29
+ # to the quoted comment's author, the sole agent ReviewHandler will accept.
30
+ # Floor-control arbitration (bid scoring, round_robin) among a forced single
31
+ # recipient is meaningless, and a low bid score must not drop it (e.g. bid
32
+ # strategy with bid_fallback_enabled: false) or the Review button no-ops.
33
+ return candidates if review_message?
34
+
28
35
  strategy = @policy_resolver.arbitration_strategy
29
36
  selected = apply_strategy(strategy, candidates)
30
37
 
@@ -37,6 +44,15 @@ module Collavre
37
44
 
38
45
  private
39
46
 
47
+ # True when the triggering comment is review feedback (mirrors Matcher's
48
+ # review-author routing), so arbitration can be bypassed for forced routing.
49
+ def review_message?
50
+ comment_id = @context.dig("comment", "id") || @context.dig(:comment, :id)
51
+ return false unless comment_id
52
+
53
+ Comment.find_by(id: comment_id)&.review_message? || false
54
+ end
55
+
40
56
  def apply_strategy(strategy, candidates)
41
57
  case strategy
42
58
  when "all"
@@ -21,6 +21,10 @@ module Collavre
21
21
 
22
22
  # Returns Array of User (AI agents) that are qualified to respond
23
23
  def match
24
+ # Priority 0: Review routing (exclusive)
25
+ review_result = match_by_review_author
26
+ return review_result unless review_result.nil?
27
+
24
28
  # Priority 1: Mention-based routing (exclusive)
25
29
  mentioned_result = match_by_mention
26
30
  return mentioned_result unless mentioned_result.nil?
@@ -31,6 +35,39 @@ module Collavre
31
35
 
32
36
  private
33
37
 
38
+ # Returns [author] for a routable review message, [] for an unroutable one,
39
+ # nil when this is not a review message.
40
+ #
41
+ # A review message can ONLY be handled by the author of the quoted comment:
42
+ # ReviewHandler#eligible? requires quoted_comment.user_id == agent.id, so any
43
+ # other agent would post a stray reply instead of the in-place review update.
44
+ # Route exclusively to that author — independent of mention or
45
+ # routing_expression — so the Review button is reliable even when the author
46
+ # has none (e.g. /compress summaries authored by a primary agent resolved via
47
+ # primary_agent_id). Without this the button renders but the feedback no-ops.
48
+ #
49
+ # Once this IS a review message, the only safe outcomes are exclusive-route or
50
+ # block ([]) — never nil. ResponseFinalizer keys on review_message? regardless
51
+ # of which agent the matcher picks, so a fall-through to mention/expression
52
+ # routing would schedule an agent that ReviewHandler#handle then bails on,
53
+ # producing the very stray reply this routing exists to prevent.
54
+ def match_by_review_author
55
+ return nil unless matched_comment&.review_message?
56
+
57
+ author = matched_comment.quoted_comment&.user
58
+ return [] unless author&.ai_user?
59
+
60
+ # Mirror ReviewHandler#eligible?: if the handler would reject this quote
61
+ # (private, or from another topic/creative), no agent can handle the review.
62
+ # Block rather than fall through, so it can't become a stray normal reply.
63
+ return [] unless Collavre::AiAgent::ReviewHandler.eligible?(matched_comment, author)
64
+
65
+ return [] unless has_creative_permission?(author)
66
+ return [] unless eligible_in_inbox?(author)
67
+
68
+ [ author ]
69
+ end
70
+
34
71
  # Returns Array of agents if mention found, nil if no mention
35
72
  # When mention IS found, this is exclusive routing
36
73
  def match_by_mention
@@ -47,6 +84,11 @@ module Collavre
47
84
  # Permission check for mentioned AI agent
48
85
  return [] unless has_creative_permission?(mentioned_user)
49
86
 
87
+ # Inbox confinement applies to mentions too: a live Claude Channel
88
+ # session agent must not be pulled into an ordinary inbox topic, even by
89
+ # an explicit @mention (see #eligible_in_inbox?).
90
+ return [] unless eligible_in_inbox?(mentioned_user)
91
+
50
92
  [ mentioned_user ]
51
93
  end
52
94
 
@@ -57,11 +99,26 @@ module Collavre
57
99
 
58
100
  agents.select do |agent|
59
101
  next false unless has_creative_permission?(agent)
102
+ next false unless eligible_in_inbox?(agent)
60
103
 
61
104
  evaluate_routing_expression(agent)
62
105
  end
63
106
  end
64
107
 
108
+ # A Claude Channel session agent holds inbox-wide :feedback +
109
+ # routing_expression="true", so within the user's Inbox it would otherwise
110
+ # match EVERY topic. Confine it to its own registered session topic (the
111
+ # topic it is primary_agent on, carrying a session_id) so ordinary inbox
112
+ # topics — Main, Content, user threads — stay identical to a normal topic
113
+ # and are never absorbed by a live session. Only the inbox is affected: on
114
+ # work/project creatives the agent still matches via routing_expression.
115
+ def eligible_in_inbox?(agent)
116
+ return true unless matched_creative&.inbox?
117
+ return true unless agent.claude_channel_agent?
118
+
119
+ matched_topic&.session_id.present? && matched_topic.primary_agent_id == agent.id
120
+ end
121
+
65
122
  def evaluate_routing_expression(agent)
66
123
  expression = agent.routing_expression.strip
67
124
 
@@ -85,14 +142,32 @@ module Collavre
85
142
  def has_creative_permission?(agent)
86
143
  # All agents need feedback permission on the creative to respond
87
144
  # searchable only affects discoverability, not response permission
88
- creative_id = @context.dig("creative", "id") || @context.dig(:creative, :id)
89
- return false unless creative_id
90
-
91
- creative = Creative.find_by(id: creative_id)
145
+ creative = matched_creative
92
146
  return false unless creative
93
147
 
94
148
  creative.has_permission?(agent, :feedback)
95
149
  end
150
+
151
+ def matched_creative
152
+ return @matched_creative if defined?(@matched_creative)
153
+
154
+ creative_id = @context.dig("creative", "id") || @context.dig(:creative, :id)
155
+ @matched_creative = creative_id && Creative.find_by(id: creative_id)
156
+ end
157
+
158
+ def matched_topic
159
+ return @matched_topic if defined?(@matched_topic)
160
+
161
+ topic_id = @context.dig("topic", "id") || @context.dig(:topic, :id)
162
+ @matched_topic = topic_id && Topic.find_by(id: topic_id)
163
+ end
164
+
165
+ def matched_comment
166
+ return @matched_comment if defined?(@matched_comment)
167
+
168
+ comment_id = @context.dig("comment", "id") || @context.dig(:comment, :id)
169
+ @matched_comment = comment_id && Comment.find_by(id: comment_id)
170
+ end
96
171
  end
97
172
  end
98
173
  end
@@ -39,9 +39,10 @@ module Collavre
39
39
  },
40
40
  "stuck_detection" => {
41
41
  "enabled" => false,
42
- "task_stuck_threshold_minutes" => 30, # Task running for > N minutes
43
- "creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
44
- "create_system_comment" => true # Create system comment on escalation
42
+ "task_stuck_threshold_minutes" => 30, # Task running for > N minutes
43
+ "creative_stall_threshold_minutes" => 120, # Creative no progress for > N minutes
44
+ "queued_orphan_threshold_minutes" => 5, # Queued waiter with no live blocker for > N minutes
45
+ "create_system_comment" => true # Create system comment on escalation
45
46
  },
46
47
  "collaboration" => {
47
48
  "a2a_focus_instruction" => nil, # nil = locale default