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
@@ -32,6 +32,7 @@ module Collavre
32
32
 
33
33
  stuck_items = []
34
34
  stuck_items.concat(detect_stuck_tasks(config))
35
+ stuck_items.concat(detect_orphaned_queued_tasks(config))
35
36
  stuck_items.concat(detect_stalled_creatives(config))
36
37
 
37
38
  auto_recover_stuck_tasks(stuck_items)
@@ -47,54 +48,124 @@ module Collavre
47
48
 
48
49
  stuck_items = []
49
50
  stuck_items.concat(detect_stuck_tasks(config))
51
+ stuck_items.concat(detect_orphaned_queued_tasks(config))
50
52
  stuck_items.concat(detect_stalled_creatives(config))
51
53
  stuck_items
52
54
  end
53
55
 
54
56
  private
55
57
 
56
- # Auto-recover stuck tasks by marking them as failed and draining the queue.
58
+ # Auto-recover stuck items: fail-and-drain for live tasks, self-heal for
59
+ # orphaned queued waiters.
57
60
  def auto_recover_stuck_tasks(stuck_items)
58
61
  stuck_items.each do |stuck_item|
59
- next unless stuck_item.type == :task
62
+ case stuck_item.type
63
+ when :task then recover_stuck_task(stuck_item)
64
+ when :queued_orphan then recover_orphaned_queued_task(stuck_item)
65
+ end
66
+ end
67
+ end
60
68
 
61
- task = stuck_item.item
62
- next unless %w[running delegated].include?(task.status)
69
+ # Self-heal an orphaned queued waiter: its blocker is gone but it was never
70
+ # drained (missed dequeue / enqueue-vs-terminate TOCTOU race / lost
71
+ # cross-process broadcast). Re-check liveness atomically, then drain.
72
+ def recover_orphaned_queued_task(stuck_item)
73
+ task = stuck_item.item.reload
74
+ return unless task.status == "queued"
75
+
76
+ # If the topic is back at capacity since detection, the normal terminal
77
+ # callback will drain the queue when a slot frees — leave it alone. This
78
+ # check also bounds promotions across one detection cycle: dequeue moves
79
+ # a waiter queued -> pending synchronously, which occupying_topic_slot
80
+ # counts, so consecutive orphans each fill exactly one free slot until the
81
+ # topic is full. With topic_max_concurrent_jobs > 1 and several free slots
82
+ # all are filled this cycle; a per-topic dedupe would instead leave every
83
+ # waiter past the first orphaned until the next run.
84
+ return if topic_at_capacity?(task)
85
+
86
+ Rails.logger.info(
87
+ "[StuckDetector] Self-healing orphaned queued task #{task.id} " \
88
+ "(topic=#{task.topic_id}, creative=#{task.creative_id}): no live blocker, draining queue"
89
+ )
90
+ AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
91
+ rescue StandardError => e
92
+ Rails.logger.error("[StuckDetector] Self-heal failed for queued task #{stuck_item.item.id}: #{e.message}")
93
+ end
63
94
 
64
- task.update!(status: "failed")
65
- Rails.logger.info(
66
- "[StuckDetector] Auto-recovered task #{task.id} (agent=#{task.agent_id}): " \
67
- "marked as failed after #{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
68
- )
95
+ # Whether a topic holds no free concurrency slot for a queued waiter.
96
+ # Compares occupied slots against topic_max (the scheduler's admission rule)
97
+ # rather than treating any single live blocker as full capacity — otherwise,
98
+ # with topic_max_concurrent_jobs > 1, a missed dequeue would leave the waiter
99
+ # suppressed until the *last* blocker terminates instead of the moment a slot
100
+ # frees up. Occupancy counts pending as well as running/delegated: a waiter
101
+ # that a prior dequeue already claimed sits in "pending" until its AiAgentJob
102
+ # starts, and the detector fires precisely on the backed-up condition where
103
+ # that window is wide — counting only running/delegated would see a free slot
104
+ # and promote a second waiter into a slot that is already claimed (double
105
+ # dequeue). This is intentionally stricter than the scheduler's reactive
106
+ # check; being stricter can only suppress recovery, never over-admit. When no
107
+ # topic limit is configured the scheduler never defers, so fall back to the
108
+ # conservative "any occupied slot" check.
109
+ def topic_at_capacity?(task)
110
+ topic_max = scheduling_resolver_for(task).topic_max_concurrent_jobs
111
+ occupied_count = Task.occupying_topic_slot(task.topic_id, task.creative_id).count
112
+ return occupied_count.positive? unless topic_max
113
+
114
+ occupied_count >= topic_max
115
+ end
69
116
 
70
- # Release resources held by the stuck task
71
- if task.agent
72
- tracker = ResourceTracker.for(task.agent)
73
- tracker.release!(task.id)
74
- end
117
+ # Resolve scheduling policy against the queued task's own topic/creative
118
+ # context — the same context the scheduler used to admit it. The detector's
119
+ # default resolver is built with an empty context (it only needs the global
120
+ # stuck_detection policy), so reading topic_max_concurrent_jobs off it would
121
+ # see only the global default and ignore any topic-/creative-scoped override.
122
+ # That mismatch would violate a topic's serialization when its scoped limit
123
+ # is below the global value, or wrongly suppress recovery when it is above.
124
+ def scheduling_resolver_for(task)
125
+ context = {}
126
+ context["creative"] = { "id" => task.creative_id } if task.creative_id
127
+ context["topic"] = { "id" => task.topic_id } if task.topic_id
128
+ PolicyResolver.new(context)
129
+ end
75
130
 
76
- # If this was a workflow subtask, fail the parent so the workflow
77
- # advances instead of staying running with pending_creative_ids
78
- # pointing at a child that's been failed underneath it.
79
- if task.parent_task_id.present?
80
- begin
81
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
82
- task,
83
- error_message: "Auto-recovered: stuck for " \
84
- "#{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
85
- )
86
- rescue StandardError => e
87
- Rails.logger.error(
88
- "[StuckDetector] fail_subtask! failed for task #{task.id}: #{e.message}"
89
- )
90
- end
91
- end
131
+ # Fail a stuck running/delegated task and drain the topic queue.
132
+ def recover_stuck_task(stuck_item)
133
+ task = stuck_item.item
134
+ return unless %w[running delegated].include?(task.status)
135
+
136
+ task.update!(status: "failed")
137
+ Rails.logger.info(
138
+ "[StuckDetector] Auto-recovered task #{task.id} (agent=#{task.agent_id}): " \
139
+ "marked as failed after #{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
140
+ )
92
141
 
93
- # Drain the queue for the topic so waiting tasks can execute
94
- AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
95
- rescue StandardError => e
96
- Rails.logger.error("[StuckDetector] Auto-recovery failed for task #{task.id}: #{e.message}")
142
+ # Release resources held by the stuck task
143
+ if task.agent
144
+ tracker = ResourceTracker.for(task.agent)
145
+ tracker.release!(task.id)
97
146
  end
147
+
148
+ # If this was a workflow subtask, fail the parent so the workflow
149
+ # advances instead of staying running with pending_creative_ids
150
+ # pointing at a child that's been failed underneath it.
151
+ if task.parent_task_id.present?
152
+ begin
153
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
154
+ task,
155
+ error_message: "Auto-recovered: stuck for " \
156
+ "#{((Time.current - stuck_item.stuck_since) / 60).round} minutes"
157
+ )
158
+ rescue StandardError => e
159
+ Rails.logger.error(
160
+ "[StuckDetector] fail_subtask! failed for task #{task.id}: #{e.message}"
161
+ )
162
+ end
163
+ end
164
+
165
+ # Drain the queue for the topic so waiting tasks can execute
166
+ AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
167
+ rescue StandardError => e
168
+ Rails.logger.error("[StuckDetector] Auto-recovery failed for task #{stuck_item.item.id}: #{e.message}")
98
169
  end
99
170
 
100
171
  def stuck_detection_config
@@ -129,6 +200,38 @@ module Collavre
129
200
  end
130
201
  end
131
202
 
203
+ # Detect orphaned queued waiters: tasks left in "queued" for a topic that
204
+ # holds no occupied slot (no running/delegated blocker and no pending claim).
205
+ # A queued task's only path to
206
+ # execution is dequeue_next_for_topic, which fires when the blocker reaches
207
+ # a terminal status. If that single hand-off is missed — an
208
+ # enqueue-vs-terminate TOCTOU race, or a lost cross-process broadcast — the
209
+ # blocker is already gone and nothing will ever wake the waiter: it shows
210
+ # "⏳" waiting notice forever. These are invisible to detect_stuck_tasks, which only
211
+ # scans running/delegated. There is no stop button to press here because the
212
+ # blocker no longer exists; the fix is to drain the queue (see
213
+ # recover_orphaned_queued_task), not to escalate.
214
+ def detect_orphaned_queued_tasks(config)
215
+ threshold_minutes = config["queued_orphan_threshold_minutes"] || 5
216
+ threshold_time = threshold_minutes.minutes.ago
217
+
218
+ Task.where(status: "queued")
219
+ .where("updated_at < ?", threshold_time)
220
+ .filter_map do |task|
221
+ # Only orphaned if the topic has a free slot. A waiter is legitimately
222
+ # queued while the topic is at capacity.
223
+ next if topic_at_capacity?(task)
224
+
225
+ StuckItem.new(
226
+ type: :queued_orphan,
227
+ item: task,
228
+ reason: :orphaned_waiter,
229
+ stuck_since: task.updated_at,
230
+ escalation_targets: []
231
+ )
232
+ end
233
+ end
234
+
132
235
  # Detect creatives that have stalled (no activity for extended period)
133
236
  def detect_stalled_creatives(config)
134
237
  threshold_minutes = config["creative_stall_threshold_minutes"] || 120
@@ -216,6 +319,10 @@ module Collavre
216
319
  escalated_count = 0
217
320
 
218
321
  stuck_items.each do |stuck_item|
322
+ # Orphaned queued waiters are silently self-healed (queue drained),
323
+ # not escalated to admins — there is no human action to take.
324
+ next if stuck_item.type == :queued_orphan
325
+
219
326
  escalated = escalate_item(stuck_item, config)
220
327
  if escalated
221
328
  mark_escalated(stuck_item)
@@ -18,9 +18,10 @@ module Collavre
18
18
  end
19
19
 
20
20
  tool_param :operations, description: "Array of operation objects. Each object must have an 'action' key.\n\n" \
21
- "For 'create': { action: 'create', parent_id: <int>, description: <string>, progress: <float>, after_id: <int>, before_id: <int> }\n" \
22
- "For 'update': { action: 'update', id: <int>, description: <string>, progress: 1.0, parent_id: <int> } — progress only accepts 1.0 (complete) and only on leaf Creatives\n" \
21
+ "For 'create': { action: 'create', parent_id: <int>, description: <markdown string>, progress: <float>, after_id: <int>, before_id: <int> }\n" \
22
+ "For 'update': { action: 'update', id: <int>, description: <markdown string>, progress: 1.0, parent_id: <int> } — progress only accepts 1.0 (complete) and only on leaf Creatives\n" \
23
23
  "For 'delete': { action: 'delete', id: <int> }\n\n" \
24
+ "The 'description' field is written as Markdown (GitHub-Flavored).\n" \
24
25
  "Fields other than 'action' and 'id'/'parent_id' are optional.", required: true
25
26
 
26
27
  class BatchRollbackError < StandardError
@@ -5,12 +5,11 @@ module Tools
5
5
  class CreativeCreateService
6
6
  extend T::Sig
7
7
  extend ToolMeta
8
- include DescriptionNormalizable
9
8
 
10
9
  tool_name "creative_create_service"
11
- tool_description "Create a new Creative (task/content block) in the hierarchical structure. Creatives function like tasks in a tree structure, with automatic progress calculation.\n\nUse this to:\n- Create new tasks under a parent Creative\n- Add sub-items to organize work\n- Build hierarchical project structures\n\nNote: The description field accepts HTML format for rich text content."
10
+ tool_description "Create a new Creative (task/content block) in the hierarchical structure. Creatives function like tasks in a tree structure, with automatic progress calculation.\n\nUse this to:\n- Create new tasks under a parent Creative\n- Add sub-items to organize work\n- Build hierarchical project structures\n\nNote: The description field is written as Markdown."
12
11
 
13
- tool_param :description, description: "The content/title of the Creative. Accepts HTML format (e.g., '<p>Task title</p>'). Plain text will be wrapped in <p> tags automatically.", required: true
12
+ tool_param :description, description: "The content/title of the Creative, written in Markdown (GitHub-Flavored: headings, bold/italic, lists, links, tables, code blocks, task lists). A single newline is a line break. Plain text is stored as-is. Example: '# Title\\n\\n- item one\\n- item two'.", required: true
14
13
  tool_param :parent_id, description: "ID of the parent Creative. Required to create under a specific parent. If omitted, creates a root Creative.", required: false
15
14
  tool_param :progress, description: "Initial progress value (0.0 to 1.0). Default is 0.", required: false
16
15
  tool_param :after_id, description: "ID of a sibling Creative to insert after. Used for ordering.", required: false
@@ -32,15 +31,16 @@ module Tools
32
31
  end
33
32
  end
34
33
 
35
- # Normalize description - wrap plain text in <p> tags if needed
36
- normalized_description = normalize_description(description)
37
-
38
- # Build the creative
34
+ # Build the creative. The description is authored as Markdown: store it as
35
+ # the canonical markdown_source and let Describable#convert_markdown_to_html
36
+ # render it to the HTML description (markdown_editor defaults to the
37
+ # advanced "source" surface for tool/MCP writes).
39
38
  creative = Creative.new(
40
- description: normalized_description,
41
39
  parent: parent,
42
40
  progress: progress || 0
43
41
  )
42
+ creative.content_type_input = "markdown"
43
+ creative.markdown_source = description
44
44
 
45
45
  # Set user based on parent or current user
46
46
  creative.user = parent ? parent.user : Current.user
@@ -5,13 +5,12 @@ module Tools
5
5
  class CreativeUpdateService
6
6
  extend T::Sig
7
7
  extend ToolMeta
8
- include DescriptionNormalizable
9
8
 
10
9
  tool_name "creative_update_service"
11
10
  tool_description "Update an existing Creative's content, progress, or parent. Use this to:\n- Modify the description/title of a Creative\n- Mark a leaf Creative as complete (progress = 1.0)\n- Move a Creative to a different parent\n\nProgress constraints:\n- Only 1.0 (100%) is allowed — partial progress updates are not supported\n- Only leaf Creatives (with no children) can have their progress updated\n- Parent Creative progress is automatically calculated from children\n\nUse creative_retrieval_service to find the correct Creative before updating."
12
11
 
13
12
  tool_param :id, description: "The ID of the Creative to update.", required: true
14
- tool_param :description, description: "New content/title for the Creative. Accepts HTML format. If omitted, description remains unchanged.", required: false
13
+ tool_param :description, description: "New content/title for the Creative, written in Markdown (GitHub-Flavored: headings, bold/italic, lists, links, tables, code blocks, task lists). A single newline is a line break. Replaces the whole body. If omitted, the description remains unchanged.", required: false
15
14
  tool_param :progress, description: "Set to 1.0 to mark a leaf Creative as complete. Only 1.0 is allowed; partial progress and updates on parent Creatives are rejected.", required: false
16
15
  tool_param :parent_id, description: "New parent Creative ID to move this Creative under. If omitted, nil, or 0, the parent remains unchanged.", required: false
17
16
 
@@ -34,9 +33,22 @@ module Tools
34
33
  updates = {}
35
34
  parent_updates = {}
36
35
 
37
- # Handle description update
38
- if description.present?
39
- updates[:description] = normalize_description(description)
36
+ # Handle description update. The description is authored as Markdown: set it
37
+ # as the canonical markdown_source on the base/origin and let
38
+ # Describable#convert_markdown_to_html render the HTML description on save.
39
+ #
40
+ # Force the "source" editing surface (like create does) rather than letting
41
+ # convert_markdown_to_html preserve a prior "rich" preference: this update
42
+ # replaces the whole body with fresh tool/MCP-authored GFM, so the old
43
+ # rich-authored content the preference belonged to is gone. Tool writes can
44
+ # contain tables/raw HTML the Lexical nodes don't fully model; reopening in
45
+ # the source textarea preserves that structure, whereas a rich-editor
46
+ # round-trip could silently rewrite it.
47
+ description_provided = description.present?
48
+ if description_provided
49
+ base.content_type_input = "markdown"
50
+ base.markdown_source = description
51
+ base.markdown_editor = "source"
40
52
  end
41
53
 
42
54
  # Handle progress update
@@ -89,9 +101,12 @@ module Tools
89
101
  success &&= creative.update(parent_updates)
90
102
  end
91
103
 
92
- # Update content on the base/origin
93
- if updates.present?
94
- success &&= base.update(updates)
104
+ # Update content on the base/origin. markdown_source is set on `base`
105
+ # directly (above), so save it whenever a description was provided even if
106
+ # `updates` (progress) is empty.
107
+ if updates.present? || description_provided
108
+ base.assign_attributes(updates) if updates.present?
109
+ success &&= base.save
95
110
 
96
111
  # Note: progress updates are only allowed on leaf Creatives (validated above).
97
112
  # Parent progress is automatically calculated from children.
@@ -0,0 +1,188 @@
1
+ module Collavre
2
+ # Server-side typo correction. Sends the text the user is typing to an LLM and
3
+ # returns a validated *structured edit list* — never a rewritten string. Each
4
+ # edit is {original, suggestion, reason, confidence}. Validation rejects anything
5
+ # that is not a small, in-place spelling fix (style rewrites, hallucinated spans,
6
+ # edits inside code/URLs/mentions), so the UI can trust every returned edit.
7
+ class TypoCorrector
8
+ AGENT_EMAIL = "typo-corrector@collavre.local"
9
+
10
+ # Reject "corrections" that are really rewrites: an edit is kept only when the
11
+ # original is short and the suggestion is within this edit distance of it.
12
+ MAX_ORIGINAL_LENGTH = 40
13
+ MIN_EDIT_DISTANCE_CAP = 2
14
+ EDIT_DISTANCE_RATIO = 0.4
15
+
16
+ FALLBACK_SYSTEM_PROMPT = <<~PROMPT.freeze
17
+ You are a typo correction engine. Fix only spelling mistakes and obvious typos,
18
+ never style, grammar or meaning, in any language. `original` must be an exact
19
+ substring of the input. Return ONLY JSON:
20
+ {"edits":[{"original":"x","suggestion":"y","reason":"spelling","confidence":0.0}]}
21
+ PROMPT
22
+
23
+ def initialize(client: nil)
24
+ @client = client
25
+ end
26
+
27
+ # Returns an Array of Hashes with string keys: "original", "suggestion",
28
+ # "reason", "confidence". Empty array on blank input or LLM/parse failure.
29
+ def correct(text)
30
+ text = text.to_s
31
+ return [] if text.strip.blank?
32
+
33
+ response = client.chat([
34
+ { role: :user, parts: [ { text: "Text:\n#{text}" } ] }
35
+ ])
36
+
37
+ edits = parse_response(response)
38
+ validate(edits, text)
39
+ end
40
+
41
+ private
42
+
43
+ def client
44
+ @client ||= build_client
45
+ end
46
+
47
+ def build_client
48
+ agent = Collavre.user_class.find_by(email: AGENT_EMAIL)
49
+ AiClient.new(
50
+ vendor: agent&.llm_vendor.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_VENDOR", "gemini"),
51
+ model: agent&.llm_model.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_MODEL", "gemini-3.1-flash-lite"),
52
+ system_prompt: agent&.system_prompt.presence || FALLBACK_SYSTEM_PROMPT,
53
+ llm_api_key: agent&.llm_api_key,
54
+ gateway_url: agent&.gateway_url,
55
+ # Vendor adapters (e.g. OpenClaw) resolve the gateway/key from the context
56
+ # user, not the llm_api_key/gateway_url kwargs. Pass the agent so an
57
+ # OpenClaw-backed typo agent isn't built with user: nil (which makes the
58
+ # adapter bail with "Gateway URL not configured" and silently return no edits).
59
+ context: { user: agent },
60
+ # Runs on debounced typing of *unsubmitted* text — never persist the draft
61
+ # to ActivityLog (RubyLlmInteractionLogger writes `messages` there).
62
+ log_interactions: false
63
+ )
64
+ end
65
+
66
+ def parse_response(content)
67
+ return [] if content.blank?
68
+
69
+ cleaned = content.gsub(/^```json\s*/, "").gsub(/\s*```$/, "").strip
70
+ parsed = JSON.parse(cleaned)
71
+ edits = parsed.is_a?(Hash) ? parsed["edits"] : parsed
72
+ edits.is_a?(Array) ? edits : []
73
+ rescue JSON::ParserError => e
74
+ # Never log the raw response: it can echo the user's unsubmitted draft,
75
+ # which would leak private text to application logs despite log_interactions: false.
76
+ Rails.logger.warn("[TypoCorrector] JSON parse error: #{e.message} (#{content.to_s.length} chars)")
77
+ []
78
+ end
79
+
80
+ def validate(edits, text)
81
+ skip_ranges = skip_ranges(text)
82
+ seen = {}
83
+
84
+ edits.filter_map do |edit|
85
+ next unless edit.is_a?(Hash)
86
+
87
+ original = edit["original"].to_s
88
+ suggestion = edit["suggestion"].to_s
89
+ next if original.empty? || suggestion.empty? || original == suggestion
90
+ next if original.length > MAX_ORIGINAL_LENGTH
91
+
92
+ # `original` must appear verbatim in the text, outside any skip region
93
+ # (code/URL/mention/markup). We return the offset of that first *valid*
94
+ # occurrence so the client anchors there — otherwise the client's own
95
+ # left-to-right search would bind to an earlier occurrence sitting inside
96
+ # a protected span and edit it (a markdown-canonical/code-safety hole).
97
+ offset = first_offset_outside_skip_ranges(text, original, skip_ranges)
98
+ next if offset.nil?
99
+
100
+ # Reject big rewrites — keep only minimal in-place fixes.
101
+ cap = [ MIN_EDIT_DISTANCE_CAP, (original.length * EDIT_DISTANCE_RATIO).ceil ].max
102
+ next if levenshtein(original, suggestion) > cap
103
+
104
+ key = [ original, suggestion ]
105
+ next if seen[key]
106
+ seen[key] = true
107
+
108
+ {
109
+ "original" => original,
110
+ "suggestion" => suggestion,
111
+ "reason" => edit["reason"].presence || "spelling",
112
+ "confidence" => clamp_confidence(edit["confidence"]),
113
+ # Emit the offset in UTF-16 code units, the coordinate space JS string
114
+ # indexing uses. A Ruby character index would be off by one per astral
115
+ # character (emoji, etc.) before the typo, so the client's substring
116
+ # check at `offset` would fail and fall back to indexOf — which can bind
117
+ # to a protected occurrence the server already excluded.
118
+ "offset" => utf16_offset(text, offset)
119
+ }
120
+ end
121
+ end
122
+
123
+ def clamp_confidence(value)
124
+ return 0.5 if value.nil?
125
+
126
+ Float(value).clamp(0.0, 1.0)
127
+ rescue ArgumentError, TypeError
128
+ 0.5
129
+ end
130
+
131
+ # Character ranges that must never be edited: fenced code, inline code, URLs,
132
+ # @mentions, HTML tags, and markdown-canonical <span style> color fragments.
133
+ def skip_ranges(text)
134
+ ranges = []
135
+ patterns = [
136
+ /```.*?```/m, # fenced code blocks
137
+ /`[^`]*`/, # inline code
138
+ %r{https?://\S+}, # URLs
139
+ /@[^\s@]+/, # @mentions
140
+ # `[^<>]` (not just `[^>]`) keeps this linear: a stray run of "<<<<" can't
141
+ # make scan re-walk the tail from every "<" (CodeQL polynomial-ReDoS).
142
+ /<[^<>]+>/ # HTML / span-style markup
143
+ ]
144
+ patterns.each do |pattern|
145
+ text.scan(pattern) { ranges << Regexp.last_match.offset(0) }
146
+ end
147
+ ranges
148
+ end
149
+
150
+ # First character offset where `needle` occurs entirely outside every skip
151
+ # range, or nil if every occurrence overlaps a protected span.
152
+ def first_offset_outside_skip_ranges(text, needle, skip_ranges)
153
+ pos = 0
154
+ while (idx = text.index(needle, pos))
155
+ finish = idx + needle.length
156
+ inside = skip_ranges.any? { |(s, e)| idx < e && finish > s }
157
+ return idx unless inside
158
+
159
+ pos = idx + 1
160
+ end
161
+ nil
162
+ end
163
+
164
+ # Convert a Ruby character index into a UTF-16 code-unit offset (JS string
165
+ # coordinates). Characters outside the BMP count as two code units in JS.
166
+ def utf16_offset(text, char_index)
167
+ text[0, char_index].to_s.encode("UTF-16LE").bytesize / 2
168
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
169
+ char_index
170
+ end
171
+
172
+ # Iterative Levenshtein distance.
173
+ def levenshtein(a, b)
174
+ a = a.chars
175
+ b = b.chars
176
+ prev = (0..b.length).to_a
177
+ a.each_with_index do |ac, i|
178
+ curr = [ i + 1 ]
179
+ b.each_with_index do |bc, j|
180
+ cost = ac == bc ? 0 : 1
181
+ curr << [ curr[j] + 1, prev[j + 1] + 1, prev[j] + cost ].min
182
+ end
183
+ prev = curr
184
+ end
185
+ prev.last
186
+ end
187
+ end
188
+ end
@@ -49,6 +49,11 @@
49
49
  <button class="comment-stop-btn" type="button" data-action="click->comment#cancelTask" data-task-id="<%= comment.task_id %>" title="<%= t('collavre.comments.stop_agent') %>">
50
50
  <span class="agent-stop-icon">&#x25A0;</span> <%= t('collavre.comments.stop_agent') %>
51
51
  </button>
52
+ <% elsif comment.waiting_notice? && (blocking_task = comment.topic_blocking_task) %>
53
+ <%# Stop the in-progress blocker so the deferred waiter can proceed (stuck recovery). %>
54
+ <button class="comment-stop-btn" type="button" data-action="click->comment#cancelTask" data-task-id="<%= blocking_task.id %>" title="<%= t('collavre.comments.stop_blocking_agent') %>">
55
+ <span class="agent-stop-icon">&#x25A0;</span> <%= t('collavre.comments.stop_blocking_agent') %>
56
+ </button>
52
57
  <% end %>
53
58
  <button class="add-reaction-btn" type="button" data-action="click->comment#triggerReactionPicker" title="<%= t('collavre.comments.add_reaction') %>">
54
59
  <span class="grayscale-emoji">☺</span>
@@ -61,7 +61,20 @@
61
61
  data-remove-from-history-label="<%= t('collavre.comments.remove_from_history') %>"
62
62
  data-inbox-reply-button="<%= t('collavre.comments.inbox_reply_button') %>"
63
63
  data-table-download-csv-text="<%= t('collavre.comments.table_download.csv') %>"
64
- data-table-download-excel-text="<%= t('collavre.comments.table_download.excel') %>">
64
+ data-table-download-excel-text="<%= t('collavre.comments.table_download.excel') %>"
65
+ <% if Current.user %>
66
+ data-typo-enabled="<%= Current.user.typo_correction_enabled %>"
67
+ data-typo-threshold="<%= Current.user.typo_correction_threshold %>"
68
+ data-typo-on-voice="<%= Current.user.typo_correction_on_voice %>"
69
+ data-typo-on-soft-keyboard="<%= Current.user.typo_correction_on_soft_keyboard %>"
70
+ data-typo-on-physical-keyboard="<%= Current.user.typo_correction_on_physical_keyboard %>"
71
+ data-typo-in-chat="<%= Current.user.typo_correction_in_chat %>"
72
+ data-typo-in-editor="<%= Current.user.typo_correction_in_editor %>"
73
+ data-typo-endpoint="<%= collavre.typo_corrections_path %>"
74
+ data-typo-keep-label="<%= t('collavre.comments.typo_keep_label') %>"
75
+ data-typo-custom-label="<%= t('collavre.comments.typo_custom_label') %>"
76
+ data-typo-input-label="<%= t('collavre.comments.typo_input_label') %>"
77
+ <% end %>>
65
78
  <div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
66
79
  <div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
67
80
  <div class="comments-popup-header" data-comments--popup-target="header">
@@ -4,6 +4,7 @@
4
4
  <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" />
5
5
  <input type="hidden" id="inline-creative-description" name="creative[description]" />
6
6
  <input type="hidden" id="inline-content-type" name="creative[content_type_input]" value="html" />
7
+ <input type="hidden" id="inline-markdown-editor" name="creative[markdown_editor]" />
7
8
  <input type="hidden" id="inline-markdown-source" name="creative[markdown_source]" />
8
9
  <input type="hidden" id="inline-parent-id" name="creative[parent_id]" />
9
10
  <input type="hidden" id="inline-before-id" name="before_id" />
@@ -0,0 +1,42 @@
1
+ <%#
2
+ Popup shown after a topic is moved to another creative, offering to re-add
3
+ members who had access on the source creative but not on the target.
4
+ Rendered hidden; populated and shown by topic_move_members_popup.js using the
5
+ `missing_members` payload returned from TopicsController#move.
6
+ %>
7
+ <div id="topic-move-members-modal"
8
+ style="display: none; position: fixed; inset: 0; z-index: 10001; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3);"
9
+ data-shares-url-template="<%= collavre.creative_creative_shares_path('__CREATIVE_ID__') %>"
10
+ data-adding-text="<%= t('collavre.topics.move.add_members.adding') %>"
11
+ data-added-one="<%= t('collavre.topics.move.add_members.added.one') %>"
12
+ data-added-other="<%= t('collavre.topics.move.add_members.added.other', count: '__COUNT__') %>"
13
+ data-partial-text="<%= t('collavre.topics.move.add_members.partial', added: '__ADDED__', failed: '__FAILED__') %>"
14
+ data-failed-text="<%= t('collavre.topics.move.add_members.failed') %>"
15
+ data-description-template="<%= t('collavre.topics.move.add_members.description', creative: '__CREATIVE_NAME__') %>">
16
+ <div class="popup-box" style="max-width: 460px; width: 90%;">
17
+ <button type="button" id="topic-move-members-close" class="popup-close" aria-label="Close" style="position: absolute; top: 12px; right: 12px;">&times;</button>
18
+ <h2><%= t('collavre.topics.move.add_members.title') %></h2>
19
+ <p id="topic-move-members-description" style="opacity: 0.75; margin: 0.5em 0 1em;"></p>
20
+ <div id="topic-move-members-message" aria-live="polite"></div>
21
+ <ul id="topic-move-members-list" class="share-grid"></ul>
22
+ <div style="display: flex; gap: 0.5em; justify-content: flex-end; margin-top: 1em;">
23
+ <button type="button" id="topic-move-members-skip" class="btn btn-secondary"><%= t('collavre.topics.move.add_members.skip') %></button>
24
+ <button type="button" id="topic-move-members-add" class="btn btn-primary"><%= t('collavre.topics.move.add_members.add') %></button>
25
+ </div>
26
+ </div>
27
+
28
+ <template id="topic-move-member-row">
29
+ <li class="topic-move-member-row" style="display: flex; align-items: center; gap: 0.5em;">
30
+ <input type="checkbox" class="tmm-checkbox" checked>
31
+ <span class="avatar share-avatar tmm-avatar" style="display: inline-block; width: 20px; height: 20px; border-radius: 50%; background: var(--surface-3, #ddd); text-align: center; line-height: 20px; font-size: 10px; overflow: hidden;"></span>
32
+ <span class="tmm-name"></span>
33
+ <span class="tmm-email" style="opacity: 0.5; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
34
+ <select class="org-chart-permission-select share-modal-permission-select tmm-permission">
35
+ <% %w[admin write feedback read].each do |perm| %>
36
+ <option value="<%= perm %>"><%= t("collavre.creatives.index.permission_#{perm}") %></option>
37
+ <% end %>
38
+ </select>
39
+ <span class="tmm-status" style="width: 16px; text-align: center;"></span>
40
+ </li>
41
+ </template>
42
+ </div>
@@ -104,6 +104,17 @@
104
104
  <div data-share-modal-target="container"></div>
105
105
  <% end %>
106
106
 
107
+ <%# The "re-add dropped members" modal must be present wherever a topic can be
108
+ dropped onto another creative — including the top-level index, where
109
+ @parent_creative/@creative are nil (moving a topic between root-level
110
+ creatives). Gating it on the write check above hid it exactly there, so
111
+ getModal() returned null and the popup silently never appeared. It stays
112
+ hidden until populated, and share creation is permission-checked
113
+ server-side, so rendering it for any authenticated user is safe. %>
114
+ <% if authenticated? %>
115
+ <%= render 'topic_move_members_modal' %>
116
+ <% end %>
117
+
107
118
  <%= render 'import_upload_zone' %>
108
119
 
109
120
  <% if can_manage_integrations %>
@@ -159,6 +170,7 @@
159
170
  title_effective_origin = @parent_creative.effective_origin(Set.new)
160
171
  title_content_type = title_effective_origin.data&.dig("content_type")
161
172
  title_markdown_source = title_can_write ? title_effective_origin.data&.dig("markdown_source") : nil
173
+ title_markdown_editor = title_effective_origin.data&.dig("editor")
162
174
  %>
163
175
  <%= content_tag(
164
176
  "creative-tree-row",
@@ -172,7 +184,8 @@
172
184
  "data-progress-value": @parent_creative.progress,
173
185
  "data-origin-id": @parent_creative.origin_id,
174
186
  "data-content-type": title_content_type,
175
- "data-markdown-source": title_markdown_source
187
+ "data-markdown-source": title_markdown_source,
188
+ "data-markdown-editor": title_markdown_editor
176
189
  ) %>
177
190
  <% else %>
178
191
  <%= content_tag(