collavre 0.16.0 → 0.20.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +65 -5
  3. data/app/assets/stylesheets/collavre/creatives.css +5 -2
  4. data/app/controllers/collavre/admin/settings_controller.rb +9 -0
  5. data/app/controllers/collavre/attachments_controller.rb +1 -1
  6. data/app/controllers/collavre/comment_read_pointers_controller.rb +2 -2
  7. data/app/controllers/collavre/comments/snapshots_controller.rb +2 -7
  8. data/app/controllers/collavre/comments_controller.rb +23 -25
  9. data/app/controllers/collavre/creatives_controller.rb +22 -11
  10. data/app/controllers/collavre/emails_controller.rb +2 -0
  11. data/app/controllers/collavre/google_auth_controller.rb +1 -1
  12. data/app/controllers/collavre/inbox_items_controller.rb +1 -1
  13. data/app/controllers/collavre/invites_controller.rb +3 -0
  14. data/app/controllers/collavre/tasks_controller.rb +1 -1
  15. data/app/controllers/collavre/topics_controller.rb +27 -85
  16. data/app/controllers/concerns/collavre/comments/comment_scoping.rb +3 -0
  17. data/app/controllers/concerns/collavre/creative_permission_guard.rb +32 -0
  18. data/app/controllers/concerns/collavre/integration_setup.rb +17 -0
  19. data/app/helpers/collavre/application_helper.rb +30 -2
  20. data/app/javascript/components/InlineLexicalEditor.jsx +7 -3
  21. data/app/javascript/components/creative_tree_row.js +21 -1
  22. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +34 -0
  23. data/app/javascript/controllers/comment_controller.js +17 -0
  24. data/app/javascript/controllers/comments/form_controller.js +7 -4
  25. data/app/javascript/controllers/comments/list_controller.js +43 -4
  26. data/app/javascript/controllers/comments/popup_controller.js +45 -12
  27. data/app/javascript/controllers/comments/presence_controller.js +8 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +50 -31
  29. data/app/javascript/creatives/tree_renderer.js +1 -0
  30. data/app/javascript/lib/__tests__/chat_history.test.js +31 -0
  31. data/app/javascript/lib/chat_history.js +12 -2
  32. data/app/javascript/modules/command_args_form.js +8 -0
  33. data/app/javascript/modules/creative_row_editor.js +12 -17
  34. data/app/javascript/modules/integration_wizard.js +162 -0
  35. data/app/jobs/collavre/compress_job.rb +1 -0
  36. data/app/jobs/collavre/creative_broadcast_job.rb +4 -1
  37. data/app/jobs/collavre/merge_comments_job.rb +1 -0
  38. data/app/jobs/collavre/trigger_loop_check_job.rb +1 -0
  39. data/app/jobs/collavre/trigger_loop_verify_job.rb +1 -0
  40. data/app/models/collavre/calendar_event.rb +0 -4
  41. data/app/models/collavre/comment/broadcastable.rb +1 -1
  42. data/app/models/collavre/comment.rb +17 -2
  43. data/app/models/collavre/comment_snapshot.rb +0 -1
  44. data/app/models/collavre/creative/describable.rb +10 -1
  45. data/app/models/collavre/creative/realtime_broadcastable.rb +17 -5
  46. data/app/models/collavre/creative.rb +43 -1
  47. data/app/models/collavre/current.rb +1 -1
  48. data/app/models/collavre/inbox_item.rb +0 -4
  49. data/app/models/collavre/system_setting.rb +10 -1
  50. data/app/models/collavre/task.rb +17 -8
  51. data/app/models/collavre/user.rb +11 -1
  52. data/app/models/concerns/collavre/ai_agent_resolvable.rb +0 -8
  53. data/app/services/collavre/ai_agent/message_builder.rb +32 -15
  54. data/app/services/collavre/ai_agent/response_finalizer.rb +2 -1
  55. data/app/services/collavre/ai_agent/session_context_resolver.rb +50 -0
  56. data/app/services/collavre/ai_agent_service.rb +14 -2
  57. data/app/services/collavre/ai_client.rb +13 -0
  58. data/app/services/collavre/command_menu_service.rb +23 -3
  59. data/app/services/collavre/comments/command_processor.rb +1 -1
  60. data/app/services/collavre/comments/mcp_command.rb +27 -8
  61. data/app/services/collavre/comments/mcp_command_builder.rb +4 -3
  62. data/app/services/collavre/creatives/tree_builder.rb +3 -0
  63. data/app/services/collavre/google_calendar_service.rb +32 -6
  64. data/app/services/collavre/markdown_converter.rb +15 -20
  65. data/app/services/collavre/mcp_service.rb +4 -4
  66. data/app/services/collavre/orchestration/agent_orchestrator.rb +2 -2
  67. data/app/services/collavre/tools/creative_batch_service.rb +5 -1
  68. data/app/services/collavre/tools/cron_create_service.rb +9 -6
  69. data/app/services/collavre/topic_branch_service.rb +2 -2
  70. data/app/views/collavre/admin/settings/_system_tab.html.erb +11 -0
  71. data/app/views/collavre/comments/_comment.html.erb +7 -1
  72. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  73. data/config/locales/admin.en.yml +3 -0
  74. data/config/locales/admin.ko.yml +3 -0
  75. data/config/locales/comments.en.yml +6 -1
  76. data/config/locales/comments.ko.yml +6 -1
  77. data/db/migrate/20251126040752_add_description_to_creatives.rb +1 -1
  78. data/db/migrate/20260415000000_create_main_topics_for_existing_creatives.rb +69 -0
  79. data/lib/collavre/version.rb +1 -1
  80. metadata +21 -4
  81. data/app/jobs/collavre/permission_cache_cleanup_job.rb +0 -36
  82. data/app/models/collavre/variation.rb +0 -5
  83. data/app/services/collavre/creatives/path_exporter.rb +0 -131
@@ -1,6 +1,11 @@
1
1
  module Collavre
2
2
  class TopicsController < ApplicationController
3
+ include Collavre::CreativePermissionGuard
4
+
3
5
  before_action :set_creative
6
+ before_action :require_creative_read!, only: %i[next_name]
7
+ before_action :require_creative_admin!, only: %i[update destroy move reorder]
8
+ before_action :require_creative_write!, only: %i[create archive unarchive set_primary_agent]
4
9
 
5
10
  def index
6
11
  is_owner = @creative.user == Current.user
@@ -18,6 +23,7 @@ module Collavre
18
23
  end
19
24
 
20
25
  system_topic_id = @creative.inbox? ? @creative.topics.find_by(name: Creative::SYSTEM_TOPIC_NAME)&.id : nil
26
+ main_topic_id = @creative.main_topic(fallback_user: Current.user).id
21
27
 
22
28
  render json: {
23
29
  topics: active_topics.map { |t| topic_json(t) },
@@ -26,15 +32,12 @@ module Collavre
26
32
  can_create_topic: can_create_topic,
27
33
  last_topic_id: last_topic_id,
28
34
  is_inbox: @creative.inbox?,
29
- system_topic_id: system_topic_id
35
+ system_topic_id: system_topic_id,
36
+ main_topic_id: main_topic_id
30
37
  }
31
38
  end
32
39
 
33
40
  def create
34
- unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
35
- render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
36
- end
37
-
38
41
  topic = @creative.topics.build(topic_params)
39
42
  topic.user = Current.user
40
43
 
@@ -56,10 +59,7 @@ module Collavre
56
59
  end
57
60
 
58
61
  broadcast_data = agent ? topic_json_with_agent(topic, agent) : topic.slice(:id, :name)
59
- TopicsChannel.broadcast_to(
60
- @creative,
61
- { action: "created", topic: broadcast_data, user_id: Current.user.id }
62
- )
62
+ broadcast_topic_event("created", topic: broadcast_data, user_id: Current.user.id)
63
63
  render json: topic, status: :created
64
64
  else
65
65
  render json: { errors: topic.errors.full_messages }, status: :unprocessable_entity
@@ -70,54 +70,35 @@ module Collavre
70
70
  end
71
71
 
72
72
  def next_name
73
- unless @creative.has_permission?(Current.user, :read) || @creative.user == Current.user
74
- render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
75
- end
76
-
77
73
  render json: { name: generate_next_topic_name }
78
74
  end
79
75
 
80
76
  def update
81
- unless @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
82
- render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
83
- end
84
-
85
77
  topic = @creative.topics.find(params[:id])
86
78
 
87
79
  if topic.update(topic_params)
88
- TopicsChannel.broadcast_to(
89
- @creative,
90
- { action: "updated", topic: topic.slice(:id, :name) }
91
- )
92
- render json: topic
80
+ broadcast_topic_event("updated", topic: topic_json(topic))
81
+ render json: topic_json(topic)
93
82
  else
94
83
  render json: { errors: topic.errors.full_messages }, status: :unprocessable_entity
95
84
  end
96
85
  end
97
86
 
98
87
  def destroy
99
- unless @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
100
- render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
88
+ topic = @creative.topics.find(params[:id])
89
+
90
+ if topic.name == Creative::MAIN_TOPIC_NAME
91
+ render json: { error: I18n.t("collavre.topics.cannot_delete_main") }, status: :unprocessable_entity and return
101
92
  end
102
93
 
103
- topic = @creative.topics.find(params[:id])
104
94
  topic_id = topic.id
105
-
106
- # last_topic_id is nullified by DB FK (on_delete: :nullify) and model dependent: :nullify
107
95
  topic.destroy
108
96
 
109
- TopicsChannel.broadcast_to(
110
- @creative,
111
- { action: "deleted", topic_id: topic_id }
112
- )
97
+ broadcast_topic_event("deleted", topic_id: topic_id)
113
98
  head :no_content
114
99
  end
115
100
 
116
101
  def move
117
- unless @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
118
- render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
119
- end
120
-
121
102
  topic = @creative.topics.find(params[:id])
122
103
  target_creative = Creative.find(params[:target_creative_id]).effective_origin
123
104
 
@@ -135,53 +116,29 @@ module Collavre
135
116
  topic.update!(creative: target_creative)
136
117
  end
137
118
 
138
- TopicsChannel.broadcast_to(
139
- @creative,
140
- { action: "deleted", topic_id: topic.id }
141
- )
142
- TopicsChannel.broadcast_to(
143
- target_creative,
144
- { action: "created", topic: topic.slice(:id, :name) }
145
- )
119
+ broadcast_topic_event("deleted", topic_id: topic.id)
120
+ broadcast_topic_event("created", creative: target_creative, topic: topic.slice(:id, :name))
146
121
 
147
122
  render json: { success: true, topic: topic.slice(:id, :name), target_creative_id: target_creative.id }
148
123
  end
149
124
 
150
125
  def archive
151
- unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
152
- render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
153
- end
154
-
155
126
  topic = @creative.topics.find(params[:id])
156
127
  topic.archive!
157
128
 
158
- TopicsChannel.broadcast_to(
159
- @creative,
160
- { action: "archived", topic: topic.slice(:id, :name) }
161
- )
129
+ broadcast_topic_event("archived", topic: topic.slice(:id, :name))
162
130
  render json: { success: true }
163
131
  end
164
132
 
165
133
  def unarchive
166
- unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
167
- render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
168
- end
169
-
170
134
  topic = @creative.topics.find(params[:id])
171
135
  topic.unarchive!
172
136
 
173
- TopicsChannel.broadcast_to(
174
- @creative,
175
- { action: "unarchived", topic: topic.slice(:id, :name, :archived_at) }
176
- )
137
+ broadcast_topic_event("unarchived", topic: topic.slice(:id, :name, :archived_at))
177
138
  render json: { success: true }
178
139
  end
179
140
 
180
141
  def reorder
181
- unless @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
182
- render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
183
- end
184
-
185
142
  topic_ids = params[:topic_ids]
186
143
  unless topic_ids.is_a?(Array) && topic_ids.present?
187
144
  render json: { error: "Invalid topic_ids" }, status: :unprocessable_entity and return
@@ -193,19 +150,12 @@ module Collavre
193
150
  end
194
151
  end
195
152
 
196
- TopicsChannel.broadcast_to(
197
- @creative,
198
- { action: "reordered", topic_ids: topic_ids }
199
- )
153
+ broadcast_topic_event("reordered", topic_ids: topic_ids)
200
154
 
201
155
  render json: { success: true }
202
156
  end
203
157
 
204
158
  def set_primary_agent
205
- unless @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
206
- render json: { error: I18n.t("collavre.topics.no_permission") }, status: :forbidden and return
207
- end
208
-
209
159
  topic = @creative.topics.find(params[:id])
210
160
  agent = User.find_by(id: params[:agent_id])
211
161
 
@@ -215,13 +165,7 @@ module Collavre
215
165
 
216
166
  topic.set_primary_agent!(agent)
217
167
 
218
- TopicsChannel.broadcast_to(
219
- @creative,
220
- {
221
- action: "updated",
222
- topic: topic_json_with_agent(topic, agent)
223
- }
224
- )
168
+ broadcast_topic_event("updated", topic: topic_json_with_agent(topic, agent))
225
169
 
226
170
  render json: { success: true, topic: topic_json_with_agent(topic, agent) }
227
171
  end
@@ -287,13 +231,11 @@ module Collavre
287
231
  end
288
232
 
289
233
  def agent_json(agent)
290
- {
291
- id: agent.id,
292
- name: agent.display_name,
293
- avatar_url: view_context.user_avatar_url(agent, size: 20),
294
- default_avatar: !agent.avatar.attached? && agent.avatar_url.blank?,
295
- initial: agent.display_name&.at(0)&.upcase || "?"
296
- }
234
+ view_context.user_json(agent)
235
+ end
236
+
237
+ def broadcast_topic_event(action, creative: @creative, **payload)
238
+ TopicsChannel.broadcast_to(creative, { action: action, **payload })
297
239
  end
298
240
  end
299
241
  end
@@ -9,6 +9,9 @@ module Collavre
9
9
 
10
10
  def set_creative
11
11
  @creative = Creative.find(params[:creative_id]).effective_origin
12
+ unless @creative.has_permission?(Current.user, :read)
13
+ render json: { error: I18n.t("collavre.creatives.errors.no_permission") }, status: :forbidden
14
+ end
12
15
  end
13
16
 
14
17
  def set_comment
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module CreativePermissionGuard
5
+ extend ActiveSupport::Concern
6
+
7
+ private
8
+
9
+ def require_creative_read!
10
+ return if @creative.has_permission?(Current.user, :read) || @creative.user == Current.user
11
+
12
+ render json: { error: creative_permission_denied_message }, status: :forbidden
13
+ end
14
+
15
+ def require_creative_write!
16
+ return if @creative.has_permission?(Current.user, :write) || @creative.user == Current.user
17
+
18
+ render json: { error: creative_permission_denied_message }, status: :forbidden
19
+ end
20
+
21
+ def require_creative_admin!
22
+ return if @creative.has_permission?(Current.user, :admin) || @creative.user == Current.user
23
+
24
+ render json: { error: creative_permission_denied_message }, status: :forbidden
25
+ end
26
+
27
+ # Override in controllers that use a different i18n key.
28
+ def creative_permission_denied_message
29
+ I18n.t("collavre.topics.no_permission")
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module IntegrationSetup
5
+ extend ActiveSupport::Concern
6
+
7
+ private
8
+
9
+ def set_creative
10
+ @creative = Collavre::Creative.find(params[:creative_id])
11
+ end
12
+
13
+ def set_origin
14
+ @origin = @creative.effective_origin
15
+ end
16
+ end
17
+ end
@@ -81,11 +81,30 @@ module Collavre
81
81
  safe_join(styles, "\n")
82
82
  end
83
83
 
84
+ def user_json(user, email: false, ai_user: false)
85
+ data = {
86
+ id: user.id,
87
+ name: user.display_name,
88
+ avatar_url: user_avatar_url(user, size: 20),
89
+ default_avatar: !user.avatar.attached? && user.avatar_url.blank?,
90
+ initial: user.display_name&.at(0)&.upcase || "?"
91
+ }
92
+ data[:email] = user.email if email
93
+ data[:ai_user] = user.ai_user? if ai_user
94
+ data
95
+ end
96
+
84
97
  private
85
98
 
99
+ CSS_VARIABLE_KEY_PATTERN = /\A--[a-zA-Z0-9_-]+\z/
100
+ CSS_VARIABLE_VALUE_PATTERN = /\A[^;}{<>"']+\z/
101
+ ALLOWED_COLOR_SCHEMES = %w[light dark].freeze
102
+
86
103
  def render_theme_media_query(theme, mode)
87
- vars = theme.variables.map { |k, v| "#{k}: #{v} !important;" }.join("\n ")
88
- legacy = legacy_alias_declarations(theme.variables).map { |k, v| "#{k}: #{v} !important;" }.join("\n ")
104
+ return "" unless ALLOWED_COLOR_SCHEMES.include?(mode)
105
+
106
+ vars = safe_css_declarations(theme.variables)
107
+ legacy = safe_css_declarations(legacy_alias_declarations(theme.variables))
89
108
  dark_filter = theme.dark? ? "--date-icon-filter: invert(0.8) !important;" : ""
90
109
 
91
110
  <<~CSS
@@ -99,6 +118,15 @@ module Collavre
99
118
  CSS
100
119
  end
101
120
 
121
+ def safe_css_declarations(variables)
122
+ variables.filter_map { |k, v|
123
+ k = k.to_s
124
+ v = v.to_s
125
+ next unless k.match?(CSS_VARIABLE_KEY_PATTERN) && v.match?(CSS_VARIABLE_VALUE_PATTERN)
126
+ "#{k}: #{v} !important;"
127
+ }.join("\n ")
128
+ end
129
+
102
130
  public
103
131
 
104
132
  # Render all partials registered for a named extension slot.
@@ -48,6 +48,7 @@ import FileUploadPlugin, {
48
48
  import { ImageNode } from "../lib/lexical/image_node"
49
49
  import { AttachmentNode } from "../lib/lexical/attachment_node"
50
50
  import AttachmentCleanupPlugin from "./plugins/attachment_cleanup_plugin"
51
+ import MarkdownShortcutsPlugin from "./plugins/markdown_shortcuts_plugin"
51
52
  import { syncLexicalStyleAttributes } from "../lib/lexical/style_attributes"
52
53
  import { updateResponsiveImages } from "../lib/responsive_images"
53
54
 
@@ -956,6 +957,7 @@ function EditorInner({
956
957
  blobUrlTemplate={blobUrlTemplate}
957
958
  />
958
959
  <AttachmentCleanupPlugin deletedAttachmentsRef={deletedAttachmentsRef} />
960
+ <MarkdownShortcutsPlugin />
959
961
  {onEnterKey && <EnterKeyPlugin onEnterKey={onEnterKey} />}
960
962
  </div>
961
963
  </div>
@@ -966,14 +968,16 @@ function EnterKeyPlugin({ onEnterKey }) {
966
968
  const [editor] = useLexicalComposerContext()
967
969
 
968
970
  useEffect(() => {
969
- // Use capture-phase keydown on the root element to intercept Enter
970
- // BEFORE Lexical's own handlers process it and insert a paragraph.
971
+ // Use capture-phase keydown on the root element to intercept Shift+Enter
972
+ // BEFORE Lexical's own handlers process it.
973
+ // Bare Enter is left to Lexical for newline insertion.
971
974
  const rootElement = editor.getRootElement()
972
975
  if (!rootElement) return
973
976
 
974
977
  const handler = (event) => {
975
978
  if (event.key !== 'Enter') return
976
- if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return
979
+ if (!event.shiftKey) return // only intercept Shift+Enter
980
+ if (event.altKey || event.ctrlKey || event.metaKey) return
977
981
  if (event.isComposing) return
978
982
 
979
983
  if (onEnterKey(event, editor) === true) {
@@ -25,6 +25,7 @@ class CreativeTreeRow extends LitElement {
25
25
  originLinkHtml: { state: true },
26
26
  isTitle: { type: Boolean, attribute: "is-title", reflect: true },
27
27
  archived: { type: Boolean, attribute: "archived", reflect: true },
28
+ githubSource: { type: Boolean, attribute: "github-source", reflect: true },
28
29
  loadingChildren: { type: Boolean, attribute: "loading-children", reflect: true },
29
30
  _loadingDotsState: { state: true },
30
31
  editingUsers: { state: true }
@@ -48,6 +49,7 @@ class CreativeTreeRow extends LitElement {
48
49
  this.editOffIconHtml = "";
49
50
  this.originLinkHtml = "";
50
51
  this.isTitle = false;
52
+ this.githubSource = false;
51
53
  this.editingUsers = []; // [{ user_id, user_name, avatar_url }]
52
54
  this._templatesExtracted = false;
53
55
  this.loadingChildren = false;
@@ -315,12 +317,22 @@ class CreativeTreeRow extends LitElement {
315
317
  `;
316
318
  }
317
319
 
320
+ _renderGithubBadge() {
321
+ if (!this.githubSource) return nothing;
322
+ return html`<span class="github-source-badge" title="Synced from GitHub (read-only)">
323
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" style="vertical-align: -2px; opacity: 0.5;">
324
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
325
+ </svg>
326
+ </span>`;
327
+ }
328
+
318
329
  _renderContent() {
319
330
  const level = Number(this.level) || 1;
320
331
  // Toggle is now rendered outside
332
+ const githubBadge = this._renderGithubBadge();
321
333
  const content = html`
322
334
  <div class="creative-content" @click=${this._handleContentClick}>
323
- ${unsafeHTML(this.descriptionHtml || "")}
335
+ ${githubBadge}${unsafeHTML(this.descriptionHtml || "")}
324
336
  </div>
325
337
  `;
326
338
  const indicator = this.loadingChildren ? this._renderLoadingIndicator() : nothing;
@@ -579,6 +591,14 @@ class CreativeTreeRow extends LitElement {
579
591
  }
580
592
 
581
593
  _handleContentClick(event) {
594
+ // In select mode, block navigation — selection toggle is handled by
595
+ // select_mode_controller's mousedown handler to avoid double-toggling.
596
+ if (this.selectMode) {
597
+ event.preventDefault();
598
+ event.stopPropagation();
599
+ return;
600
+ }
601
+
582
602
  // Check if the clicked element is an interactive element or inside one
583
603
  const target = event.target;
584
604
  if (target.tagName === 'A' || target.closest('a') ||
@@ -0,0 +1,34 @@
1
+ import { useEffect } from "react"
2
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"
3
+ import {
4
+ registerMarkdownShortcuts,
5
+ UNORDERED_LIST,
6
+ ORDERED_LIST,
7
+ CODE
8
+ } from "@lexical/markdown"
9
+
10
+ /**
11
+ * Markdown-style shortcuts for the creative inline editor:
12
+ *
13
+ * - "* " / "- " / "+ " at line start → unordered list
14
+ * - "1. " (any number) at line start → ordered list
15
+ * - "```" + space at line start → code block
16
+ *
17
+ * These fire on text change (not on Enter), so they don't conflict
18
+ * with the Enter→addNew() shortcut on desktop.
19
+ */
20
+ const CREATIVE_MARKDOWN_TRANSFORMERS = [
21
+ UNORDERED_LIST,
22
+ ORDERED_LIST,
23
+ CODE
24
+ ]
25
+
26
+ export default function MarkdownShortcutsPlugin() {
27
+ const [editor] = useLexicalComposerContext()
28
+
29
+ useEffect(() => {
30
+ return registerMarkdownShortcuts(editor, CREATIVE_MARKDOWN_TRANSFORMERS)
31
+ }, [editor])
32
+
33
+ return null
34
+ }
@@ -2,6 +2,7 @@ import { Controller } from "@hotwired/stimulus"
2
2
  import { renderCommentMarkdown, renderMermaidDiagrams } from '../lib/utils/markdown'
3
3
  import { addTableDownloadButtons } from '../lib/utils/table_download'
4
4
  import CommonPopup from '../lib/common_popup'
5
+ import csrfFetch from '../lib/api/csrf_fetch'
5
6
 
6
7
  // Global tracker: persists streaming state across Turbo replacements
7
8
  // (each replacement creates a new controller instance, losing instance state)
@@ -163,6 +164,22 @@ export default class extends Controller {
163
164
  }
164
165
  }
165
166
 
167
+ cancelTask(event) {
168
+ event.preventDefault()
169
+ event.stopPropagation()
170
+ const taskId = event.currentTarget.dataset.taskId
171
+ if (!taskId) return
172
+ event.currentTarget.disabled = true
173
+ csrfFetch(`/tasks/${taskId}/cancel`, {
174
+ method: 'POST',
175
+ headers: { 'Content-Type': 'application/json' },
176
+ })
177
+ .then((response) => {
178
+ if (!response.ok) event.currentTarget.disabled = false
179
+ })
180
+ .catch(() => { event.currentTarget.disabled = false })
181
+ }
182
+
166
183
  triggerReactionPicker(event) {
167
184
  event.preventDefault()
168
185
  event.stopPropagation()
@@ -103,6 +103,7 @@ export default class extends Controller {
103
103
  this.currentTopicId = event.detail.topicId
104
104
  this._isInbox = event.detail.isInbox || false
105
105
  this._systemTopicId = event.detail.systemTopicId || null
106
+ this._mainTopicId = event.detail.mainTopicId || null
106
107
  this._updateInboxReplyMode()
107
108
  }
108
109
 
@@ -265,8 +266,9 @@ export default class extends Controller {
265
266
  const wasPrivate = this.privateCheckboxTarget?.checked ?? false
266
267
 
267
268
  const formData = new FormData(this.formTarget)
268
- if (this.currentTopicId) {
269
- formData.append('comment[topic_id]', this.currentTopicId)
269
+ const effectiveTopicId = this.currentTopicId || this._mainTopicId
270
+ if (effectiveTopicId) {
271
+ formData.append('comment[topic_id]', effectiveTopicId)
270
272
  }
271
273
  if (this._pendingReviewType) {
272
274
  formData.append('comment[review_type]', this._pendingReviewType)
@@ -763,8 +765,9 @@ export default class extends Controller {
763
765
  }
764
766
  const isPrivate = this.privateCheckboxTarget?.checked ?? false
765
767
  if (isPrivate) formData.append('comment[private]', '1')
766
- if (this.currentTopicId) {
767
- formData.append('comment[topic_id]', this.currentTopicId)
768
+ const effectiveTopicId = this.currentTopicId || this._mainTopicId
769
+ if (effectiveTopicId) {
770
+ formData.append('comment[topic_id]', effectiveTopicId)
768
771
  }
769
772
 
770
773
  const url = `/creatives/${this.creativeId}/comments`
@@ -579,10 +579,9 @@ export default class extends Controller {
579
579
  bar.querySelector('.selection-action-branch').addEventListener('click', (e) => { e.stopPropagation(); this.branchSelectedComments() })
580
580
  bar.querySelector('.selection-action-bar-close').addEventListener('click', () => this.clearSelection())
581
581
 
582
- // Insert before typing indicator so it stays inside the popup window
583
- const typingIndicator = this.element.querySelector('#typing-indicator')
584
- if (typingIndicator) {
585
- typingIndicator.parentNode.insertBefore(bar, typingIndicator)
582
+ const typingRow = this.element.querySelector('#typing-indicator-row') || this.element.querySelector('#typing-indicator')
583
+ if (typingRow) {
584
+ typingRow.parentNode.insertBefore(bar, typingRow)
586
585
  } else {
587
586
  this.element.appendChild(bar)
588
587
  }
@@ -1061,6 +1060,46 @@ export default class extends Controller {
1061
1060
  .finally(() => { this.movingComments = false })
1062
1061
  }
1063
1062
 
1063
+ scrollToPreviousMessage() {
1064
+ const list = this.listTarget
1065
+ const items = Array.from(list.querySelectorAll('.comment-item'))
1066
+ if (items.length === 0) return
1067
+
1068
+ const listRect = list.getBoundingClientRect()
1069
+ const viewportTop = listRect.top
1070
+
1071
+ let currentIdx = -1
1072
+ for (let i = 0; i < items.length; i++) {
1073
+ const rect = items[i].getBoundingClientRect()
1074
+ if (rect.top >= viewportTop - 2) {
1075
+ currentIdx = i
1076
+ break
1077
+ }
1078
+ }
1079
+
1080
+ if (currentIdx === -1) currentIdx = items.length - 1
1081
+
1082
+ const currentItem = items[currentIdx]
1083
+ const currentRect = currentItem.getBoundingClientRect()
1084
+ const isAtTop = Math.abs(currentRect.top - viewportTop) < 4
1085
+
1086
+ const targetIdx = isAtTop ? currentIdx - 1 : currentIdx
1087
+ if (targetIdx < 0) {
1088
+ if (!this.allOlderLoaded) {
1089
+ this.loadOlderComments()
1090
+ }
1091
+ return
1092
+ }
1093
+
1094
+ const target = items[targetIdx]
1095
+ const targetTop = target.offsetTop - list.offsetTop
1096
+ list.scrollTo({ top: targetTop, behavior: 'smooth' })
1097
+ this.stickToBottom = false
1098
+
1099
+ target.classList.add('highlight-flash')
1100
+ setTimeout(() => target.classList.remove('highlight-flash'), 2000)
1101
+ }
1102
+
1064
1103
  // UI Helpers
1065
1104
  updateStickiness() {
1066
1105
  this.stickToBottom = this.isNearBottom()