collavre 0.5.0 → 0.7.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
@@ -72,11 +72,12 @@ module Collavre
72
72
  return delayed_decision(agent, :rate_limited, config)
73
73
  end
74
74
 
75
- # Check 4: Topic concurrency limit (applies to Main topic with nil topic_id too)
75
+ # Check 4: Topic concurrency limit (scoped by creative to avoid cross-creative blocking)
76
76
  topic_max = @policy_resolver.topic_max_concurrent_jobs
77
77
  if topic_max && @context.key?("topic")
78
78
  topic_id = @context.dig("topic", "id")
79
- if (Task.running_for_topic(topic_id).count + topic_immediate_count) >= topic_max
79
+ creative_id = @context.dig("creative", "id")
80
+ if (Task.running_for_topic(topic_id, creative_id).count + topic_immediate_count) >= topic_max
80
81
  return deferred_decision(agent, :topic_concurrency)
81
82
  end
82
83
  end
@@ -74,7 +74,7 @@ module Collavre
74
74
  end
75
75
 
76
76
  # Drain the queue for the topic so waiting tasks can execute
77
- AgentOrchestrator.dequeue_next_for_topic(task.topic_id)
77
+ AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
78
78
  rescue StandardError => e
79
79
  Rails.logger.error("[StuckDetector] Auto-recovery failed for task #{task.id}: #{e.message}")
80
80
  end
@@ -6,6 +6,15 @@ module Collavre
6
6
  end
7
7
 
8
8
  def dispatch(event_name, context)
9
+ comment_id = context.dig("comment", "id")
10
+ comment_user_id = context.dig("comment", "user_id")
11
+ creative_id = context.dig("creative", "id")
12
+ Rails.logger.info(
13
+ "[SystemEvents::Dispatcher] event=#{event_name} " \
14
+ "comment_id=#{comment_id} comment_user_id=#{comment_user_id} " \
15
+ "creative_id=#{creative_id} " \
16
+ "caller=#{caller_locations(1, 5)&.map { |l| "#{File.basename(l.path)}:#{l.lineno}" }&.join(' <- ')}"
17
+ )
9
18
  # Delegate to AgentOrchestrator for unified routing/scheduling
10
19
  Orchestration::AgentOrchestrator.dispatch(event_name, context)
11
20
  end
@@ -5,6 +5,7 @@ module Tools
5
5
  class CreativeCreateService
6
6
  extend T::Sig
7
7
  extend ToolMeta
8
+ include DescriptionNormalizable
8
9
 
9
10
  tool_name "creative_create_service"
10
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."
@@ -62,14 +63,6 @@ module Tools
62
63
 
63
64
  private
64
65
 
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
66
  def handle_ordering(creative, before_id:, after_id:)
74
67
  return unless before_id.present? || after_id.present?
75
68
 
@@ -0,0 +1,46 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+
5
+ module Tools
6
+ class CreativeImportService
7
+ extend T::Sig
8
+ extend ToolMeta
9
+
10
+ tool_name "creative_import_service"
11
+ tool_description "Import a markdown document as a Creative tree structure using the built-in MarkdownImporter. " \
12
+ "Headings (# through ######) become nested Creatives, preserving the hierarchy. " \
13
+ "Bullet lists (-, *, +) become nested children with indent-based depth. " \
14
+ "Tables, fenced code blocks, and inline images are also supported.\n\n" \
15
+ "Example input:\n" \
16
+ "```\n# Project Plan\n## Phase 1\n- Setup infrastructure\n- Configure CI\n## Phase 2\n- Build features\n```\n\n" \
17
+ "This creates a tree under the specified parent Creative.\n\n" \
18
+ "This tool requires approval before execution."
19
+
20
+ def self.requires_approval?
21
+ true
22
+ end
23
+
24
+ tool_param :markdown, description: "The markdown text to import. Headings and bullet lists define the tree structure.", required: true
25
+ tool_param :parent_id, description: "ID of the parent Creative to import under. The entire markdown tree will be created as children of this Creative.", required: true
26
+
27
+ sig { params(markdown: String, parent_id: Integer).returns(T::Hash[Symbol, T.untyped]) }
28
+ def call(markdown:, parent_id:)
29
+ raise "Current.user is required" unless Current.user
30
+
31
+ parent = Creative.find_by(id: parent_id)
32
+ return { error: "Parent Creative not found", id: parent_id } unless parent
33
+ return { error: "No write permission on parent Creative", id: parent_id } unless parent.has_permission?(Current.user, :write)
34
+
35
+ created = MarkdownImporter.import(markdown, parent: parent, user: Current.user)
36
+
37
+ {
38
+ success: true,
39
+ parent_id: parent_id,
40
+ created_count: created.size,
41
+ tree: created.map { |c| { id: c.id, description: c.description.to_s.truncate(100), parent_id: c.parent_id } }
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -7,131 +7,192 @@ module Tools
7
7
  extend ToolMeta
8
8
 
9
9
  tool_name "creative_retrieval_service"
10
- tool_description "Retrieve creatives by ID or query text. without query or ID, it will return root creatives. Returns a list of matching creatives with their details, supporting both hierarchical tree and flat list formats.\n\nA Creative is a content block that functions like a task, organized in a tree structure similar to a to-do list. You can navigate the tree at any level as a structured document, with progress automatically calculated to show whats been completed.\n\ne.g.\n- When user say creative or Test creative, it means \"Test\" creative and it's children as a writing page.\n- Summary of Test creative? - you need to search \"Test\" creatives with level 3 or more and find the title is \"Test\" or similar and make summary of that."
10
+ tool_description "Retrieve creatives by ID or query text. Without query or ID, it returns root creatives.\n\nA Creative is a content block that functions like a task, organized in a tree structure similar to a to-do list. You can navigate the tree at any level as a structured document, with progress automatically calculated to show what's been completed.\n\nDefault output is a compact markdown tree:\n<!-- format: [id] description (progress%) -->\n- [123] My Task (50%)\n - [124] Subtask A (100%)\n - [125] Subtask B (0%)\n\ne.g.\n- When user say creative or Test creative, it means \"Test\" creative and it's children as a writing page.\n- Summary of Test creative? - you need to search \"Test\" creatives with level 3 or more and find the title is \"Test\" or similar and make summary of that."
11
11
 
12
- tool_param :id, description: "The ID of the creative to retrieve."
13
- tool_param :query, description: "Text to search for in creative descriptions."
12
+ tool_param :id, description: "The ID of the creative to retrieve with its subtree."
13
+ tool_param :query, description: "Text to search for in creative descriptions and comments."
14
14
  tool_param :level, description: "Creative tree depth to return (default: 3).", required: false
15
- tool_param :simple, description: "If true, returns a simplified flat list. If false (default), returns a tree structure with HTML.", required: false
16
-
17
- sig { params(id: T.nilable(Integer), query: T.nilable(String), level: T.nilable(Integer), simple: T.nilable(T::Boolean)).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
18
- def call(id: nil, query: nil, level: 3, simple: false)
15
+ tool_param :tags, description: "Filter by tag names (comma-separated).", required: false
16
+ tool_param :progress_min, description: "Min progress filter (0.0-1.0).", required: false
17
+ tool_param :progress_max, description: "Max progress filter (0.0-1.0).", required: false
18
+ tool_param :updated_since, description: "ISO8601 timestamp - only items updated after this.", required: false
19
+ tool_param :include_comments, description: "Include recent comments per creative (default: false).", required: false
20
+ tool_param :format, description: "Response format: 'markdown' (default, compact tree) or 'json' (structured data with tags/dates).", required: false
21
+
22
+ sig do
23
+ params(
24
+ id: T.nilable(Integer),
25
+ query: T.nilable(String),
26
+ level: T.nilable(Integer),
27
+ tags: T.nilable(String),
28
+ progress_min: T.nilable(Float),
29
+ progress_max: T.nilable(Float),
30
+ updated_since: T.nilable(String),
31
+ include_comments: T.nilable(T::Boolean),
32
+ format: T.nilable(String)
33
+ ).returns(T.any(String, T::Array[T::Hash[Symbol, T.untyped]]))
34
+ end
35
+ def call(id: nil, query: nil, level: 3, tags: nil, progress_min: nil, progress_max: nil, updated_since: nil, include_comments: false, format: "markdown")
19
36
  level ||= 3
20
- simple ||= false
37
+ format ||= "markdown"
38
+ include_comments ||= false
39
+
40
+ raise "Current.user is required" unless Current.user
21
41
 
22
- # Ensure fresh permission cache for this tool execution
23
- Current.creative_share_cache = nil if Current.respond_to?(:creative_share_cache=)
42
+ creatives = fetch_creatives(id: id, query: query)
43
+ creatives = apply_filters(creatives, tags: tags, progress_min: progress_min, progress_max: progress_max, updated_since: updated_since)
24
44
 
25
- # Mock session and request setup
26
- setup_mock_environment
45
+ # Search queries return a flat list — no subtree expansion needed
46
+ if query.present? && id.blank?
47
+ creatives.map do |c|
48
+ { id: c.id, description: Creatives::TreeFormatter.plain_description(c), progress: c.progress.to_f.round(2) }
49
+ end
50
+ elsif format == "json"
51
+ build_json_tree(creatives, depth: level, include_comments: include_comments)
52
+ else
53
+ formatter = Creatives::TreeFormatter.new(
54
+ max_depth: level - 1,
55
+ include_header: true,
56
+ include_comments: include_comments
57
+ )
58
+ formatter.format(creatives) + "\n"
59
+ end
60
+ end
27
61
 
28
- controller = Collavre::CreativesController.new
29
- setup_controller(controller)
62
+ private
30
63
 
64
+ def fetch_creatives(id:, query:)
31
65
  if id.present?
32
- # Get the creative details (show)
33
- show_result = dispatch_request(controller, :show, id: id, format: :json)
34
- return show_result if show_result.is_a?(Array) && show_result.first[:error]
66
+ creative = Creative.find_by(id: id)
67
+ return [] unless creative && creative.has_permission?(Current.user, :read)
68
+ [ creative ]
69
+ elsif query.present?
70
+ search_creatives(query)
71
+ else
72
+ Creative.where(user: Current.user).roots.order(:sequence).to_a
73
+ end
74
+ end
35
75
 
36
- # Get the children (index with id acts as parent filter)
37
- index_result = dispatch_request(controller, :index, id: id, search: query, simple: simple, level: level, format: :json)
38
- return index_result if index_result.is_a?(Array) && index_result.first[:error]
76
+ def search_creatives(query)
77
+ sanitized = Creative.sanitize_sql_like(query)
78
+ pattern = "%#{sanitized}%"
39
79
 
40
- # Combine results
41
- # show_result is expected to be a hash of the creative
42
- # index_result is expected to be a list of children or simple list
80
+ # Scope search to user's own + shared creatives via permission cache
81
+ accessible_ids = accessible_creative_ids
43
82
 
44
- # Parse show result
45
- creative_details = JSON.parse(show_result[:body], symbolize_names: true)
83
+ desc_ids = Creative.where(id: accessible_ids)
84
+ .where("description LIKE ?", pattern)
85
+ .pluck(:id)
46
86
 
47
- # Parse index result
48
- children_data = JSON.parse(index_result[:body], symbolize_names: true)
49
- filtered_children = filter_result(children_data)
87
+ comment_ids = Comment.where(creative_id: accessible_ids)
88
+ .where("content LIKE ?", pattern)
89
+ .pluck(:creative_id)
50
90
 
51
- # Merge
52
- # We construct a tree node for the parent, with the children attached
53
- parent_node = filter_tree([ creative_details ]).first
54
- parent_node[:children] = filtered_children
91
+ combined_ids = (desc_ids | comment_ids)
92
+ return [] if combined_ids.empty?
55
93
 
56
- [ parent_node ]
57
- else
58
- # Normal index call
59
- result = dispatch_request(controller, :index, search: query, simple: simple, level: level, format: :json)
60
-
61
- if result[:status] == 200
62
- parsed = JSON.parse(result[:body], symbolize_names: true)
63
- filter_result(parsed)
64
- else
65
- [ { error: "Controller returned status #{result[:status]}", body: result[:body] } ]
66
- end
67
- end
94
+ Creative.where(id: combined_ids)
95
+ .sort_by { |c| c.description.to_s.length }
68
96
  end
69
97
 
70
- private
98
+ def accessible_creative_ids
99
+ # User's own creatives + those shared via permission cache
100
+ own_ids = Creative.where(user: Current.user).pluck(:id)
101
+ shared_ids = CreativeSharesCache
102
+ .where(user_id: Current.user.id)
103
+ .where.not(permission: :no_access)
104
+ .pluck(:creative_id)
105
+ own_ids | shared_ids
106
+ end
71
107
 
72
- def setup_mock_environment
73
- raise "Current.user is required" unless Current.user
74
- unless Current.session
75
- require "ostruct"
76
- Current.session = OpenStruct.new(user: Current.user, persisted?: false)
108
+ def apply_filters(creatives, tags:, progress_min:, progress_max:, updated_since:)
109
+ result = creatives
110
+
111
+ if tags.present?
112
+ tag_names = tags.split(",").map(&:strip)
113
+ label_ids = Label.where(value: tag_names).pluck(:id)
114
+ tagged_ids = Tag.where(label_id: label_ids).pluck(:creative_id).to_set
115
+ # Batch: find which input creatives have tagged descendants (single query)
116
+ creative_ids = result.map(&:id)
117
+ ancestors_with_tagged = CreativeHierarchy
118
+ .where(ancestor_id: creative_ids)
119
+ .where(descendant_id: tagged_ids.to_a)
120
+ .pluck(:ancestor_id)
121
+ .to_set
122
+ result = result.select { |c| tagged_ids.include?(c.id) || ancestors_with_tagged.include?(c.id) }
77
123
  end
78
- end
79
124
 
80
- def setup_controller(controller)
81
- # Stub cookies
82
- controller.define_singleton_method(:cookies) do
83
- @mock_cookies ||= begin
84
- jar = OpenStruct.new
85
- def jar.signed; self; end
86
- def jar.encrypted; self; end
87
- def jar.[](key); nil; end
88
- def jar.delete(key); nil; end
89
- jar
90
- end
125
+ if progress_min.present?
126
+ result = result.select { |c| c.progress.to_f >= progress_min.to_f }
127
+ end
128
+
129
+ if progress_max.present?
130
+ result = result.select { |c| c.progress.to_f <= progress_max.to_f }
131
+ end
132
+
133
+ if updated_since.present?
134
+ since = Time.parse(updated_since)
135
+ # Batch check: get all descendant IDs with updates, then filter
136
+ creative_ids = result.map(&:id)
137
+ updated_descendant_ancestors = CreativeHierarchy
138
+ .where(ancestor_id: creative_ids)
139
+ .joins("INNER JOIN creatives ON creatives.id = creative_hierarchies.descendant_id")
140
+ .where("creatives.updated_at >= ?", since)
141
+ .pluck(:ancestor_id)
142
+ .to_set
143
+
144
+ result = result.select { |c| c.updated_at >= since || updated_descendant_ancestors.include?(c.id) }
91
145
  end
92
- end
93
146
 
94
- def dispatch_request(controller, action, params)
95
- env = Rack::MockRequest.env_for(
96
- "/creatives",
97
- method: "GET",
98
- params: params.compact,
99
- "HTTP_X_ORIGIN_SECRET" => ENV["ORIGIN_SHARED_SECRET"] # Internal call
100
- )
101
- controller.request = ActionDispatch::Request.new(env)
102
- controller.response = ActionDispatch::Response.new
103
- controller.process(action)
104
-
105
- { status: controller.response.status, body: controller.response.body }
147
+ result
106
148
  end
107
149
 
108
- def filter_result(result)
109
- if result.is_a?(Array)
110
- # Simple mode
111
- result.map { |item| item.slice(:id, :description, :progress) }
112
- elsif result.is_a?(Hash) && result[:creatives].is_a?(Array)
113
- # Normal mode (Tree)
114
- filter_tree(result[:creatives])
115
- else
116
- []
150
+ # --- JSON format ---
151
+
152
+ def build_json_tree(creatives, depth:, include_comments: false)
153
+ return [] if creatives.blank?
154
+
155
+ creatives.map do |creative|
156
+ serialize_creative(creative, depth: depth, current_depth: 1, include_comments: include_comments)
117
157
  end
118
158
  end
119
159
 
120
- def filter_tree(nodes)
121
- nodes.map do |node|
122
- description = if node.dig(:templates, :description_html)
123
- Rails::Html::FullSanitizer.new.sanitize(node.dig(:templates, :description_html))
124
- else
125
- node[:description]
160
+ def serialize_creative(creative, depth:, current_depth:, include_comments: false)
161
+ children = creative.linked_children
162
+
163
+ result = {
164
+ id: creative.id,
165
+ description: Creatives::TreeFormatter.plain_description(creative),
166
+ progress: creative.progress.to_f.round(2),
167
+ parent_id: creative.parent_id,
168
+ tags: creative.tags.includes(:label).map { |t| t.label&.value }.compact,
169
+ linked: creative.origin_id.present?,
170
+ origin_id: creative.origin_id,
171
+ has_children: children.any?,
172
+ children_count: children.size,
173
+ created_at: creative.created_at&.iso8601,
174
+ updated_at: creative.updated_at&.iso8601
175
+ }
176
+
177
+ if include_comments
178
+ result[:recent_comments] = creative.comments.order(created_at: :desc).limit(3).map do |comment|
179
+ {
180
+ content: ActionView::Base.full_sanitizer.sanitize(comment.content).strip.truncate(200),
181
+ user: comment.user&.display_name || comment.user&.name,
182
+ created_at: comment.created_at&.iso8601
183
+ }
126
184
  end
185
+ end
127
186
 
128
- {
129
- id: node[:id],
130
- description: description&.strip,
131
- progress: node.dig(:inline_editor_payload, :progress) || node[:progress],
132
- children: node.dig(:children_container, :nodes) ? filter_tree(node.dig(:children_container, :nodes)) : []
133
- }
187
+ if current_depth < depth
188
+ result[:children] = children.map do |child|
189
+ serialize_creative(child, depth: depth, current_depth: current_depth + 1, include_comments: include_comments)
190
+ end
191
+ else
192
+ result[:children] = []
134
193
  end
194
+
195
+ result
135
196
  end
136
197
  end
137
198
  end
@@ -5,6 +5,7 @@ module Tools
5
5
  class CreativeUpdateService
6
6
  extend T::Sig
7
7
  extend ToolMeta
8
+ include DescriptionNormalizable
8
9
 
9
10
  tool_name "creative_update_service"
10
11
  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."
@@ -108,14 +109,6 @@ module Tools
108
109
  end
109
110
 
110
111
  private
111
-
112
- def normalize_description(desc)
113
- return desc if desc.blank?
114
- # If it already looks like HTML, return as-is
115
- return desc if desc.strip.start_with?("<")
116
- # Otherwise wrap in <p> tags
117
- "<p>#{ERB::Util.html_escape(desc)}</p>"
118
- end
119
112
  end
120
113
  end
121
114
  end
@@ -23,7 +23,7 @@ module Tools
23
23
  unless creative.has_permission?(Current.user, :read)
24
24
  return { error: "No read permission on this Creative", id: creative_id }
25
25
  end
26
- tasks = tasks.where("key LIKE ?", "cron_#{creative_id}_%")
26
+ tasks = tasks.where("key LIKE ?", "cron_#{creative_id.to_i}_%")
27
27
  end
28
28
 
29
29
  results = tasks.filter_map do |task|
@@ -0,0 +1,16 @@
1
+ module Collavre
2
+ module Tools
3
+ module DescriptionNormalizable
4
+ private
5
+
6
+ def normalize_description(desc)
7
+ return desc if desc.blank?
8
+ stripped = desc.strip
9
+ # If it already looks like HTML, return as-is
10
+ return stripped if stripped.start_with?("<")
11
+ # Otherwise wrap in <p> tags
12
+ "<p>#{ERB::Util.html_escape(stripped)}</p>"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -57,26 +57,43 @@
57
57
  <%= t('collavre.comments.edit_button') %>
58
58
  </button>
59
59
  <% if comment.user&.ai_user? %>
60
- <button class="review-comment-btn" data-comment-target="reviewButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.review_button') %>">
60
+ <button class="review-comment-btn" data-action="click->comment#reviewClick" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.review_button') %>" data-hint-text="<%= t('collavre.comments.review_select_hint') %>">
61
61
  <%= t('collavre.comments.review_button') %>
62
62
  </button>
63
- <button class="replace-comment-btn" data-comment-target="replaceButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.replace_button') %>" disabled>
64
- <%= t('collavre.comments.replace_button') %>
65
- </button>
66
63
  <% end %>
67
64
  <button class="copy-comment-link-btn" data-comment-id="<%= comment.id %>" data-comment-url="<%= collavre.creative_comment_url(comment.creative, comment, Rails.application.config.action_mailer.default_url_options) %>" title="<%= t('collavre.comments.copy_link_button') %>">
68
65
  <%= t('collavre.comments.copy_link_button') %>
69
66
  </button>
70
- <button class="delete-comment-btn comment-delete-hidden" data-comment-target="deleteButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.delete') %>">
71
- <%= t('collavre.comments.delete_button') %>
72
- </button>
73
67
  </div>
74
68
  <% if comment.quoted_text.present? %>
75
69
  <div class="comment-quoted-block">
76
70
  <blockquote class="comment-quoted-text"><%= comment.quoted_text %></blockquote>
77
71
  </div>
78
72
  <% end %>
79
- <div class="comment-content"><%= comment.content %></div>
73
+ <div class="comment-content" data-comment-target="content"><%= comment.content %></div>
74
+ <% if comment.comment_versions.any? %>
75
+ <% version_count = comment.comment_versions.size %>
76
+ <% selected_id = comment.selected_version_id %>
77
+ <% version_ids = comment.comment_versions.order(:version_number).pluck(:id) %>
78
+ <% selected_index = selected_id ? (version_ids.index(selected_id)&.+(1) || version_count) : version_count %>
79
+ <div class="comment-version-navigator"
80
+ data-controller="comment-version"
81
+ data-comment-version-comment-id-value="<%= comment.id %>"
82
+ data-comment-version-creative-id-value="<%= comment.creative_id %>"
83
+ data-comment-version-versions-url-value="<%= collavre.creative_comment_versions_path(comment.creative, comment) %>"
84
+ data-comment-version-total-value="<%= version_count %>"
85
+ data-comment-version-initial-index-value="<%= selected_index %>"
86
+ data-comment-version-selected-version-id-value="<%= selected_id %>"
87
+ data-comment-version-content-target-value="<%= dom_id(comment) %>">
88
+ <button class="comment-version-btn" data-action="click->comment-version#prev" data-comment-version-target="prevBtn" title="<%= t('collavre.comments.versions.previous') %>">◀</button>
89
+ <span class="comment-version-indicator" data-comment-version-target="indicator">
90
+ v<%= selected_index %>/<%= version_count %>
91
+ </span>
92
+ <button class="comment-version-btn" data-action="click->comment-version#next" data-comment-version-target="nextBtn" title="<%= t('collavre.comments.versions.next') %>">▶</button>
93
+ <button class="comment-version-select-btn" data-action="click->comment-version#selectVersion" data-comment-version-target="selectBtn" title="<%= t('collavre.comments.versions.select') %>" disabled>✓ <%= t('collavre.comments.versions.select') %></button>
94
+ <button class="comment-version-delete-btn" data-action="click->comment-version#deleteVersion" data-comment-version-target="deleteBtn" data-confirm-message="<%= t('collavre.comments.versions.delete_confirm') %>" title="<%= t('collavre.comments.versions.delete') %>" disabled>✕</button>
95
+ </div>
96
+ <% end %>
80
97
 
81
98
 
82
99
  <% if comment.images.attached? %>
@@ -2,7 +2,7 @@
2
2
  <% fullscreen = local_assigns.fetch(:fullscreen, false) %>
3
3
  <% auto_fullscreen = local_assigns.fetch(:auto_fullscreen, false) %>
4
4
  <% creative = local_assigns[:creative] %>
5
- <div id="comments-popup" data-controller="comments--popup comments--list comments--form comments--presence comments--mention-menu comments--topics" class="popup-box"
5
+ <div id="comments-popup" data-controller="comments--popup comments--list comments--form comments--presence comments--mention-menu comments--topics comments--contexts share-modal" class="popup-box"
6
6
  data-fullscreen="<%= fullscreen %>"
7
7
  <% if auto_fullscreen %>data-auto-fullscreen="true"<% end %>
8
8
  data-fullscreen-label="<%= t('collavre.comments.fullscreen', default: 'Full screen') %>"
@@ -29,16 +29,38 @@
29
29
  data-voice-stop-text="<%= t('collavre.comments.voice_stop') %>"
30
30
  data-move-no-selection-text="<%= t('collavre.comments.move_no_selection') %>"
31
31
  data-move-error-text="<%= t('collavre.comments.move_error') %>"
32
- data-hint-drag-topic-text="<%= t('collavre.comments.hint_drag_topic') %>"
33
- data-hint-move-button-text="<%= t('collavre.comments.hint_move_button') %>"
32
+ data-selection-count-text="<%= t('collavre.comments.selection_count') %>"
33
+ data-selection-delete-text="<%= t('collavre.comments.selection_delete') %>"
34
+ data-selection-move-text="<%= t('collavre.comments.selection_move') %>"
35
+ data-selection-topic-move-text="<%= t('collavre.comments.selection_topic_move') %>"
36
+ data-selection-close-text="<%= t('collavre.comments.selection_close') %>"
37
+ data-selection-drag-hint-text="<%= t('collavre.comments.selection_drag_hint') %>"
38
+ data-batch-delete-confirm-text="<%= t('collavre.comments.batch_delete_confirm') %>"
39
+ data-topic-search-placeholder-text="<%= t('collavre.comments.topic_search_placeholder') %>"
40
+ data-topic-main-text="<%= t('collavre.comments.topic_main') %>"
34
41
  data-add-participant-text="<%= t('collavre.comments.add_participant') %>"
35
- data-review-button-text="<%= t('collavre.comments.review_button') %>">
42
+ data-review-button-text="<%= t('collavre.comments.review_button') %>"
43
+ data-review-feedback-placeholder="<%= t('collavre.comments.review_feedback_placeholder') %>"
44
+ data-review-summary-placeholder="<%= t('collavre.comments.review_summary_placeholder') %>"
45
+ data-review-add-quote="<%= t('collavre.comments.review_add_quote') %>"
46
+ data-review-send="<%= t('collavre.comments.review_send') %>"
47
+ data-review-send-question="<%= t('collavre.comments.review_send_question') %>"
48
+ data-review-type-review="💬"
49
+ data-review-type-question="❓"
50
+ data-review-type-review-label="<%= t('collavre.comments.review_type_review') %>"
51
+ data-review-type-question-label="<%= t('collavre.comments.review_type_question') %>">
36
52
  <div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
37
53
  <div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
38
54
  <div class="comments-popup-header">
39
55
  <h3 id="comments-popup-title" data-comments--popup-target="title"><%= fullscreen && creative.present? ? creative.creative_snippet : t('collavre.comments.comments') %></h3>
40
56
  <span data-integration-badges class="integration-badges"></span>
41
57
  <div class="comments-popup-actions">
58
+ <button class="comments-popup-action context-toggle-btn"
59
+ data-comments--contexts-target="toggleButton"
60
+ data-action="click->comments--contexts#toggleVisibility"
61
+ type="button"
62
+ title="<%= t('collavre.contexts.toggle_label', default: 'Contexts') %>"
63
+ style="display:none;">🔗</button>
42
64
  <button class="comments-popup-action comments-popup-fullscreen"
43
65
  data-comments--popup-target="fullscreenButton"
44
66
  data-action="click->comments--popup#toggleFullscreen"
@@ -54,6 +76,11 @@
54
76
  <button id="close-comments-btn" data-comments--popup-target="closeButton" class="popup-close-btn" type="button">&times;</button>
55
77
  </div>
56
78
  </div>
79
+ <div id="comment-contexts" data-comments--contexts-target="list" class="comment-contexts-list" style="display:none;"
80
+ data-inherited-label="<%= t('collavre.contexts.inherited_label', default: 'Inherited from parent') %>"
81
+ data-self-context-label="<%= t('collavre.contexts.self_context_label', default: 'Current creative context') %>"
82
+ data-navigate-label="<%= t('collavre.contexts.navigate_label', default: 'Go to creative') %>"></div>
83
+ <div data-share-modal-target="container"></div>
57
84
  <div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
58
85
  <div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
59
86
  data-confirm-delete-text="<%= t('collavre.topics.delete_confirm') %>"
@@ -69,6 +96,7 @@
69
96
  <span class="comment-quote-indicator-text" data-comments--form-target="quoteIndicatorText"></span>
70
97
  <button type="button" class="comment-quote-cancel" data-action="click->comments--form#cancelQuote" title="<%= t('app.cancel') %>">&times;</button>
71
98
  </div>
99
+ <div class="review-quotes-container" data-comments--form-target="reviewQuotesContainer" style="display:none;"></div>
72
100
  <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>
73
101
  <div class="comment-bottom">
74
102
  <input type="file" id="comment-images" name="comment[images][]" accept="image/*" multiple data-comments--form-target="imageInput" style="display:none;" />
@@ -77,7 +105,6 @@
77
105
  <button class="creative-action-btn" id="attach-image-btn" data-comments--form-target="imageButton" type="button"><%= t('collavre.comments.image_button') %></button>
78
106
  <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>
79
107
  <button class="creative-action-btn" id="search-comments-btn" data-comments--form-target="searchButton" type="button"><%= t('collavre.comments.search_button') %></button>
80
- <button class="creative-action-btn" id="move-comments-btn" data-comments--form-target="moveButton" type="button" disabled><%= t('collavre.comments.move_button') %></button>
81
108
  <button class="creative-action-btn" id="cancel-edit-btn" data-comments--form-target="cancel" type="button" style="display:none;"><%= t('app.cancel') %></button>
82
109
  <button class="creative-action-btn" type="submit" data-comments--form-target="submit"><%= svg_tag 'send.svg', class: 'send-icon' %></button>
83
110
  </div>
@@ -27,6 +27,9 @@
27
27
  data-children-alert-message="<%= t('collavre.creatives.index.progress_complete_children_alert') %>">
28
28
  <span id="inline-progress-value"><%= t('collavre.creatives.index.progress_incomplete') %></span>
29
29
  </label>
30
+ <button type="button" id="inline-metadata-btn" class="creative-action-btn" title="<%= t('collavre.creatives.index.metadata_tooltip') %>" style="margin-left:0.5em;font-family:monospace;font-size:0.9em;">
31
+ { }
32
+ </button>
30
33
  </div>
31
34
  <div style="margin-top:0.5em;">
32
35
  <button type="button" id="inline-move-up" class="creative-action-btn" title="<%= t('collavre.creatives.index.inline_move_up_tooltip') %>">
@@ -91,6 +94,16 @@
91
94
  </div>
92
95
  <button type="button" id="inline-recommend-parent" class="creative-action-btn" style="margin-top:0.5em;" title="<%= t('collavre.creatives.index.recommend_parent') %>">✨<%= t('collavre.creatives.index.recommend_parent') %></button>
93
96
  <select id="parent-suggestions" size="5" style="display:none;margin-top:0.5em;width:100%;"></select>
97
+ <div id="metadata-popup" style="display:none;" class="metadata-popup">
98
+ <div class="metadata-popup-header">
99
+ <span><%= t('collavre.creatives.index.metadata_title') %></span>
100
+ <button type="button" id="metadata-popup-close">&times;</button>
101
+ </div>
102
+ <textarea id="metadata-yaml-editor" rows="12"></textarea>
103
+ <button type="button" id="metadata-save-btn" class="creative-action-btn">
104
+ <%= t('collavre.creatives.index.metadata_save') %>
105
+ </button>
106
+ </div>
94
107
  </div>
95
108
  </form>
96
109
  </div>
@@ -1,3 +1,6 @@
1
- <button id="share-creative-btn" class="btn btn-primary desktop-only">
1
+ <% current_creative = @parent_creative || @creative %>
2
+ <button id="share-creative-btn" class="btn btn-primary desktop-only"
3
+ data-action="click->share-modal#open"
4
+ data-share-modal-url-param="<%= collavre.creative_creative_shares_path(current_creative) %>">
2
5
  <span aria-hidden="true"><%= svg_tag 'share.svg', class: 'icon-up', width: 22, height: 20 %></span>
3
6
  </button>