collavre 0.23.0 β†’ 0.24.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  3. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
  7. data/app/assets/stylesheets/collavre/landing.css +92 -0
  8. data/app/assets/stylesheets/collavre/popup.css +4 -2
  9. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  10. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  11. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  12. data/app/controllers/collavre/attachments_controller.rb +13 -3
  13. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  14. data/app/controllers/collavre/creatives_controller.rb +32 -99
  15. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  16. data/app/controllers/collavre/landing_controller.rb +5 -2
  17. data/app/controllers/collavre/topics_controller.rb +5 -0
  18. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  19. data/app/errors/collavre/api_error.rb +20 -0
  20. data/app/helpers/collavre/creatives_helper.rb +30 -8
  21. data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
  22. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  23. data/app/javascript/components/creative_tree_row.js +8 -2
  24. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  25. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  26. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  27. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +47 -0
  29. data/app/javascript/controllers/common_popup_controller.js +2 -2
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/search_popup_controller.js +17 -1
  32. data/app/javascript/controllers/topic_list_controller.js +78 -0
  33. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  34. data/app/javascript/lib/common_popup.js +24 -5
  35. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  36. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  37. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  38. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  39. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  40. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  41. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  42. data/app/javascript/modules/creative_inline_payload.js +86 -0
  43. data/app/javascript/modules/creative_row_editor.js +121 -401
  44. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  45. data/app/javascript/modules/creative_save_queue.js +95 -0
  46. data/app/javascript/modules/creative_tree_dom.js +260 -0
  47. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  48. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  49. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  50. data/app/models/collavre/channel.rb +16 -0
  51. data/app/models/collavre/comment/approvable.rb +16 -0
  52. data/app/models/collavre/comment/notifiable.rb +1 -1
  53. data/app/models/collavre/comment.rb +8 -1
  54. data/app/models/collavre/comment_version.rb +1 -1
  55. data/app/models/collavre/creative/describable.rb +15 -9
  56. data/app/models/collavre/creative/linkable.rb +9 -0
  57. data/app/models/collavre/creative/permissible.rb +97 -49
  58. data/app/models/collavre/creative.rb +73 -5
  59. data/app/models/collavre/creative_share.rb +78 -26
  60. data/app/models/collavre/inbox_item.rb +2 -2
  61. data/app/models/collavre/task.rb +12 -0
  62. data/app/models/collavre/user.rb +33 -9
  63. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  64. data/app/services/collavre/agent_type_classifier.rb +23 -0
  65. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  66. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  67. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  68. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  69. data/app/services/collavre/ai_client.rb +53 -2
  70. data/app/services/collavre/auto_theme_generator.rb +14 -10
  71. data/app/services/collavre/creatives/children_index.rb +86 -0
  72. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  73. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  74. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  75. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  76. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  77. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  78. data/app/services/collavre/http_client.rb +119 -0
  79. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  80. data/app/services/collavre/markdown_importer.rb +30 -6
  81. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  82. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  83. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  84. data/app/services/collavre/system_events/context_builder.rb +1 -14
  85. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  86. data/app/views/collavre/comments/_comment.html.erb +4 -4
  87. data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
  88. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  89. data/app/views/collavre/landing/show.html.erb +52 -10
  90. data/config/locales/comments.en.yml +1 -0
  91. data/config/locales/comments.ko.yml +1 -0
  92. data/config/locales/creatives.en.yml +6 -0
  93. data/config/locales/creatives.ko.yml +5 -0
  94. data/config/locales/landing.en.yml +22 -10
  95. data/config/locales/landing.ko.yml +22 -10
  96. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  97. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  98. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  99. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  100. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  101. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  102. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  103. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  104. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  105. data/lib/collavre/version.rb +1 -1
  106. metadata +42 -1
@@ -1,5 +1,5 @@
1
1
  module Collavre
2
- require "open-uri"
2
+ require "net/http"
3
3
  require "nokogiri"
4
4
  require "uri"
5
5
  require "ipaddr"
@@ -29,18 +29,22 @@ module Collavre
29
29
  IPAddr.new("ff00::/8")
30
30
  ].freeze
31
31
 
32
+ # Minimal HTTP result the fetcher reasons about. `body` is only populated for
33
+ # successful (2xx) responses; redirects carry a `location` instead.
34
+ Response = Struct.new(:code, :content_type, :body, :location, keyword_init: true)
35
+
32
36
  def self.fetch(url)
33
37
  new(url).fetch
34
38
  end
35
39
 
36
- def initialize(url, io_opener: URI, logger: Rails.logger)
40
+ def initialize(url, http_client: nil, logger: Rails.logger)
37
41
  @url = url
38
- @io_opener = io_opener
42
+ @http_client = http_client || PinnedHttpClient.new
39
43
  @logger = logger
40
44
  end
41
45
 
42
46
  def fetch
43
- uri = safe_http_uri
47
+ uri = parse_http_uri(@url)
44
48
  return {} unless uri
45
49
 
46
50
  html, base_uri = read_html(uri)
@@ -56,47 +60,53 @@ module Collavre
56
60
  private
57
61
 
58
62
  def read_html(uri, redirect_limit = MAX_REDIRECTS)
59
- html = nil
60
- base_uri = nil
61
- options = REQUEST_OPTIONS.merge(read_timeout: READ_TIMEOUT, open_timeout: OPEN_TIMEOUT, redirect: false)
62
- @io_opener.open(uri.to_s, options) do |io|
63
- content_type = io.respond_to?(:content_type) ? io.content_type : nil
64
- if content_type && HTML_CONTENT_TYPES.none? { |type| content_type.include?(type) }
65
- return [ nil, nil ]
66
- end
67
- base_uri = io.respond_to?(:base_uri) ? io.base_uri : uri
68
- html = io.read(MAX_BYTES)
63
+ # Resolve+validate the host ONCE per hop and pin the request to that exact
64
+ # IP. This closes the DNS-rebinding TOCTOU gap: a hostname that validates
65
+ # as public here can no longer be re-resolved to a private/metadata IP at
66
+ # connect time, because the connection targets the pinned address (with the
67
+ # original hostname preserved for Host/SNI).
68
+ addresses = safe_addresses(uri)
69
+ return [ nil, nil ] if addresses.empty?
70
+
71
+ response = fetch_via_pinned_addresses(uri, addresses)
72
+ return [ nil, nil ] unless response
73
+
74
+ if redirect?(response.code)
75
+ return [ nil, nil ] if redirect_limit <= 0
76
+
77
+ redirected_uri = normalize_redirect_uri(uri, response.location)
78
+ return [ nil, nil ] unless redirected_uri
79
+
80
+ # Recurse so the new host is resolved+validated+pinned from scratch;
81
+ # never follow a hop to an internal address.
82
+ return read_html(redirected_uri, redirect_limit - 1)
69
83
  end
70
- [ html, base_uri ]
71
- rescue OpenURI::HTTPRedirect => e
72
- return [ nil, nil ] if redirect_limit <= 0
73
84
 
74
- redirected_uri = safe_redirect_uri(uri, e.uri)
75
- return [ nil, nil ] unless redirected_uri
85
+ return [ nil, nil ] unless success?(response.code)
76
86
 
77
- read_html(redirected_uri, redirect_limit - 1)
78
- rescue OpenURI::HTTPError, SocketError, IOError, SystemCallError, URI::InvalidURIError => e
87
+ content_type = response.content_type
88
+ if content_type && HTML_CONTENT_TYPES.none? { |type| content_type.include?(type) }
89
+ return [ nil, nil ]
90
+ end
91
+
92
+ [ response.body, uri ]
93
+ rescue SocketError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
94
+ OpenSSL::SSL::SSLError, URI::InvalidURIError => e
79
95
  @logger&.info("Link preview fetch skipped for #{@url}: #{e.class} #{e.message}")
80
96
  [ nil, nil ]
81
97
  end
82
98
 
83
- def safe_http_uri
84
- uri = parse_http_uri(@url)
85
- return unless uri
86
- return unless allowed_destination?(uri)
87
-
88
- uri
99
+ def redirect?(code)
100
+ code.to_i.between?(300, 399)
89
101
  end
90
102
 
91
- def safe_redirect_uri(current_uri, redirected)
92
- new_uri = normalize_redirect_uri(current_uri, redirected)
93
- return unless new_uri
94
- return unless allowed_destination?(new_uri)
95
-
96
- new_uri
103
+ def success?(code)
104
+ code.to_i.between?(200, 299)
97
105
  end
98
106
 
99
107
  def normalize_redirect_uri(current_uri, redirected)
108
+ return if redirected.blank?
109
+
100
110
  target_uri = redirected.is_a?(URI) ? redirected : URI.parse(redirected.to_s)
101
111
  target_uri = current_uri.merge(target_uri) if target_uri.relative?
102
112
  return unless %w[http https].include?(target_uri.scheme)
@@ -116,15 +126,45 @@ module Collavre
116
126
  nil
117
127
  end
118
128
 
119
- def allowed_destination?(uri)
129
+ # Resolve the host, reject if it maps to ANY unsafe address, and return every
130
+ # validated public IP to pin against. Rejecting when any resolved address is
131
+ # unsafe (not just the one we pick) keeps defense-in-depth against
132
+ # split-horizon DNS returning a mix of public and private records. Returning
133
+ # all safe addresses lets the caller fall back to the next one when the first
134
+ # is unreachable (e.g. an AAAA record with no IPv6 egress).
135
+ def safe_addresses(uri)
120
136
  host = uri.hostname
121
- return false if host.nil? || host.empty?
137
+ return [] if host.nil? || host.empty?
122
138
 
123
139
  addresses = resolve_addresses(host)
124
- return false if addresses.empty?
125
- return false if addresses.any? { |address| unsafe_ip?(address) }
140
+ return [] if addresses.empty?
141
+ return [] if addresses.any? { |address| unsafe_ip?(address) }
126
142
 
127
- true
143
+ addresses
144
+ end
145
+
146
+ # Try each pre-validated address in turn, pinning the connection to it, and
147
+ # return the first response we can actually open. Connection-level failures
148
+ # fall through to the next candidate; if none connect, the caller treats it
149
+ # as an empty preview. Every address here already passed safe_addresses, so
150
+ # falling back never targets an unsafe IP.
151
+ def fetch_via_pinned_addresses(uri, addresses)
152
+ last_error = nil
153
+ addresses.each do |address|
154
+ return @http_client.get(
155
+ uri,
156
+ ip: address,
157
+ headers: REQUEST_OPTIONS,
158
+ open_timeout: OPEN_TIMEOUT,
159
+ read_timeout: READ_TIMEOUT,
160
+ max_bytes: MAX_BYTES
161
+ )
162
+ rescue SocketError, IOError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout,
163
+ OpenSSL::SSL::SSLError => e
164
+ last_error = e
165
+ end
166
+ @logger&.info("Link preview fetch skipped for #{@url}: #{last_error.class} #{last_error.message}") if last_error
167
+ nil
128
168
  end
129
169
 
130
170
  def resolve_addresses(host)
@@ -226,5 +266,56 @@ module Collavre
226
266
 
227
267
  text.to_s.gsub(/\s+/, " ").strip
228
268
  end
269
+
270
+ # Performs a single (non-redirect-following) GET against a pre-resolved IP
271
+ # while keeping the original hostname for the Host header and TLS SNI/cert
272
+ # verification. Net::HTTP#ipaddr= pins the socket to `ip`, so no second DNS
273
+ # lookup happens between validation and connect. Enforces the caller's open/
274
+ # read timeouts and byte cap.
275
+ class PinnedHttpClient
276
+ def get(uri, ip:, headers:, open_timeout:, read_timeout:, max_bytes:)
277
+ # Pass an explicit nil proxy: Net::HTTP.new defaults p_addr to :ENV, so a
278
+ # set http_proxy/HTTP_PROXY would route through the proxy, which resolves
279
+ # the hostname itself and defeats `http.ipaddr = ip` pinning β€” reopening
280
+ # the DNS-rebinding/SSRF gap. nil forces a direct, pinned connection.
281
+ http = Net::HTTP.new(uri.hostname, uri.port, nil)
282
+ http.use_ssl = uri.scheme == "https"
283
+ http.ipaddr = ip
284
+ http.open_timeout = open_timeout
285
+ http.read_timeout = read_timeout
286
+
287
+ request = Net::HTTP::Get.new(uri, headers)
288
+ response_struct = nil
289
+
290
+ http.start do |conn|
291
+ conn.request(request) do |response|
292
+ body = read_capped_body(response, max_bytes)
293
+ response_struct = Response.new(
294
+ code: response.code.to_i,
295
+ content_type: response.content_type,
296
+ body: body,
297
+ location: response["location"]
298
+ )
299
+ end
300
+ end
301
+
302
+ response_struct
303
+ ensure
304
+ http&.finish if http&.started?
305
+ end
306
+
307
+ private
308
+
309
+ def read_capped_body(response, max_bytes)
310
+ return nil unless response.is_a?(Net::HTTPSuccess)
311
+
312
+ body = +""
313
+ response.read_body do |chunk|
314
+ body << chunk
315
+ break if body.bytesize >= max_bytes
316
+ end
317
+ body.byteslice(0, max_bytes)
318
+ end
319
+ end
229
320
  end
230
321
  end
@@ -20,6 +20,12 @@ module Collavre
20
20
  end
21
21
  end
22
22
  created = []
23
+ # Per-parent sequence counters. closure_tree orders siblings by the
24
+ # `sequence` column (default 0), but plain Creative.create never assigns
25
+ # it β€” so without this every imported sibling would share sequence 0 and
26
+ # fall back to a DB-defined tie-break (stable on SQLite, arbitrary on
27
+ # Postgres), corrupting document order. Assign contiguous values here.
28
+ seq = {}
23
29
  root = parent
24
30
  i = 0
25
31
  if create_root
@@ -28,7 +34,7 @@ module Collavre
28
34
  end
29
35
  if i < lines.size && lines[i] !~ /^\s*#/ && lines[i] !~ /^\s*[-*+]/
30
36
  page_title = lines[i].strip
31
- root = Creative.create(user: user, parent: parent, description: page_title)
37
+ root = create_child(user: user, parent: parent, description: page_title, seq: seq)
32
38
  created << root
33
39
  i += 1
34
40
  end
@@ -73,7 +79,7 @@ module Collavre
73
79
  code_html = build_code_block_html(code_content, fence_info)
74
80
 
75
81
  new_parent = stack.any? ? stack.last[1] : root
76
- c = Creative.create(user: user, parent: new_parent, description: code_html)
82
+ c = create_child(user: user, parent: new_parent, description: code_html, seq: seq)
77
83
  created << c
78
84
  next
79
85
  end
@@ -81,7 +87,7 @@ module Collavre
81
87
  if (table_data = parse_markdown_table(lines, i))
82
88
  table_html = build_table_html(table_data, image_refs)
83
89
  new_parent = stack.any? ? stack.last[1] : root
84
- c = Creative.create(user: user, parent: new_parent, description: table_html)
90
+ c = create_child(user: user, parent: new_parent, description: table_html, seq: seq)
85
91
  created << c
86
92
  i = table_data[:next_index]
87
93
  elsif line =~ /^(#+)\s+(.*)$/
@@ -89,7 +95,7 @@ module Collavre
89
95
  desc = ApplicationController.helpers.markdown_links_to_html($2.strip, image_refs)
90
96
  stack.pop while stack.any? && stack.last[0] >= level
91
97
  new_parent = stack.any? ? stack.last[1] : root
92
- c = Creative.create(user: user, parent: new_parent, description: desc)
98
+ c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
93
99
  created << c
94
100
  stack << [ level, c ]
95
101
  i += 1
@@ -99,14 +105,14 @@ module Collavre
99
105
  bullet_level = 10 + indent / 2
100
106
  stack.pop while stack.any? && stack.last[0] >= bullet_level
101
107
  new_parent = stack.any? ? stack.last[1] : root
102
- c = Creative.create(user: user, parent: new_parent, description: desc)
108
+ c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
103
109
  created << c
104
110
  stack << [ bullet_level, c ]
105
111
  i += 1
106
112
  elsif !line.strip.empty?
107
113
  desc = ApplicationController.helpers.markdown_links_to_html(line.strip, image_refs)
108
114
  new_parent = stack.any? ? stack.last[1] : root
109
- c = Creative.create(user: user, parent: new_parent, description: desc)
115
+ c = create_child(user: user, parent: new_parent, description: desc, seq: seq)
110
116
  created << c
111
117
  i += 1
112
118
  else
@@ -121,6 +127,24 @@ module Collavre
121
127
  created
122
128
  end
123
129
 
130
+ # Creates a child under `parent` with an explicit `sequence`, tracking the
131
+ # next value per parent in `seq`. The first time a parent is seen the
132
+ # counter is seeded past any pre-existing children so an import into an
133
+ # already-populated parent appends after them instead of colliding at 0.
134
+ # A nil parent means a root-level import (top-level page with no parent_id);
135
+ # closure_tree orders roots by `sequence` too, so seed from Creative.roots.
136
+ def self.create_child(user:, parent:, description:, seq:)
137
+ key = parent&.id
138
+ unless seq.key?(key)
139
+ siblings = parent ? parent.children : Creative.roots
140
+ max = siblings.maximum(:sequence)
141
+ seq[key] = max ? max + 1 : 0
142
+ end
143
+ creative = Creative.create(user: user, parent: parent, description: description, sequence: seq[key])
144
+ seq[key] += 1
145
+ creative
146
+ end
147
+
124
148
  def self.build_code_block_html(code_content, language = nil)
125
149
  escaped_content = ERB::Util.html_escape(code_content)
126
150
  lang_class = language.present? ? " class=\"language-#{ERB::Util.html_escape(language)}\"" : ""
@@ -29,7 +29,7 @@ module Collavre
29
29
  end
30
30
 
31
31
  def parse_action(comment)
32
- return nil if comment.action.blank?
32
+ return nil unless comment.approval_action?
33
33
 
34
34
  JSON.parse(comment.action)
35
35
  rescue JSON::ParserError
@@ -126,24 +126,10 @@ module Collavre
126
126
  "id" => @agent.id,
127
127
  "name" => @agent.name,
128
128
  "display_name" => @agent.display_name,
129
- "type" => extract_agent_type
129
+ "type" => AgentTypeClassifier.classify(@agent)
130
130
  }
131
131
  end
132
132
 
133
- def extract_agent_type
134
- # Extract agent type from system_prompt or default
135
- prompt = @agent.system_prompt.to_s.downcase
136
- case prompt
137
- when /developer|개발/ then "developer"
138
- when /pm|project.?manager|ν”„λ‘œμ νŠΈ/ then "pm"
139
- when /qa|test|quality|ν…ŒμŠ€νŠΈ|ν’ˆμ§ˆ/ then "qa"
140
- when /research|쑰사|연ꡬ/ then "researcher"
141
- when /market|λ§ˆμΌ€νŒ…/ then "marketer"
142
- when /plan|기획/ then "planner"
143
- else "agent"
144
- end
145
- end
146
-
147
133
  def build_collaborators
148
134
  return [] unless @creative
149
135
 
@@ -64,7 +64,7 @@ module Collavre
64
64
  return unless creative_id && context&.key?("topic")
65
65
 
66
66
  topic_id = context.dig("topic", "id")
67
- scope = Comment.public_only
67
+ scope = Comment.public_only.without_approval_action
68
68
  .where(creative_id: creative_id, topic_id: topic_id)
69
69
  .where.not(user_id: [ task.agent_id, nil ])
70
70
  .order(created_at: :desc)
@@ -34,23 +34,10 @@ module Collavre
34
34
  "name" => user.name,
35
35
  "display_name" => user.respond_to?(:display_name) ? user.display_name : user.name,
36
36
  "is_ai" => user.ai_user?,
37
- "type" => user.ai_user? ? extract_agent_type(user) : "human"
37
+ "type" => user.ai_user? ? AgentTypeClassifier.classify(user) : "human"
38
38
  }
39
39
  end
40
40
 
41
- def extract_agent_type(user)
42
- prompt = user.system_prompt.to_s.downcase
43
- case prompt
44
- when /developer|개발/ then "developer"
45
- when /pm|project.?manager|ν”„λ‘œμ νŠΈ/ then "pm"
46
- when /qa|test|quality|ν…ŒμŠ€νŠΈ|ν’ˆμ§ˆ/ then "qa"
47
- when /research|쑰사|연ꡬ/ then "researcher"
48
- when /market|λ§ˆμΌ€νŒ…/ then "marketer"
49
- when /plan|기획/ then "planner"
50
- else "agent"
51
- end
52
- end
53
-
54
41
  def mentioned_user(chat_context)
55
42
  content = chat_context["content"]
56
43
  return nil unless content
@@ -113,15 +113,13 @@ module Tools
113
113
  raise ArgumentError, "preview_url is not a valid URI: #{preview_url.inspect}"
114
114
  end
115
115
 
116
- # config is JSON, so worktree_id matching is a Ruby-side scan over the
117
- # topic's preview channels. With only a handful of previews per topic in
118
- # practice this is fine and avoids JSON operator divergence between
119
- # Postgres (config->>'worktree_id') and SQLite (json_extract).
116
+ # worktree_id is a real column denormalized from config (see
117
+ # Collavre::Channel#sync_indexed_config_columns), so the lookup is an
118
+ # indexed query β€” it also backs the (topic_id, worktree_id) unique index
119
+ # that makes attach idempotent.
120
120
  sig { params(topic: Collavre::Topic, worktree_id: String).returns(T.nilable(Collavre::PreviewChannel)) }
121
121
  def lookup_channel(topic, worktree_id)
122
- Collavre::PreviewChannel.where(topic_id: topic.id).find do |c|
123
- c.worktree_id.to_s == worktree_id.to_s
124
- end
122
+ Collavre::PreviewChannel.find_by(topic_id: topic.id, worktree_id: worktree_id)
125
123
  end
126
124
  end
127
125
  end
@@ -1,6 +1,6 @@
1
1
  <% comment_topic = comment.topic %>
2
2
  <% current_topic_id = local_assigns[:current_topic_id] %>
3
- <% has_pending_action = comment.action.present? && comment.action_executed_at.blank? %>
3
+ <% has_pending_action = comment.approval_action? && comment.action_executed_at.blank? %>
4
4
  <% approver_id = comment.approver_id %>
5
5
  <div class="comment-item" id="<%= dom_id(comment) %>" data-controller="comment" data-user-id="<%= comment.user&.id %>" data-comment-id="<%= comment.id %>" data-topic-id="<%= comment_topic&.id %>" data-creative-id="<%= comment.creative_id %>" data-creative-owner-id="<%= comment.creative&.user_id %>" data-has-pending-action="<%= has_pending_action %>" data-approver-id="<%= approver_id %>" data-ai-user="<%= comment.user&.ai_user? %>" data-streaming="<%= local_assigns.fetch(:streaming, false) %>">
6
6
  <div class="comment-select">
@@ -90,8 +90,8 @@
90
90
  </div>
91
91
  <% end %>
92
92
  <div class="comment-content" data-comment-target="content"><%= comment.content %></div>
93
- <% if comment.comment_versions.any? %>
94
- <% version_count = comment.comment_versions.size %>
93
+ <% if comment.comment_versions_count > 0 %>
94
+ <% version_count = comment.comment_versions_count %>
95
95
  <% selected_id = comment.selected_version_id %>
96
96
  <% version_ids = comment.comment_versions.order(:version_number).pluck(:id) %>
97
97
  <% selected_index = selected_id ? (version_ids.index(selected_id)&.+(1) || version_count) : version_count %>
@@ -173,7 +173,7 @@
173
173
  <a href="#" class="comment-topic-switch" data-topic-id="<%= comment_topic.id %>">#<%= comment_topic.name %></a>
174
174
  </div>
175
175
  <% end %>
176
- <% if comment.action.present? %>
176
+ <% if comment.approval_action? %>
177
177
  <div class="comment-action-block" data-comment-id="<%= comment.id %>">
178
178
  <details class="comment-action-details">
179
179
  <summary class="comment-action-summary"><%= t("collavre.comments.action_summary") %></summary>
@@ -125,9 +125,17 @@
125
125
  data-action="dragover->comments--contexts#handleExternalDragOver drop->comments--contexts#handleExternalDrop dragleave->comments--contexts#handleExternalDragLeave"></div>
126
126
  <div data-share-modal-target="container"></div>
127
127
  <div id="comment-participants" data-comments--presence-target="participants" data-comments--mention-menu-target="participants"></div>
128
- <div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
129
- data-confirm-delete-text="<%= t('collavre.topics.delete_confirm') %>"
130
- data-new-topic-placeholder="<%= t('collavre.topics.new_placeholder') %>"></div>
128
+ <div class="comment-topics-bar">
129
+ <div id="comment-topics" data-comments--topics-target="list" class="comment-topics-list"
130
+ data-confirm-delete-text="<%= t('collavre.topics.delete_confirm') %>"
131
+ data-new-topic-placeholder="<%= t('collavre.topics.new_placeholder') %>"></div>
132
+ <button type="button" class="topic-list-btn"
133
+ data-action="click->comments--topics#openTopicListPopup"
134
+ title="<%= t('collavre.comments.topic_list') %>"
135
+ aria-label="<%= t('collavre.comments.topic_list') %>">
136
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
137
+ </button>
138
+ </div>
131
139
  <%= render Collavre::UserMentionMenuComponent.new(menu_id: 'mention-menu') %>
132
140
  <%= render Collavre::CommandMenuComponent.new(menu_id: 'command-menu') %>
133
141
  <div id="comments-list" data-comments--popup-target="list" data-comments--list-target="list"><%= t('app.loading') %></div>
@@ -50,6 +50,15 @@
50
50
  style="margin-left:0.5em;font-family:monospace;font-size:0.9em;">
51
51
  <%= t('collavre.creatives.index.toggle_markdown') %>
52
52
  </button>
53
+ <span id="inline-save-status"
54
+ class="inline-save-status"
55
+ role="status"
56
+ aria-live="polite"
57
+ data-label-pending="<%= t('collavre.creatives.index.save_status_pending') %>"
58
+ data-label-saving="<%= t('collavre.creatives.index.save_status_saving') %>"
59
+ data-label-saved="<%= t('collavre.creatives.index.save_status_saved') %>"
60
+ data-label-error="<%= t('collavre.creatives.index.save_status_error') %>"
61
+ style="margin-left:auto;font-size:0.85em;color:var(--text-secondary);"></span>
53
62
  </div>
54
63
  <div style="margin-top:0.5em;">
55
64
  <button type="button" id="inline-move-up" class="creative-action-btn" title="<%= t('collavre.creatives.index.inline_move_up_tooltip') %>">
@@ -19,6 +19,7 @@
19
19
  <section id="problem" class="landing-section landing-problem">
20
20
  <div class="landing-container">
21
21
  <h2 class="landing-section-title"><%= t('collavre.landing.problem.title') %></h2>
22
+ <p class="landing-section-sub"><%= t('collavre.landing.problem.subtitle') %></p>
22
23
  <div class="landing-problem-grid">
23
24
  <div class="landing-problem-card">
24
25
  <span class="landing-problem-icon">πŸ“</span>
@@ -52,40 +53,77 @@
52
53
  <p><%= t('collavre.landing.features.tree.desc') %></p>
53
54
  </div>
54
55
 
55
- <div class="landing-feature-card">
56
- <div class="landing-feature-icon">πŸ€–</div>
57
- <h3><%= t('collavre.landing.features.ai.title') %></h3>
58
- <p><%= t('collavre.landing.features.ai.desc') %></p>
59
- </div>
60
-
61
56
  <div class="landing-feature-card">
62
57
  <div class="landing-feature-icon">πŸ’‘</div>
63
58
  <h3><%= t('collavre.landing.features.context.title') %></h3>
64
59
  <p><%= t('collavre.landing.features.context.desc') %></p>
65
60
  </div>
66
61
 
62
+ <div class="landing-feature-card">
63
+ <div class="landing-feature-icon">πŸ€–</div>
64
+ <h3><%= t('collavre.landing.features.ai.title') %></h3>
65
+ <p><%= t('collavre.landing.features.ai.desc') %></p>
66
+ </div>
67
+
67
68
  <div class="landing-feature-card">
68
69
  <div class="landing-feature-icon">πŸ’¬</div>
69
70
  <h3><%= t('collavre.landing.features.chat.title') %></h3>
70
71
  <p><%= t('collavre.landing.features.chat.desc') %></p>
71
72
  </div>
72
73
 
74
+ </div>
75
+ </div>
76
+ </section>
73
77
 
78
+ <%# ═════════════════════════════════════ THE INTERFACE ══════════════════════════════════════ %>
79
+ <%# A still before the film: the reader decides in two seconds whether this is a surface they
80
+ would write in, and a video they have not pressed play on cannot answer that. %>
81
+ <section id="interface" class="landing-section landing-interface">
82
+ <div class="landing-container">
83
+ <h2 class="landing-section-title"><%= t('collavre.landing.interface.title') %></h2>
84
+ <p class="landing-section-sub"><%= t('collavre.landing.interface.subtitle') %></p>
85
+
86
+ <div class="landing-shot-frame landing-shot-light">
87
+ <%= image_tag "collavre/landing/interface-#{I18n.locale == :ko ? 'ko' : 'en'}.png",
88
+ class: "landing-shot-img",
89
+ alt: t('collavre.landing.interface.alt'), loading: "lazy" %>
74
90
  </div>
91
+ <div class="landing-shot-frame landing-shot-dark">
92
+ <%= image_tag "collavre/landing/interface-#{I18n.locale == :ko ? 'ko' : 'en'}-dark.png",
93
+ class: "landing-shot-img",
94
+ alt: t('collavre.landing.interface.alt'), loading: "lazy" %>
95
+ </div>
96
+
97
+ <ul class="landing-interface-points">
98
+ <li><%= t('collavre.landing.interface.markdown') %></li>
99
+ <li><%= t('collavre.landing.interface.rich') %></li>
100
+ <li><%= t('collavre.landing.interface.drag') %></li>
101
+ <li><%= t('collavre.landing.interface.presence') %></li>
102
+ </ul>
75
103
  </div>
76
104
  </section>
77
105
 
78
106
  <%# ══════════════════════════════════════════ DEMO ══════════════════════════════════════════ %>
107
+ <%# Demo video/poster URLs are full absolute URLs stored per-locale in
108
+ config/locales/landing.*.yml. They point at the hosted assets directly, so
109
+ they resolve from any environment (preview included) with no DB lookup and
110
+ no env-bound blob ids. Render the section only when a video URL is set. %>
111
+ <% demo_video = t('collavre.landing.demo.video', default: nil) %>
112
+ <% demo_poster = t('collavre.landing.demo.poster', default: nil) %>
113
+ <% demo_video_dark = t('collavre.landing.demo.video_dark', default: nil) %>
114
+ <% demo_poster_dark = t('collavre.landing.demo.poster_dark', default: nil) %>
115
+ <% if demo_video.present? || demo_video_dark.present? %>
79
116
  <section id="demo" class="landing-section landing-demo">
80
117
  <div class="landing-container">
81
118
  <h2 class="landing-section-title"><%= t('collavre.landing.demo.title') %></h2>
82
119
  <%# Light mode video %>
120
+ <% if demo_video.present? %>
83
121
  <div class="landing-demo-frame landing-demo-light" data-controller="landing-video">
84
122
  <video class="landing-demo-video"
85
123
  data-landing-video-target="video"
86
124
  autoplay muted loop playsinline
87
- poster="/public-assets/blobs/eyJfcmFpbHMiOnsiZGF0YSI6NDEyLCJwdXIiOiJibG9iX2lkIn19--33811afcd451cef0cf10313fa1bfecca15b41299/demo-poster.jpg">
88
- <source src="/public-assets/blobs/eyJfcmFpbHMiOnsiZGF0YSI6NDEwLCJwdXIiOiJibG9iX2lkIn19--c272341a19ed8e847208c52da0617ebc801e2e67/demo.mp4" type="video/mp4">
125
+ poster="<%= demo_poster %>">
126
+ <source src="<%= demo_video %>" type="video/mp4">
89
127
  </video>
90
128
  <div class="landing-demo-progress" data-landing-video-target="progressBar">
91
129
  <div class="landing-demo-progress-fill" data-landing-video-target="progressFill"></div>
@@ -94,13 +132,15 @@
94
132
  <span class="landing-demo-toggle-icon" data-landing-video-target="icon">❚❚</span>
95
133
  </button>
96
134
  </div>
135
+ <% end %>
97
136
  <%# Dark mode video %>
137
+ <% if demo_video_dark.present? %>
98
138
  <div class="landing-demo-frame landing-demo-dark" data-controller="landing-video">
99
139
  <video class="landing-demo-video"
100
140
  data-landing-video-target="video"
101
141
  autoplay muted loop playsinline
102
- poster="/public-assets/blobs/eyJfcmFpbHMiOnsiZGF0YSI6NDEzLCJwdXIiOiJibG9iX2lkIn19--53c3c505596bca0befbbebb5958764a153ffee5e/demo-dark-poster.jpg">
103
- <source src="/public-assets/blobs/eyJfcmFpbHMiOnsiZGF0YSI6NDExLCJwdXIiOiJibG9iX2lkIn19--758c90c311bb68f46a2bda87553e3cdfd800420b/demo-dark.mp4" type="video/mp4">
142
+ poster="<%= demo_poster_dark %>">
143
+ <source src="<%= demo_video_dark %>" type="video/mp4">
104
144
  </video>
105
145
  <div class="landing-demo-progress" data-landing-video-target="progressBar">
106
146
  <div class="landing-demo-progress-fill" data-landing-video-target="progressFill"></div>
@@ -109,8 +149,10 @@
109
149
  <span class="landing-demo-toggle-icon" data-landing-video-target="icon">❚❚</span>
110
150
  </button>
111
151
  </div>
152
+ <% end %>
112
153
  </div>
113
154
  </section>
155
+ <% end %>
114
156
 
115
157
  <%# ═══════════════════════════════════════ FINAL CTA ════════════════════════════════════════ %>
116
158
  <section class="landing-section landing-cta">
@@ -140,6 +140,7 @@ en:
140
140
  batch_delete_not_found: Some selected messages could not be found.
141
141
  topic_search_placeholder: Search topics...
142
142
  topic_main: All Messages
143
+ topic_list: Topic list
143
144
  fullscreen: Full screen
144
145
  exit_fullscreen: Exit full screen
145
146
  typo_keep_label: keep
@@ -137,6 +137,7 @@ ko:
137
137
  batch_delete_not_found: 일뢀 μ„ νƒν•œ λ©”μ‹œμ§€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.
138
138
  topic_search_placeholder: ν† ν”½ 검색...
139
139
  topic_main: 전체 λ©”μ„Έμ§€
140
+ topic_list: ν† ν”½ λͺ©λ‘
140
141
  fullscreen: 전체 ν™”λ©΄
141
142
  exit_fullscreen: 전체 ν™”λ©΄ μ’…λ£Œ
142
143
  typo_keep_label: μœ μ§€
@@ -136,6 +136,10 @@ en:
136
136
  metadata_save: Save
137
137
  toggle_markdown: Markdown
138
138
  toggle_richtext: Rich Text
139
+ save_status_pending: Save pending
140
+ save_status_saving: Saving…
141
+ save_status_saved: Saved
142
+ save_status_error: Save failed
139
143
  markdown_to_richtext_confirm: Switching to Rich Text will discard the Markdown
140
144
  source. Continue?
141
145
  richtext_to_markdown_confirm: Switching to Markdown will discard the current
@@ -156,6 +160,8 @@ en:
156
160
  errors:
157
161
  no_permission: You do not have permission to perform this action.
158
162
  metadata_must_be_object: Metadata must be an object.
163
+ description_read_only_source: cannot be changed directly for read-only source
164
+ content
159
165
  inline_editor:
160
166
  placeholder: Describe the creative…
161
167
  markdown_placeholder: Write in Markdown…