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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- 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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
#
|
|
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.
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
94
|
-
|
|
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">■</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">■</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;">×</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(
|