collavre 0.1.1 → 0.2.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +293 -8
  3. data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
  4. data/app/assets/stylesheets/collavre/popup.css +7 -0
  5. data/app/assets/stylesheets/collavre/print.css +18 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +33 -0
  7. data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
  8. data/app/components/collavre/autocomplete_popup_component.rb +18 -0
  9. data/app/components/collavre/command_menu_component.rb +7 -0
  10. data/app/components/collavre/plans_timeline_component.html.erb +1 -1
  11. data/app/components/collavre/plans_timeline_component.rb +29 -32
  12. data/app/components/collavre/user_mention_menu_component.rb +4 -5
  13. data/app/controllers/collavre/comments_controller.rb +111 -10
  14. data/app/controllers/collavre/creatives_controller.rb +8 -0
  15. data/app/controllers/collavre/google_auth_controller.rb +5 -1
  16. data/app/controllers/collavre/plans_controller.rb +65 -9
  17. data/app/controllers/collavre/topics_controller.rb +42 -0
  18. data/app/controllers/collavre/users_controller.rb +4 -14
  19. data/app/errors/collavre/approval_pending_error.rb +54 -0
  20. data/app/errors/collavre/cancelled_error.rb +9 -0
  21. data/app/helpers/collavre/navigation_helper.rb +3 -1
  22. data/app/javascript/collavre.js +1 -0
  23. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
  24. data/app/javascript/controllers/comments/form_controller.js +2 -1
  25. data/app/javascript/controllers/comments/list_controller.js +185 -2
  26. data/app/javascript/controllers/comments/popup_controller.js +95 -20
  27. data/app/javascript/controllers/comments/presence_controller.js +30 -1
  28. data/app/javascript/controllers/comments/topics_controller.js +314 -4
  29. data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
  30. data/app/javascript/modules/command_menu.js +116 -0
  31. data/app/javascript/modules/creative_progress.js +14 -0
  32. data/app/javascript/modules/creative_row_editor.js +104 -20
  33. data/app/javascript/modules/plans_timeline.js +15 -4
  34. data/app/javascript/modules/share_modal.js +3 -0
  35. data/app/jobs/collavre/ai_agent_job.rb +35 -21
  36. data/app/models/collavre/calendar_event.rb +7 -1
  37. data/app/models/collavre/comment.rb +35 -2
  38. data/app/models/collavre/creative.rb +1 -3
  39. data/app/models/collavre/mcp_tool.rb +4 -0
  40. data/app/models/collavre/plan.rb +23 -0
  41. data/app/models/collavre/topic.rb +12 -0
  42. data/app/models/collavre/user.rb +15 -1
  43. data/app/services/collavre/ai_agent_service.rb +174 -66
  44. data/app/services/collavre/ai_client.rb +31 -2
  45. data/app/services/collavre/comments/action_executor.rb +47 -1
  46. data/app/services/collavre/comments/calendar_command.rb +117 -18
  47. data/app/services/collavre/google_calendar_service.rb +38 -15
  48. data/app/services/collavre/markdown_importer.rb +47 -8
  49. data/app/services/collavre/mcp_service.rb +23 -10
  50. data/app/services/collavre/system_events/router.rb +50 -26
  51. data/app/services/collavre/tools/creative_create_service.rb +97 -0
  52. data/app/services/collavre/tools/creative_update_service.rb +116 -0
  53. data/app/views/collavre/comments/_comment.html.erb +2 -2
  54. data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
  55. data/app/views/collavre/comments/fullscreen.html.erb +5 -0
  56. data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
  57. data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
  58. data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
  59. data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
  60. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
  61. data/app/views/collavre/creatives/_share_button.html.erb +1 -1
  62. data/app/views/collavre/creatives/index.html.erb +22 -4
  63. data/app/views/collavre/users/edit_ai.html.erb +15 -0
  64. data/app/views/collavre/users/new_ai.html.erb +15 -0
  65. data/app/views/layouts/collavre/chat.html.erb +46 -0
  66. data/config/locales/ai_agent.en.yml +15 -0
  67. data/config/locales/ai_agent.ko.yml +15 -0
  68. data/config/locales/comments.en.yml +15 -3
  69. data/config/locales/comments.ko.yml +15 -3
  70. data/config/locales/creatives.en.yml +3 -31
  71. data/config/locales/creatives.ko.yml +3 -27
  72. data/config/locales/plans.en.yml +4 -0
  73. data/config/locales/plans.ko.yml +4 -0
  74. data/config/locales/users.en.yml +3 -0
  75. data/config/locales/users.ko.yml +3 -0
  76. data/config/routes.rb +8 -3
  77. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
  78. data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
  79. data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
  80. data/lib/collavre/engine.rb +171 -6
  81. data/lib/collavre/integration_registry.rb +129 -0
  82. data/lib/collavre/version.rb +1 -1
  83. data/lib/collavre.rb +2 -0
  84. data/lib/navigation/registry.rb +130 -0
  85. metadata +22 -15
  86. data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
  87. data/app/controllers/collavre/notion_auth_controller.rb +0 -25
  88. data/app/jobs/collavre/notion_export_job.rb +0 -30
  89. data/app/jobs/collavre/notion_sync_job.rb +0 -48
  90. data/app/models/collavre/notion_account.rb +0 -17
  91. data/app/models/collavre/notion_block_link.rb +0 -10
  92. data/app/models/collavre/notion_page_link.rb +0 -19
  93. data/app/services/collavre/notion_client.rb +0 -231
  94. data/app/services/collavre/notion_creative_exporter.rb +0 -296
  95. data/app/services/collavre/notion_service.rb +0 -249
  96. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
  97. data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
  98. data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
  99. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +0 -5
@@ -2,6 +2,8 @@ module Collavre
2
2
  require "google/apis/calendar_v3"
3
3
  require "googleauth"
4
4
 
5
+ class GoogleCalendarError < StandardError; end
6
+
5
7
  class GoogleCalendarService
6
8
  def initialize(user:)
7
9
  @user = user
@@ -13,7 +15,7 @@ module Collavre
13
15
  # Creates a Google Calendar event.
14
16
  # Optional params supported: location, recurrence (array), attendees (array of emails or attendee hashes),
15
17
  # reminders (hash: { use_default: true/false, overrides: [{ method: 'email'|'popup', minutes: Integer }, ...] })
16
- def create_event(
18
+ def create_google_event(
17
19
  calendar_id: "primary",
18
20
  start_time:,
19
21
  end_time:,
@@ -24,8 +26,7 @@ module Collavre
24
26
  recurrence: nil,
25
27
  attendees: nil,
26
28
  reminders: nil,
27
- all_day: false,
28
- creative: nil
29
+ all_day: false
29
30
  )
30
31
  timezone ||= @user.timezone || Time.zone.tzinfo.name
31
32
  event_args = { summary: summary, description: description }
@@ -74,17 +75,7 @@ module Collavre
74
75
  @user.calendar_id ||= create_app_calendar
75
76
  calendar_id = @user.calendar_id
76
77
  end
77
- result = @service.insert_event(calendar_id, event)
78
- CalendarEvent.create!(
79
- user: @user,
80
- creative: creative,
81
- google_event_id: result.id,
82
- summary: result.summary,
83
- start_time: result.start.date_time || result.start.date,
84
- end_time: result.end.date_time || result.end.date,
85
- html_link: result.html_link
86
- )
87
- result
78
+ @service.insert_event(calendar_id, event)
88
79
  rescue Google::Apis::ClientError => e
89
80
  # Surface helpful error info to aid debugging 400 errors
90
81
  Rails.logger.error("Google Calendar insert_event 4xx: #{e.class} #{e.status_code} - #{e.message} body=#{e.body}")
@@ -109,14 +100,46 @@ module Collavre
109
100
  private
110
101
 
111
102
  def user_credentials
103
+ token = refresh_token
104
+ raise GoogleCalendarError, I18n.t("collavre.google_calendar.errors.not_connected") if token.blank?
105
+
112
106
  Google::Auth::UserRefreshCredentials.new(
113
107
  client_id: ENV["GOOGLE_CLIENT_ID"] || Rails.application.credentials.dig(:google, :client_id),
114
108
  client_secret: ENV["GOOGLE_CLIENT_SECRET"] || Rails.application.credentials.dig(:google, :client_secret),
115
109
  scope: [ Google::Apis::CalendarV3::AUTH_CALENDAR_APP_CREATED ],
116
- refresh_token: @user.google_refresh_token
110
+ refresh_token: token
117
111
  ).tap(&:fetch_access_token!)
118
112
  end
119
113
 
114
+ def refresh_token
115
+ raw_value = raw_google_refresh_token
116
+ return nil if raw_value.blank?
117
+
118
+ # If already encrypted (JSON format with "p":), decrypt normally
119
+ if encrypted_value?(raw_value)
120
+ @user.google_refresh_token
121
+ else
122
+ # Legacy unencrypted token - re-encrypt and return
123
+ @user.update!(google_refresh_token: raw_value)
124
+ raw_value
125
+ end
126
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveRecord::Encryption::Errors::Decryption
127
+ # Decryption failed - token may be corrupted or encrypted with different key
128
+ Rails.logger.error("Failed to decrypt google_refresh_token for user #{@user.id}")
129
+ nil
130
+ end
131
+
132
+ def raw_google_refresh_token
133
+ result = ActiveRecord::Base.connection.select_value(
134
+ "SELECT google_refresh_token FROM users WHERE id = #{@user.id}"
135
+ )
136
+ result
137
+ end
138
+
139
+ def encrypted_value?(value)
140
+ value.is_a?(String) && value.start_with?("{") && value.include?('"p":')
141
+ end
142
+
120
143
  def create_app_calendar
121
144
  calendar = @service.insert_calendar(Google::Apis::CalendarV3::Calendar.new(summary: @service.client_options.application_name))
122
145
  # save calendar id to user profile
@@ -1,5 +1,8 @@
1
1
  module Collavre
2
2
  class MarkdownImporter
3
+ # Matches horizontal rules: ---, ***, ___ (with optional spaces)
4
+ HORIZONTAL_RULE_REGEX = /^\s*([-*_])\s*\1\s*\1[\s\1]*$/
5
+
3
6
  def self.import(content, parent:, user:, create_root: false)
4
7
  lines = content.to_s.lines
5
8
  image_refs = {}
@@ -26,21 +29,51 @@ module Collavre
26
29
  end
27
30
  end
28
31
  stack = [ [ 0, root ] ]
29
- current_fence = nil
30
32
  while i < lines.size
31
33
  line = lines[i]
32
- if (fence_match = line.match(/^\s*(`{3,}|~{3,})/))
33
- fence_marker = fence_match[1]
34
+
35
+ # Skip horizontal rules (---, ***, ___)
36
+ if line =~ HORIZONTAL_RULE_REGEX
37
+ i += 1
38
+ next
39
+ end
40
+
41
+ # Handle fenced code blocks (``` or ~~~)
42
+ if (fence_match = line.match(/^(\s*)(`{3,}|~{3,})(.*)$/))
43
+ fence_indent = fence_match[1]
44
+ fence_marker = fence_match[2]
34
45
  fence_char = fence_marker[0]
35
46
  fence_length = fence_marker.length
36
- if current_fence.nil?
37
- current_fence = { char: fence_char, length: fence_length }
38
- elsif current_fence[:char] == fence_char && fence_length >= current_fence[:length]
39
- current_fence = nil
47
+ fence_info = fence_match[3].strip # language info (e.g., "ruby", "javascript")
48
+
49
+ # Collect all lines until closing fence
50
+ code_lines = []
51
+ i += 1
52
+ while i < lines.size
53
+ close_line = lines[i]
54
+ # Check for closing fence (same char, at least same length)
55
+ if (close_match = close_line.match(/^\s*(`{3,}|~{3+})\s*$/))
56
+ close_marker = close_match[1]
57
+ if close_marker[0] == fence_char && close_marker.length >= fence_length
58
+ i += 1
59
+ break
60
+ end
61
+ end
62
+ code_lines << close_line
63
+ i += 1
40
64
  end
65
+
66
+ # Build code block HTML
67
+ code_content = code_lines.join
68
+ code_html = build_code_block_html(code_content, fence_info)
69
+
70
+ new_parent = stack.any? ? stack.last[1] : root
71
+ c = Creative.create(user: user, parent: new_parent, description: code_html)
72
+ created << c
73
+ next
41
74
  end
42
75
 
43
- if current_fence.nil? && (table_data = parse_markdown_table(lines, i))
76
+ if (table_data = parse_markdown_table(lines, i))
44
77
  table_html = build_table_html(table_data, image_refs)
45
78
  new_parent = stack.any? ? stack.last[1] : root
46
79
  c = Creative.create(user: user, parent: new_parent, description: table_html)
@@ -78,6 +111,12 @@ module Collavre
78
111
  created
79
112
  end
80
113
 
114
+ def self.build_code_block_html(code_content, language = nil)
115
+ escaped_content = ERB::Util.html_escape(code_content)
116
+ lang_class = language.present? ? " class=\"language-#{ERB::Util.html_escape(language)}\"" : ""
117
+ "<pre><code#{lang_class}>#{escaped_content}</code></pre>"
118
+ end
119
+
81
120
  def self.parse_markdown_table(lines, index)
82
121
  return nil if index >= lines.length
83
122
  header_line = lines[index]
@@ -74,16 +74,15 @@ module Collavre
74
74
  end
75
75
 
76
76
  # Check strict loading? No, simple where is fine.
77
- dynamic_tools = McpTool.where(name: registered_names)
77
+ dynamic_tools = McpTool.where(name: registered_names).includes(:creative)
78
78
  dynamic_tool_names = dynamic_tools.pluck(:name).to_set
79
79
 
80
- # If user is present, find their owned tools
81
- user_owned_tool_names = if user
82
- dynamic_tools
83
- .joins(:creative)
84
- .where(creatives: { user_id: user.id })
85
- .pluck(:name)
86
- .to_set
80
+ # Build set of tool names the user has permission to run
81
+ # User needs write permission on the creative to run its tools
82
+ accessible_tool_names = if user
83
+ dynamic_tools.select do |mcp_tool|
84
+ mcp_tool.creative&.has_permission?(user, :write)
85
+ end.map(&:name).to_set
87
86
  else
88
87
  Set.new
89
88
  end
@@ -97,8 +96,8 @@ module Collavre
97
96
  nil
98
97
  end
99
98
  if dynamic_tool_names.include?(name)
100
- # It is a dynamic tool; user must own it.
101
- user_owned_tool_names.include?(name)
99
+ # It is a dynamic tool; user must have write permission on its creative.
100
+ accessible_tool_names.include?(name)
102
101
  else
103
102
  # It is a system tool (not in McpTool database); allow it.
104
103
  true
@@ -112,6 +111,20 @@ module Collavre
112
111
  end
113
112
  end
114
113
 
114
+ # Fetch and filter available tools for the given user.
115
+ # Returns an array of tool hashes with :name, :description, :params keys.
116
+ def self.available_tools(user)
117
+ return [] unless defined?(RailsMcpEngine)
118
+
119
+ RailsMcpEngine::Engine.build_tools!
120
+ result = ::Tools::MetaToolService.new.call(action: "list", tool_name: nil, query: nil, arguments: nil)
121
+ tool_list = Array(result[:tools])
122
+ filter_tools(tool_list, user)
123
+ rescue StandardError => e
124
+ Rails.logger.error("Failed to load available tools: #{e.message}")
125
+ []
126
+ end
127
+
115
128
  def self.delete_tool(tool_name)
116
129
  result = ::Tools::MetaToolWriteService.new.delete_tool(tool_name)
117
130
 
@@ -6,6 +6,41 @@ module Collavre
6
6
  liquid_context = Collavre::SystemEvents::ContextBuilder.new(context).build
7
7
  liquid_context["event_name"] = event_name
8
8
 
9
+ # Priority 1: Mention-based routing (exclusive)
10
+ # If any user is mentioned, mention routing takes over exclusively.
11
+ # - Mentioned AI agent → route only to that agent
12
+ # - Mentioned non-AI user → route to nobody (no AI agents)
13
+ mentioned_result = find_mentioned_routing(liquid_context, context)
14
+ return mentioned_result unless mentioned_result.nil?
15
+
16
+ # Priority 2: Liquid expression routing (fallback, only when no mention)
17
+ route_by_liquid_expression(liquid_context, context)
18
+ end
19
+
20
+ private
21
+
22
+ # Returns Array of agents to route to, or nil if no mention was found.
23
+ # When a mention IS found, this is exclusive:
24
+ # - AI agent mentioned → [agent] (if permitted)
25
+ # - Non-AI user mentioned → [] (no AI agents should receive it)
26
+ def find_mentioned_routing(liquid_context, context)
27
+ mentioned_user_data = liquid_context.dig("chat", "mentioned_user")
28
+ return nil unless mentioned_user_data && mentioned_user_data["id"]
29
+
30
+ mentioned_user = User.find_by(id: mentioned_user_data["id"])
31
+ return nil unless mentioned_user
32
+
33
+ # Mention found — this is exclusive routing.
34
+ # If the mentioned user is not an AI agent, no AI agents should receive the message.
35
+ return [] unless mentioned_user.ai_user?
36
+
37
+ # Permission check for mentioned AI agent
38
+ return [] unless has_creative_permission?(mentioned_user, context)
39
+
40
+ [ mentioned_user ]
41
+ end
42
+
43
+ def route_by_liquid_expression(liquid_context, context)
9
44
  # Find all AI agents
10
45
  agents = User.where.not(llm_vendor: nil)
11
46
 
@@ -15,32 +50,7 @@ module Collavre
15
50
  next if agent.routing_expression.blank?
16
51
 
17
52
  # Permission Check
18
- # If agent is not searchable, it must have feedback permission on the creative
19
- unless agent.searchable?
20
- creative_id = context.dig("creative", "id") || context.dig(:creative, :id)
21
- if creative_id
22
- creative = Creative.find_by(id: creative_id)
23
- if creative
24
- # Check for feedback permission (which implies read access)
25
- # has_permission? checks for the specific permission or higher
26
- unless creative.has_permission?(agent, :feedback)
27
- # Rails.logger.info "Agent #{agent.id} skipped: No feedback permission on Creative #{creative.id}"
28
- next
29
- end
30
- else
31
- # If creative ID is present but not found, skip for safety
32
- next
33
- end
34
- else
35
- # If no creative context, we might skip or allow depending on policy.
36
- # Assuming 'chat.creative' implies creative context is required for this check.
37
- # If it's a global event without creative, maybe searchable check isn't needed?
38
- # But the user said "must have feedback permission on the chat.creative".
39
- # If there is no creative, we can't check permission, so we should probably skip to be safe
40
- # unless it's a purely global event. But for now, let's skip.
41
- next
42
- end
43
- end
53
+ next unless has_creative_permission?(agent, context)
44
54
 
45
55
  begin
46
56
  # Add 'agent' to context so they can refer to themselves
@@ -67,6 +77,20 @@ module Collavre
67
77
 
68
78
  matched_agents
69
79
  end
80
+
81
+ def has_creative_permission?(agent, context)
82
+ # Searchable agents can receive any routed message
83
+ return true if agent.searchable?
84
+
85
+ # Non-searchable agents must have feedback permission on the creative
86
+ creative_id = context.dig("creative", "id") || context.dig(:creative, :id)
87
+ return false unless creative_id
88
+
89
+ creative = Creative.find_by(id: creative_id)
90
+ return false unless creative
91
+
92
+ creative.has_permission?(agent, :feedback)
93
+ end
70
94
  end
71
95
  end
72
96
  end
@@ -0,0 +1,97 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ class CreativeCreateService
6
+ extend T::Sig
7
+ extend ToolMeta
8
+
9
+ tool_name "creative_create_service"
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 accepts HTML format for rich text content."
11
+
12
+ 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
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
14
+ tool_param :progress, description: "Initial progress value (0.0 to 1.0). Default is 0.", required: false
15
+ tool_param :after_id, description: "ID of a sibling Creative to insert after. Used for ordering.", required: false
16
+ tool_param :before_id, description: "ID of a sibling Creative to insert before. Used for ordering.", required: false
17
+
18
+ sig { params(description: String, parent_id: T.nilable(Integer), progress: T.nilable(Numeric), after_id: T.nilable(Integer), before_id: T.nilable(Integer)).returns(T::Hash[Symbol, T.untyped]) }
19
+ def call(description:, parent_id: nil, progress: nil, after_id: nil, before_id: nil)
20
+ raise "Current.user is required" unless Current.user
21
+
22
+ # Validate parent permission if specified
23
+ parent = nil
24
+ if parent_id.present?
25
+ parent = Creative.find_by(id: parent_id)
26
+ unless parent
27
+ return { error: "Parent Creative not found", id: parent_id }
28
+ end
29
+ unless parent.has_permission?(Current.user, :write)
30
+ return { error: "No write permission on parent Creative", id: parent_id }
31
+ end
32
+ end
33
+
34
+ # Normalize description - wrap plain text in <p> tags if needed
35
+ normalized_description = normalize_description(description)
36
+
37
+ # Build the creative
38
+ creative = Creative.new(
39
+ description: normalized_description,
40
+ parent: parent,
41
+ progress: progress || 0
42
+ )
43
+
44
+ # Set user based on parent or current user
45
+ creative.user = parent ? parent.user : Current.user
46
+
47
+ unless creative.save
48
+ return { error: "Failed to create Creative", details: creative.errors.full_messages }
49
+ end
50
+
51
+ # Handle ordering
52
+ handle_ordering(creative, before_id: before_id, after_id: after_id)
53
+
54
+ {
55
+ success: true,
56
+ id: creative.id,
57
+ description: creative.description,
58
+ parent_id: creative.parent_id,
59
+ progress: creative.progress
60
+ }
61
+ end
62
+
63
+ private
64
+
65
+ def normalize_description(desc)
66
+ return desc if desc.blank?
67
+ # If it already looks like HTML, return as-is
68
+ return desc if desc.strip.start_with?("<")
69
+ # Otherwise wrap in <p> tags
70
+ "<p>#{ERB::Util.html_escape(desc)}</p>"
71
+ end
72
+
73
+ def handle_ordering(creative, before_id:, after_id:)
74
+ return unless before_id.present? || after_id.present?
75
+
76
+ siblings = creative.parent ? creative.parent.children.order(:sequence).to_a : Creative.roots.order(:sequence).to_a
77
+ siblings.reject! { |s| s.id == creative.id }
78
+
79
+ if before_id.present?
80
+ before_creative = Creative.find_by(id: before_id)
81
+ if before_creative && before_creative.parent_id == creative.parent_id
82
+ index = siblings.index { |s| s.id == before_creative.id } || 0
83
+ siblings.insert(index, creative)
84
+ end
85
+ elsif after_id.present?
86
+ after_creative = Creative.find_by(id: after_id)
87
+ if after_creative && after_creative.parent_id == creative.parent_id
88
+ index = siblings.index { |s| s.id == after_creative.id } || -1
89
+ siblings.insert(index + 1, creative)
90
+ end
91
+ end
92
+
93
+ siblings.each_with_index { |c, idx| c.update_column(:sequence, idx) }
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,116 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ class CreativeUpdateService
6
+ extend T::Sig
7
+ extend ToolMeta
8
+
9
+ tool_name "creative_update_service"
10
+ tool_description "Update an existing Creative's content, progress, or parent. Use this to:\n- Modify the description/title of a Creative\n- Update progress (0.0 to 1.0, where 1.0 = complete)\n- Move a Creative to a different parent\n\nNote: Setting progress to 1.0 on a parent will also complete all descendants."
11
+
12
+ tool_param :id, description: "The ID of the Creative to update.", required: true
13
+ tool_param :description, description: "New content/title for the Creative. Accepts HTML format. If omitted, description remains unchanged.", required: false
14
+ tool_param :progress, description: "New progress value (0.0 to 1.0). Setting to 1.0 marks as complete.", required: false
15
+ tool_param :parent_id, description: "New parent Creative ID to move this Creative under. Use null/0 to make it a root Creative.", required: false
16
+
17
+ sig { params(id: Integer, description: T.nilable(String), progress: T.nilable(Float), parent_id: T.nilable(Integer)).returns(T::Hash[Symbol, T.untyped]) }
18
+ def call(id:, description: nil, progress: nil, parent_id: nil)
19
+ raise "Current.user is required" unless Current.user
20
+
21
+ creative = Creative.find_by(id: id)
22
+ unless creative
23
+ return { error: "Creative not found", id: id }
24
+ end
25
+
26
+ unless creative.has_permission?(Current.user, :write)
27
+ return { error: "No write permission on this Creative", id: id }
28
+ end
29
+
30
+ # Get the effective origin for updating content
31
+ base = creative.effective_origin
32
+ previous_progress = base.progress
33
+
34
+ updates = {}
35
+ parent_updates = {}
36
+
37
+ # Handle description update
38
+ if description.present?
39
+ updates[:description] = normalize_description(description)
40
+ end
41
+
42
+ # Handle progress update
43
+ if progress.present?
44
+ updates[:progress] = progress.to_f.clamp(0.0, 1.0)
45
+ end
46
+
47
+ # Handle parent change (on the creative itself, not base)
48
+ if parent_id.present? || parent_id == 0
49
+ new_parent_id = parent_id == 0 ? nil : parent_id
50
+
51
+ if new_parent_id.present?
52
+ new_parent = Creative.find_by(id: new_parent_id)
53
+ unless new_parent
54
+ return { error: "New parent Creative not found", parent_id: new_parent_id }
55
+ end
56
+ unless new_parent.has_permission?(Current.user, :write)
57
+ return { error: "No write permission on new parent Creative", parent_id: new_parent_id }
58
+ end
59
+ # Prevent circular reference
60
+ if new_parent.self_and_ancestors.include?(creative)
61
+ return { error: "Cannot move Creative under its own descendant", parent_id: new_parent_id }
62
+ end
63
+ end
64
+
65
+ parent_updates[:parent_id] = new_parent_id
66
+ end
67
+
68
+ # Apply updates
69
+ success = true
70
+
71
+ # Update parent on the creative (for linked creatives, parent is on the link, not origin)
72
+ if parent_updates.present?
73
+ success &&= creative.update(parent_updates)
74
+ end
75
+
76
+ # Update content on the base/origin
77
+ if updates.present?
78
+ success &&= base.update(updates)
79
+
80
+ # If progress set to 1.0 and has children, complete all descendants
81
+ requested_progress = updates[:progress]
82
+ if success && requested_progress.present? && requested_progress >= 1 && previous_progress.to_f < 1
83
+ if base.children.exists?
84
+ base.self_and_descendants.where(origin_id: nil)
85
+ .update_all(progress: 1.0, updated_at: Time.current)
86
+ end
87
+ end
88
+ end
89
+
90
+ if success
91
+ creative.reload
92
+ base.reload
93
+ {
94
+ success: true,
95
+ id: creative.id,
96
+ description: base.description,
97
+ parent_id: creative.parent_id,
98
+ progress: base.progress
99
+ }
100
+ else
101
+ { error: "Failed to update Creative", details: creative.errors.full_messages + base.errors.full_messages }
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def normalize_description(desc)
108
+ return desc if desc.blank?
109
+ # If it already looks like HTML, return as-is
110
+ return desc if desc.strip.start_with?("<")
111
+ # Otherwise wrap in <p> tags
112
+ "<p>#{ERB::Util.html_escape(desc)}</p>"
113
+ end
114
+ end
115
+ end
116
+ end
@@ -34,10 +34,10 @@
34
34
  <%= render "collavre/comments/read_receipts", read_by_users: users_read, present_user_ids: present_user_ids unless comment.private? %>
35
35
  </span>
36
36
  <% if comment.private? %>
37
- <span class="private-label">[<%= t('collavre.comments.private') %>]</span>
37
+ <span class="comment-status-label private-label">🔒 <%= t('collavre.comments.private') %></span>
38
38
  <% end %>
39
39
  <% if comment.action_executed_at.present? %>
40
- <span class="comment-status-label approved-label">[<%= t('collavre.comments.approved_label') %>]</span>
40
+ <span class="comment-status-label approved-label">✅ <%= t('collavre.comments.approved_label') %></span>
41
41
  <% end %>
42
42
  <% can_convert_comment = comment.user == Current.user || comment.creative.has_permission?(Current.user, :admin) %>
43
43
  </div>
@@ -1,5 +1,14 @@
1
1
 
2
+ <% fullscreen = local_assigns.fetch(:fullscreen, false) %>
3
+ <% creative = local_assigns[:creative] %>
2
4
  <div id="comments-popup" data-controller="comments--popup comments--list comments--form comments--presence comments--mention-menu comments--topics" class="popup-box"
5
+ data-fullscreen="<%= fullscreen %>"
6
+ data-fullscreen-url-template="<%= collavre.fullscreen_creative_comments_path(creative_id: "__CREATIVE_ID__") %>"
7
+ <% if fullscreen && creative.present? %>
8
+ data-creative-id="<%= creative.id %>"
9
+ data-creative-snippet="<%= creative.creative_snippet %>"
10
+ data-can-comment="<%= creative.has_permission?(Current.user, :feedback) %>"
11
+ <% end %>
3
12
  data-loading-text="<%= t('app.loading') %>"
4
13
  data-delete-confirm-text="<%= t("collavre.comments.delete_confirm") %>"
5
14
  data-update-comment-text="<%= t('collavre.comments.update_comment') %>"
@@ -15,23 +24,48 @@
15
24
  data-voice-start-text="<%= t('collavre.comments.voice_button') %>"
16
25
  data-voice-stop-text="<%= t('collavre.comments.voice_stop') %>"
17
26
  data-move-no-selection-text="<%= t('collavre.comments.move_no_selection') %>"
18
- data-move-error-text="<%= t('collavre.comments.move_error') %>">
19
- <div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
20
- <div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
21
- <button id="close-comments-btn" data-comments--popup-target="closeButton" class="popup-close-btn">&times;</button>
22
- <h3 id="comments-popup-title" data-comments--popup-target="title"><%= t('collavre.comments.comments') %></h3>
27
+ data-move-error-text="<%= t('collavre.comments.move_error') %>"
28
+ data-hint-drag-topic-text="<%= t('collavre.comments.hint_drag_topic') %>"
29
+ data-hint-move-button-text="<%= t('collavre.comments.hint_move_button') %>"
30
+ data-add-participant-text="<%= t('collavre.comments.add_participant') %>">
31
+ <% unless fullscreen %>
32
+ <div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
33
+ <div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
34
+ <% end %>
35
+ <div class="comments-popup-header">
36
+ <h3 id="comments-popup-title" data-comments--popup-target="title"><%= fullscreen && creative.present? ? creative.creative_snippet : t('collavre.comments.comments') %></h3>
37
+ <span data-integration-badges class="integration-badges"></span>
38
+ <div class="comments-popup-actions">
39
+ <% if fullscreen && creative.present? %>
40
+ <%= link_to collavre.creative_path(creative, open_comments: true),
41
+ class: "comments-popup-action comments-popup-fullscreen",
42
+ aria: { label: t("collavre.comments.exit_fullscreen", default: "Exit full screen") } do %>
43
+ <%= svg_tag "exit-fullscreen.svg", class: "comments-popup-action-icon" %>
44
+ <% end %>
45
+ <% else %>
46
+ <%= link_to "#",
47
+ class: "comments-popup-action comments-popup-fullscreen",
48
+ data: { "comments--popup-target": "fullscreenLink" },
49
+ aria: { label: t("collavre.comments.fullscreen", default: "Full screen") } do %>
50
+ <%= svg_tag "fullscreen.svg", class: "comments-popup-action-icon" %>
51
+ <% end %>
52
+ <button id="close-comments-btn" data-comments--popup-target="closeButton" class="comments-popup-action comments-popup-close" type="button">&times;</button>
53
+ <% end %>
54
+ </div>
55
+ </div>
23
56
  <div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
24
57
  <div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
25
58
  data-confirm-delete-text="<%= t('collavre.topics.delete_confirm') %>"
26
59
  data-new-topic-placeholder="<%= t('collavre.topics.new_placeholder') %>"></div>
27
60
  <%= render UserMentionMenuComponent.new(menu_id: 'mention-menu') %>
61
+ <%= render CommandMenuComponent.new(menu_id: 'command-menu') %>
28
62
  <div id="comments-list" data-comments--popup-target="list" data-comments--list-target="list"><%= t('app.loading') %></div>
29
63
  <div id="typing-indicator" data-comments--presence-target="typingIndicator"></div>
30
64
  <form id="new-comment-form" data-comments--popup-target="form" data-comments--form-target="form" style="display:none;">
31
65
  <textarea class="shared-input-surface" name="comment[content]" data-comments--form-target="textarea" data-comments--presence-target="textarea" data-comments--mention-menu-target="textarea" rows="2" enterkeyhint="send"></textarea>
32
66
  <div class="comment-bottom">
33
67
  <input type="file" id="comment-images" name="comment[images][]" accept="image/*" multiple data-comments--form-target="imageInput" style="display:none;" />
34
- <label style="height: 22px"><input type="checkbox" id="comment-private" data-comments--form-target="privateCheckbox" data-comments--presence-target="privateCheckbox" name="comment[private]" /> <%= t('collavre.comments.private') %></label>
68
+ <label style="height: 22px"><input type="hidden" name="comment[private]" value="0" /><input type="checkbox" id="comment-private" data-comments--form-target="privateCheckbox" data-comments--presence-target="privateCheckbox" name="comment[private]" value="1" /> <%= t('collavre.comments.private') %></label>
35
69
  <div class="comment-actions">
36
70
  <button class="creative-action-btn" id="attach-image-btn" data-comments--form-target="imageButton" type="button"><%= t('collavre.comments.image_button') %></button>
37
71
  <button class="creative-action-btn" id="voice-comments-btn" data-comments--form-target="voiceButton" type="button" data-voice-state="idle"><%= t('collavre.comments.voice_button') %></button>
@@ -0,0 +1,5 @@
1
+ <% content_for :title, "#{@creative_snippet} - #{t('collavre.comments.comments')}" %>
2
+
3
+ <div class="comments-fullscreen-page">
4
+ <%= render "collavre/comments/comments_popup", fullscreen: true, creative: @creative %>
5
+ </div>
@@ -16,8 +16,17 @@
16
16
  data-placeholder="<%= t('collavre.creatives.inline_editor.placeholder') %>"></div>
17
17
  <div id="actions-inline-editor" style="padding: 0px 8px;">
18
18
  <div style="margin-top:0.5em;display:flex;align-items:center;gap:0.5em;">
19
- <input type="range" id="inline-creative-progress" name="creative[progress]" min="0" max="1" step="0.01">
20
- <span id="inline-progress-value">0%</span>
19
+ <input type="hidden" name="creative[progress]" value="0">
20
+ <label for="inline-creative-progress" style="display:flex;align-items:center;gap:0.4em;">
21
+ <input type="checkbox"
22
+ id="inline-creative-progress"
23
+ name="creative[progress]"
24
+ value="1"
25
+ data-complete-label="<%= t('collavre.creatives.index.progress_complete') %>"
26
+ data-incomplete-label="<%= t('collavre.creatives.index.progress_incomplete') %>"
27
+ data-children-alert-message="<%= t('collavre.creatives.index.progress_complete_children_alert') %>">
28
+ <span id="inline-progress-value"><%= t('collavre.creatives.index.progress_incomplete') %></span>
29
+ </label>
21
30
  </div>
22
31
  <div style="margin-top:0.5em;">
23
32
  <button type="button" id="inline-move-up" class="creative-action-btn" title="<%= t('collavre.creatives.index.inline_move_up_tooltip') %>">
@@ -86,4 +95,3 @@
86
95
  </form>
87
96
  </div>
88
97
 
89
-