collavre 0.23.0 → 0.24.1

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 (115) 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 +45 -17
  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__/form_controller_review.test.js +26 -0
  28. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +230 -0
  29. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  30. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +178 -0
  31. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +146 -0
  33. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  34. data/app/javascript/controllers/comments/form_controller.js +3 -0
  35. data/app/javascript/controllers/comments/list_controller.js +42 -21
  36. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  37. data/app/javascript/controllers/comments/topics_controller.js +115 -21
  38. data/app/javascript/controllers/common_popup_controller.js +2 -2
  39. data/app/javascript/controllers/index.js +3 -0
  40. data/app/javascript/controllers/search_popup_controller.js +17 -1
  41. data/app/javascript/controllers/topic_list_controller.js +78 -0
  42. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  43. data/app/javascript/lib/common_popup.js +24 -5
  44. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  45. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  46. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  47. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  48. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  49. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  50. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  51. data/app/javascript/modules/creative_inline_payload.js +86 -0
  52. data/app/javascript/modules/creative_row_editor.js +121 -401
  53. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  54. data/app/javascript/modules/creative_save_queue.js +95 -0
  55. data/app/javascript/modules/creative_tree_dom.js +260 -0
  56. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  57. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  58. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  59. data/app/models/collavre/channel.rb +16 -0
  60. data/app/models/collavre/comment/approvable.rb +16 -0
  61. data/app/models/collavre/comment/notifiable.rb +1 -1
  62. data/app/models/collavre/comment.rb +8 -1
  63. data/app/models/collavre/comment_version.rb +1 -1
  64. data/app/models/collavre/creative/describable.rb +15 -9
  65. data/app/models/collavre/creative/linkable.rb +9 -0
  66. data/app/models/collavre/creative/permissible.rb +97 -49
  67. data/app/models/collavre/creative.rb +73 -5
  68. data/app/models/collavre/creative_share.rb +78 -26
  69. data/app/models/collavre/inbox_item.rb +2 -2
  70. data/app/models/collavre/task.rb +12 -0
  71. data/app/models/collavre/user.rb +33 -9
  72. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  73. data/app/services/collavre/agent_type_classifier.rb +23 -0
  74. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  75. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  76. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  77. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  78. data/app/services/collavre/ai_client.rb +53 -2
  79. data/app/services/collavre/auto_theme_generator.rb +14 -10
  80. data/app/services/collavre/creatives/children_index.rb +86 -0
  81. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  82. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  83. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  84. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  85. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  86. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  87. data/app/services/collavre/http_client.rb +119 -0
  88. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  89. data/app/services/collavre/markdown_importer.rb +30 -6
  90. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  91. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  92. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  93. data/app/services/collavre/system_events/context_builder.rb +1 -14
  94. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  95. data/app/views/collavre/comments/_comment.html.erb +4 -4
  96. data/app/views/collavre/comments/_comments_popup.html.erb +19 -3
  97. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  98. data/app/views/collavre/landing/show.html.erb +52 -10
  99. data/config/locales/comments.en.yml +7 -0
  100. data/config/locales/comments.ko.yml +7 -0
  101. data/config/locales/creatives.en.yml +6 -0
  102. data/config/locales/creatives.ko.yml +5 -0
  103. data/config/locales/landing.en.yml +22 -10
  104. data/config/locales/landing.ko.yml +22 -10
  105. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  106. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  107. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  108. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  109. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  110. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  111. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  112. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  113. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  114. data/lib/collavre/version.rb +1 -1
  115. metadata +48 -1
@@ -6,8 +6,14 @@ import creativesApi from '../../lib/api/creatives'
6
6
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
7
7
  import { updateCsrfTokenFromResponse } from '../../lib/api/csrf_fetch'
8
8
  import { alertDialog, confirmDialog } from '../../lib/utils/dialog'
9
+ import PrevMessageNavigator from './prev_message_navigator'
9
10
  // CommonPopup is now used via TopicSearchController (Stimulus)
10
11
 
12
+ // Gestures that mean the user moved the list themselves, invalidating the
13
+ // previous-message anchor. Only user input fires these — our own smooth scroll
14
+ // does not.
15
+ const PREV_MSG_USER_INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown']
16
+
11
17
  export default class extends Controller {
12
18
  static targets = ['list']
13
19
 
@@ -20,8 +26,10 @@ export default class extends Controller {
20
26
  this.movingComments = false
21
27
  this.manualSearchQuery = null
22
28
  this.initialLoadComplete = false
29
+ this.prevMsgNavigator = new PrevMessageNavigator()
23
30
 
24
31
  this.handleScroll = this.handleScroll.bind(this)
32
+ this.handlePrevMsgUserInput = this.handlePrevMsgUserInput.bind(this)
25
33
  this.handleChange = this.handleChange.bind(this)
26
34
  this.handleClick = this.handleClick.bind(this)
27
35
  this.handleSubmit = this.handleSubmit.bind(this)
@@ -33,6 +41,9 @@ export default class extends Controller {
33
41
  this.handleStreamRender = this.handleStreamRender.bind(this)
34
42
 
35
43
  this.listTarget.addEventListener('scroll', this.handleScroll)
44
+ PREV_MSG_USER_INPUT_EVENTS.forEach((name) => {
45
+ this.listTarget.addEventListener(name, this.handlePrevMsgUserInput)
46
+ })
36
47
  this.listTarget.addEventListener('change', this.handleChange)
37
48
  this.listTarget.addEventListener('click', this.handleClick)
38
49
  this.listTarget.addEventListener('submit', this.handleSubmit)
@@ -75,6 +86,9 @@ export default class extends Controller {
75
86
 
76
87
  disconnect() {
77
88
  this.listTarget.removeEventListener('scroll', this.handleScroll)
89
+ PREV_MSG_USER_INPUT_EVENTS.forEach((name) => {
90
+ this.listTarget.removeEventListener(name, this.handlePrevMsgUserInput)
91
+ })
78
92
  this.listTarget.removeEventListener('change', this.handleChange)
79
93
  this.listTarget.removeEventListener('click', this.handleClick)
80
94
  this.listTarget.removeEventListener('submit', this.handleSubmit)
@@ -152,6 +166,9 @@ export default class extends Controller {
152
166
  if (!this.creativeId) return
153
167
  if (this.selection.size > 0) return
154
168
 
169
+ // The list is about to be replaced wholesale; any anchor we hold is stale.
170
+ this.prevMsgNavigator.reset()
171
+
155
172
  const params = {}
156
173
  if (this.highlightAfterLoad) {
157
174
  params.around_comment_id = this.highlightAfterLoad
@@ -334,9 +351,17 @@ export default class extends Controller {
334
351
  return parseInt(last.dataset.commentId)
335
352
  }
336
353
 
354
+ // Public seam for sibling controllers that scroll the list on their own
355
+ // (e.g. the review-quote chip). Lets them drop the prev-message anchor at the
356
+ // choke point without reaching into the navigator's internals.
357
+ notifyProgrammaticScroll() {
358
+ this.prevMsgNavigator?.notifyProgrammaticScroll()
359
+ }
360
+
337
361
  highlightComment(commentId) {
338
362
  const comment = document.getElementById(`comment_${commentId}`)
339
363
  if (!comment) return
364
+ this.prevMsgNavigator?.notifyProgrammaticScroll()
340
365
  comment.scrollIntoView({ behavior: 'auto', block: 'center' })
341
366
  comment.classList.add('highlight-flash')
342
367
  comment.dataset.highlighted = 'true'
@@ -357,6 +382,10 @@ export default class extends Controller {
357
382
  }, 2000);
358
383
  }
359
384
 
385
+ handlePrevMsgUserInput() {
386
+ this.prevMsgNavigator.notifyUserInput()
387
+ }
388
+
360
389
  handleScroll() {
361
390
  if (!this.initialLoadComplete) return
362
391
 
@@ -1078,28 +1107,16 @@ export default class extends Controller {
1078
1107
 
1079
1108
  scrollToPreviousMessage() {
1080
1109
  const list = this.listTarget
1081
- const items = Array.from(list.querySelectorAll('.comment-item'))
1082
- if (items.length === 0) return
1083
-
1084
- const listRect = list.getBoundingClientRect()
1085
- const viewportTop = listRect.top
1086
-
1087
- let currentIdx = -1
1088
- for (let i = 0; i < items.length; i++) {
1089
- const rect = items[i].getBoundingClientRect()
1090
- if (rect.top >= viewportTop - 2) {
1091
- currentIdx = i
1092
- break
1093
- }
1094
- }
1095
-
1096
- if (currentIdx === -1) currentIdx = items.length - 1
1110
+ const elements = Array.from(list.querySelectorAll('.comment-item'))
1111
+ if (elements.length === 0) return
1097
1112
 
1098
- const currentItem = items[currentIdx]
1099
- const currentRect = currentItem.getBoundingClientRect()
1100
- const isAtTop = Math.abs(currentRect.top - viewportTop) < 4
1113
+ const viewportTop = list.getBoundingClientRect().top
1114
+ const measured = elements.map((el) => ({
1115
+ id: el.dataset.commentId,
1116
+ top: el.getBoundingClientRect().top,
1117
+ }))
1101
1118
 
1102
- const targetIdx = isAtTop ? currentIdx - 1 : currentIdx
1119
+ const targetIdx = this.prevMsgNavigator.resolveTargetIndex(measured, viewportTop)
1103
1120
  if (targetIdx < 0) {
1104
1121
  if (!this.allOlderLoaded) {
1105
1122
  this.loadOlderComments()
@@ -1107,8 +1124,9 @@ export default class extends Controller {
1107
1124
  return
1108
1125
  }
1109
1126
 
1110
- const target = items[targetIdx]
1127
+ const target = elements[targetIdx]
1111
1128
  const targetTop = target.offsetTop - list.offsetTop
1129
+ this.prevMsgNavigator.commit(measured[targetIdx].id, measured[targetIdx].top)
1112
1130
  list.scrollTo({ top: targetTop, behavior: 'smooth' })
1113
1131
  this.stickToBottom = false
1114
1132
 
@@ -1126,6 +1144,9 @@ export default class extends Controller {
1126
1144
  }
1127
1145
 
1128
1146
  scrollToBottom() {
1147
+ // A jump to latest we did not initiate via the button - drop the anchor so
1148
+ // the next previous-message click resolves from here, not a stale target.
1149
+ this.prevMsgNavigator?.notifyProgrammaticScroll()
1129
1150
  // In column reverse, bottom of scroll might be tricky.
1130
1151
  // Easiest is to set scrollTop to a large value.
1131
1152
  requestAnimationFrame(() => {
@@ -0,0 +1,94 @@
1
+ // Tracks where the "previous message" button left off.
2
+ //
3
+ // The button scrolls with `behavior: 'smooth'`, so a rapid second click arrives
4
+ // while the list is still mid-animation. Deriving the current position purely
5
+ // from geometry at that moment picks the item we just left (it has not reached
6
+ // the top yet), which makes the list bounce up and down instead of walking
7
+ // backwards. Remembering the last target as an anchor keeps each click stepping
8
+ // one message further back.
9
+ //
10
+ // The anchor is dropped once the user moves the list themselves, so geometry
11
+ // takes over from wherever they landed. Invalidation is driven by input events
12
+ // rather than scroll events: a scroll event cannot be attributed to the user
13
+ // without guessing how long our own animation runs, and guessing short brings
14
+ // the bounce back. Dropping the anchor is always the safe direction — a settled
15
+ // list resolves correctly from geometry alone.
16
+ //
17
+ // Kept free of DOM access so it can be unit tested: jsdom has no layout engine,
18
+ // so the controller measures the items and passes `{ id, top }` pairs in.
19
+
20
+ // Sub-pixel scroll offsets (HiDPI, browser rounding) mean an item parked at the
21
+ // top is rarely at exactly `viewportTop`.
22
+ const TOP_TOLERANCE = 4
23
+
24
+ export default class PrevMessageNavigator {
25
+ constructor() {
26
+ this.anchorId = null
27
+ }
28
+
29
+ // items: `{ id, top }` in DOM order (oldest first), top relative to viewport.
30
+ // Returns the index to scroll to, or -1 when there is nothing older in the
31
+ // list (the caller then loads older comments).
32
+ resolveTargetIndex(items, viewportTop) {
33
+ if (items.length === 0) return -1
34
+
35
+ if (this.anchorId !== null) {
36
+ const anchorIdx = items.findIndex((item) => item.id === this.anchorId)
37
+ if (anchorIdx !== -1 && this.anchorIsOnItsWay(items[anchorIdx].top, viewportTop)) {
38
+ return anchorIdx - 1
39
+ }
40
+ this.anchorId = null
41
+ }
42
+
43
+ let currentIdx = items.findIndex((item) => item.top >= viewportTop - TOP_TOLERANCE)
44
+ if (currentIdx === -1) currentIdx = items.length - 1
45
+
46
+ const isAtTop = Math.abs(items[currentIdx].top - viewportTop) < TOP_TOLERANCE
47
+ return isAtTop ? currentIdx - 1 : currentIdx
48
+ }
49
+
50
+ // The anchor travels from wherever it sat when we committed to `viewportTop`,
51
+ // and nothing else moves it. Outside that corridor the list was scrolled by
52
+ // something other than us - a new message arriving, a permalink jump - none of
53
+ // which fire a user input event, so the anchor would otherwise survive and wedge
54
+ // the button (anchored on the oldest item, every click resolves to -1 forever).
55
+ //
56
+ // The corridor spans both endpoints because the anchor can approach the top from
57
+ // either side: usually from above (the next message back), but from below when
58
+ // the click started between messages and we pulled the partially visible one up.
59
+ //
60
+ // Comparing the anchor against its own start position rather than the list's
61
+ // scroll offset keeps this correct when older comments are prepended: that
62
+ // shifts `scrollTop` but leaves the anchor where it is on screen.
63
+ anchorIsOnItsWay(anchorTop, viewportTop) {
64
+ const low = Math.min(this.anchorStartTop, viewportTop) - TOP_TOLERANCE
65
+ const high = Math.max(this.anchorStartTop, viewportTop) + TOP_TOLERANCE
66
+ return anchorTop >= low && anchorTop <= high
67
+ }
68
+
69
+ // Called once we start scrolling towards `id`, which currently sits at `top`.
70
+ commit(id, top) {
71
+ this.anchorId = id
72
+ this.anchorStartTop = top
73
+ }
74
+
75
+ // Called when the user interacts with the list (wheel, touch, key, pointer).
76
+ notifyUserInput() {
77
+ this.anchorId = null
78
+ }
79
+
80
+ // Called when our own code repositions the list without going through the
81
+ // button - jumping to the newest message, a permalink highlight. These fire no
82
+ // user input event, and a small jump can leave the anchor inside the corridor
83
+ // above, where geometry cannot tell it apart from our own in-flight scroll. The
84
+ // corridor still catches larger jumps, but the caller drops the anchor at these
85
+ // known sites so a still-visible message is never skipped.
86
+ notifyProgrammaticScroll() {
87
+ this.anchorId = null
88
+ }
89
+
90
+ // Called when the list contents are replaced wholesale.
91
+ reset() {
92
+ this.anchorId = null
93
+ }
94
+ }
@@ -7,7 +7,7 @@ const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none
7
7
  const ICON_RESTORE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6.69 3L3 13"/></svg>`
8
8
 
9
9
  export default class extends Controller {
10
- static targets = ["list"]
10
+ static targets = ["list", "creationContainer"]
11
11
 
12
12
  connect() {
13
13
  this.topics = []
@@ -189,15 +189,37 @@ export default class extends Controller {
189
189
  }
190
190
  }
191
191
 
192
- // Add create button container (write permission is sufficient for topic creation)
193
- if (canCreateTopic) {
194
- html += `<span class="topic-creation-container" data-comments--topics-target="creationContainer"
195
- data-action="dragover->comments--topics#handleAddButtonDragOver dragleave->comments--topics#handleDragLeave drop->comments--topics#handleAddButtonDrop">
196
- <button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>
197
- </span>`
192
+ this.listTarget.innerHTML = html
193
+
194
+ // The create button lives outside the scrolling strip so it stays reachable
195
+ // without horizontal scrolling, no matter how many topics there are.
196
+ this.renderCreationContainer(canCreateTopic)
197
+ }
198
+
199
+ // Write permission is sufficient for topic creation.
200
+ renderCreationContainer(canCreateTopic) {
201
+ if (!this.hasCreationContainerTarget) return
202
+ const container = this.creationContainerTarget
203
+
204
+ container.hidden = !canCreateTopic
205
+ if (!canCreateTopic) {
206
+ container.innerHTML = ''
207
+ return
198
208
  }
199
209
 
200
- this.listTarget.innerHTML = html
210
+ // renderTopics re-runs on every topic broadcast; don't wipe a name being typed.
211
+ // `creating` marks an already-submitted name, whose input must give way to the button.
212
+ // A draft only survives re-renders of the creative it was typed for: chat-nav
213
+ // switches creatives without blurring, and posting it there is the wrong creative.
214
+ const draftIsCurrent = String(this._draftCreativeId) === String(this.creativeId)
215
+ if (!this.creating && draftIsCurrent && container.querySelector('.topic-input')) return
216
+
217
+ this.renderAddButton()
218
+ }
219
+
220
+ renderAddButton() {
221
+ this.creationContainerTarget.innerHTML =
222
+ `<button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>`
201
223
  }
202
224
 
203
225
  handleDragOver(event) {
@@ -404,7 +426,7 @@ export default class extends Controller {
404
426
  }
405
427
  this.loadTopics()
406
428
  } else {
407
- alertDialog("Failed to delete topic")
429
+ alertDialog(this._i18n("delete_error"))
408
430
  }
409
431
  } catch (e) {
410
432
  console.error("Error deleting topic", e)
@@ -431,7 +453,7 @@ export default class extends Controller {
431
453
  }
432
454
  this.loadTopics()
433
455
  } else {
434
- alertDialog("Failed to archive topic")
456
+ alertDialog(this._i18n("archive_error"))
435
457
  }
436
458
  } catch (e) {
437
459
  console.error("Error archiving topic", e)
@@ -454,7 +476,7 @@ export default class extends Controller {
454
476
  if (response.ok) {
455
477
  this.loadTopics()
456
478
  } else {
457
- alertDialog("Failed to restore topic")
479
+ alertDialog(this._i18n("restore_error"))
458
480
  }
459
481
  } catch (e) {
460
482
  console.error("Error restoring topic", e)
@@ -468,11 +490,59 @@ export default class extends Controller {
468
490
  this.restoreSelection()
469
491
  }
470
492
 
493
+ openTopicListPopup(event) {
494
+ const btnRect = event.currentTarget.getBoundingClientRect()
495
+
496
+ const openWith = (popup) => {
497
+ popup.openForTopics(
498
+ {
499
+ topics: this.topics || [],
500
+ archivedTopics: this.archivedTopics || [],
501
+ mainTopicId: this.mainTopicId,
502
+ allMessagesLabel: this.element.dataset.topicMainText || 'All Messages'
503
+ },
504
+ btnRect,
505
+ (item) => this.selectTopic(item.id),
506
+ this.element
507
+ )
508
+ }
509
+
510
+ let modal = document.getElementById('topic-list-modal')
511
+ if (modal) {
512
+ const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
513
+ if (popup) openWith(popup)
514
+ return
515
+ }
516
+
517
+ modal = document.createElement('div')
518
+ modal.id = 'topic-list-modal'
519
+ modal.className = 'common-popup'
520
+ modal.style.display = 'none'
521
+ modal.dataset.controller = 'topic-list'
522
+ modal.innerHTML = `
523
+ <button type="button" class="popup-close-btn" data-topic-list-target="close">&times;</button>
524
+ <input type="text" class="shared-input-surface" style="width:100%;margin-bottom:0.5em;"
525
+ placeholder="${this.element.dataset.topicSearchPlaceholderText || 'Search topics...'}"
526
+ data-topic-list-target="input">
527
+ <ul class="common-popup-list" data-popup-list data-topic-list-target="list"></ul>
528
+ `
529
+ // Append into the chat box (this.element === #comments-popup) so the popup
530
+ // is caged within it and shares its stacking context.
531
+ this.element.appendChild(modal)
532
+
533
+ requestAnimationFrame(() => {
534
+ const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
535
+ if (popup) openWith(popup)
536
+ else console.error('topic-list controller not found after creation')
537
+ })
538
+ }
539
+
471
540
  showInput(event) {
472
541
  event.preventDefault()
473
- const container = this.element.querySelector('[data-comments--topics-target="creationContainer"]')
474
- if (!container) return
542
+ if (!this.hasCreationContainerTarget) return
543
+ const container = this.creationContainerTarget
475
544
 
545
+ this._draftCreativeId = this.creativeId
476
546
  const placeholder = this.listTarget.dataset.newTopicPlaceholder || "New Topic"
477
547
  container.innerHTML = `<input type="text" class="topic-input" placeholder="${placeholder}"
478
548
  data-action="keydown->comments--topics#handleInputKey blur->comments--topics#resetInput"
@@ -485,9 +555,8 @@ export default class extends Controller {
485
555
  resetInput() {
486
556
  // Small delay to allow enter key to process first if that was the cause
487
557
  setTimeout(() => {
488
- const container = this.element.querySelector('[data-comments--topics-target="creationContainer"]')
489
- if (container && !this.creating) {
490
- container.innerHTML = `<button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>`
558
+ if (this.hasCreationContainerTarget && !this.creating) {
559
+ this.renderAddButton()
491
560
  }
492
561
  }, 200)
493
562
  }
@@ -614,7 +683,7 @@ export default class extends Controller {
614
683
  this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
615
684
  this.restoreSelection()
616
685
  } else {
617
- alertDialog("Failed to update topic")
686
+ alertDialog(this._i18n("update_error"))
618
687
  this.loadTopics() // Reload to restore state
619
688
  }
620
689
  } catch (e) {
@@ -705,7 +774,7 @@ export default class extends Controller {
705
774
  // Dispatch change event manually since we skipped the click handler
706
775
  this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
707
776
  } else {
708
- alertDialog("Failed to create topic")
777
+ alertDialog(this._i18n("create_error"))
709
778
  }
710
779
  } catch (e) {
711
780
  console.error("Error creating topic", e)
@@ -953,6 +1022,21 @@ export default class extends Controller {
953
1022
  }
954
1023
  }
955
1024
 
1025
+ // Localized strings are handed down from the ERB partial as data
1026
+ // attributes; the English literals are last-resort fallbacks for when the
1027
+ // controller is mounted without them.
1028
+ _i18n(key) {
1029
+ const translations = {
1030
+ set_agent_error: this.element.dataset.topicSetAgentError || 'Unable to assign the agent to this topic.',
1031
+ create_error: this.element.dataset.topicCreateError || 'Unable to create the topic.',
1032
+ update_error: this.element.dataset.topicUpdateError || 'Unable to update the topic.',
1033
+ delete_error: this.element.dataset.topicDeleteError || 'Unable to delete the topic.',
1034
+ archive_error: this.element.dataset.topicArchiveError || 'Unable to archive the topic.',
1035
+ restore_error: this.element.dataset.topicRestoreError || 'Unable to restore the topic.'
1036
+ }
1037
+ return translations[key] || key
1038
+ }
1039
+
956
1040
  async setTopicPrimaryAgent(topicId, agent) {
957
1041
  if (!this.creativeId) return
958
1042
 
@@ -966,13 +1050,23 @@ export default class extends Controller {
966
1050
  body: JSON.stringify({ agent_id: agent.id })
967
1051
  })
968
1052
 
1053
+ const data = await response.json().catch(() => ({}))
1054
+
969
1055
  if (!response.ok) {
970
- const data = await response.json()
971
- console.error('Failed to set primary agent:', data.error)
1056
+ alertDialog(data.error || this._i18n("set_agent_error"))
1057
+ return
972
1058
  }
973
- // Topic update comes via WebSocket broadcast
1059
+
1060
+ // Render the avatar from the response rather than waiting for the
1061
+ // WebSocket broadcast. A dropped broadcast (e.g. the topics channel
1062
+ // subscription was refused) would otherwise leave the avatar
1063
+ // invisible until the next page load. The broadcast still runs and
1064
+ // propagates the change to other connected users; re-applying it
1065
+ // here is a no-op merge.
1066
+ if (data.topic) this.updateTopicInList(data.topic)
974
1067
  } catch (e) {
975
1068
  console.error('Error setting primary agent', e)
1069
+ alertDialog(this._i18n("set_agent_error"))
976
1070
  }
977
1071
  }
978
1072
 
@@ -22,8 +22,8 @@ export default class extends Controller {
22
22
  this.popup = null
23
23
  }
24
24
 
25
- open(anchorRect) {
26
- this.popup.showAt(anchorRect)
25
+ open(anchorRect, boundsElement = null) {
26
+ this.popup.showAt(anchorRect, boundsElement)
27
27
  }
28
28
 
29
29
  close() {
@@ -22,6 +22,7 @@ import ClickTargetController from "./click_target_controller"
22
22
  import TabsController from "./tabs_controller"
23
23
  import LinkCreativeController from "./link_creative_controller"
24
24
  import TopicSearchController from "./topic_search_controller"
25
+ import TopicListController from "./topic_list_controller"
25
26
  import CommonPopupController from "./common_popup_controller"
26
27
  import CommentController from "./comment_controller"
27
28
  import ReactionPickerController from "./reaction_picker_controller"
@@ -59,6 +60,7 @@ export {
59
60
  TabsController,
60
61
  LinkCreativeController,
61
62
  TopicSearchController,
63
+ TopicListController,
62
64
  CommonPopupController,
63
65
  CommentController,
64
66
  ReactionPickerController,
@@ -98,6 +100,7 @@ export function registerControllers(application) {
98
100
  application.register("tabs", TabsController)
99
101
  application.register("link-creative", LinkCreativeController)
100
102
  application.register("topic-search", TopicSearchController)
103
+ application.register("topic-list", TopicListController)
101
104
  application.register("common-popup", CommonPopupController)
102
105
  application.register("comment", CommentController)
103
106
  application.register("reaction-picker", ReactionPickerController)
@@ -9,9 +9,14 @@ export default class extends Controller {
9
9
  }
10
10
  document.addEventListener('keydown', this._escHandler)
11
11
 
12
- // Global keyboard shortcut: Cmd/Ctrl + K to open search
12
+ // Global keyboard shortcut: Cmd/Ctrl + K to open search.
13
+ // Skip when focus is inside an editable element (input, textarea,
14
+ // contenteditable, or the inline Lexical editor) so that element's own
15
+ // Ctrl+K behavior (e.g. delete-to-end-of-line) takes precedence over the
16
+ // global search popup.
13
17
  this._shortcutHandler = (e) => {
14
18
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
19
+ if (this._isEditableTarget(e.target)) return
15
20
  e.preventDefault()
16
21
  this.toggle()
17
22
  }
@@ -19,6 +24,17 @@ export default class extends Controller {
19
24
  document.addEventListener('keydown', this._shortcutHandler)
20
25
  }
21
26
 
27
+ _isEditableTarget(target) {
28
+ const el = target instanceof Element ? target : document.activeElement
29
+ if (!el || typeof el.closest !== 'function') return false
30
+ const tag = el.tagName
31
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
32
+ if (el.isContentEditable) return true
33
+ return Boolean(
34
+ el.closest('[contenteditable]:not([contenteditable="false"]), [data-lexical-editor-root]')
35
+ )
36
+ }
37
+
22
38
  disconnect() {
23
39
  document.removeEventListener('keydown', this._escHandler)
24
40
  document.removeEventListener('keydown', this._shortcutHandler)
@@ -0,0 +1,78 @@
1
+ import CommonPopupController from './common_popup_controller'
2
+
3
+ const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>`
4
+
5
+ export default class extends CommonPopupController {
6
+ static targets = ['input', 'list', 'close']
7
+
8
+ connect() {
9
+ super.connect()
10
+ this._allItems = []
11
+ this._onInputBound = this._onInput.bind(this)
12
+ this._onKeydownBound = this.handleInputKeydown.bind(this)
13
+ this._onCloseBound = () => this.close()
14
+ this.inputTarget.addEventListener('input', this._onInputBound)
15
+ this.inputTarget.addEventListener('keydown', this._onKeydownBound)
16
+ this.closeTarget.addEventListener('click', this._onCloseBound)
17
+ }
18
+
19
+ disconnect() {
20
+ this.inputTarget?.removeEventListener('input', this._onInputBound)
21
+ this.inputTarget?.removeEventListener('keydown', this._onKeydownBound)
22
+ this.closeTarget?.removeEventListener('click', this._onCloseBound)
23
+ super.disconnect()
24
+ }
25
+
26
+ openForTopics({ topics = [], archivedTopics = [], mainTopicId = null, allMessagesLabel = 'All Messages' }, anchorRect, onSelectCallback, boundsElement = null) {
27
+ this.onSelectCallback = onSelectCallback
28
+ this._allItems = this._buildItems({ topics, archivedTopics, mainTopicId, allMessagesLabel })
29
+ this.inputTarget.value = ''
30
+ this.setItems(this._allItems)
31
+ super.open(anchorRect, boundsElement)
32
+ requestAnimationFrame(() => this.inputTarget.focus())
33
+ }
34
+
35
+ _buildItems({ topics, archivedTopics, mainTopicId, allMessagesLabel }) {
36
+ const main = mainTopicId ? topics.find(t => String(t.id) === String(mainTopicId)) : null
37
+ const others = topics.filter(t => !main || String(t.id) !== String(mainTopicId))
38
+ const items = []
39
+ if (main) items.push({ id: main.id, label: `#${main.name}`, archived: false })
40
+ others.forEach(t => items.push({ id: t.id, label: `#${t.name}`, archived: false }))
41
+ items.push({ id: '', label: `📋 ${allMessagesLabel}`, archived: false })
42
+ archivedTopics.forEach(t => items.push({ id: t.id, label: `#${t.name}`, archived: true }))
43
+ return items
44
+ }
45
+
46
+ _onInput() {
47
+ const q = this.inputTarget.value.toLowerCase().trim()
48
+ if (!q) { this.setItems(this._allItems); return }
49
+ const filtered = this._allItems.filter(i => (i.label || '').toLowerCase().includes(q))
50
+ this.setItems(filtered)
51
+ }
52
+
53
+ handleInputKeydown(event) {
54
+ if (this.handleKey(event)) return
55
+ if (event.key === 'Escape') this.close()
56
+ }
57
+
58
+ select(item) {
59
+ if (this.onSelectCallback) this.onSelectCallback(item)
60
+ this.close()
61
+ }
62
+
63
+ renderItem(item) {
64
+ const escaped = String(item.label || '')
65
+ .replace(/&/g, '&amp;')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;')
68
+ if (item.archived) {
69
+ return `<span class="topic-list-item topic-list-item--archived">${ICON_ARCHIVE} ${escaped}</span>`
70
+ }
71
+ return `<span class="topic-list-item">${escaped}</span>`
72
+ }
73
+
74
+ dispatchClose(reason) {
75
+ this.onSelectCallback = null
76
+ super.dispatchClose(reason)
77
+ }
78
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import CommonPopup from '../common_popup'
5
+
6
+ // Cage-inside-bounds positioning: when a boundsElement is passed to showAt,
7
+ // updatePosition must clamp the popup within that element's rect (not the
8
+ // viewport) and cap its size so a long list scrolls inside instead of spilling.
9
+ describe('CommonPopup bounds clamping', () => {
10
+ const BOUNDS = { left: 100, top: 100, right: 500, bottom: 700, width: 400, height: 600 }
11
+
12
+ let element, boundsElement
13
+
14
+ const build = ({ popupWidth, popupHeight }) => {
15
+ boundsElement = document.createElement('div')
16
+ boundsElement.getBoundingClientRect = () => BOUNDS
17
+
18
+ element = document.createElement('div')
19
+ // offsetParent is the bounds (chat box); parentRect === BOUNDS.
20
+ Object.defineProperty(element, 'offsetParent', { value: boundsElement, configurable: true })
21
+ Object.defineProperty(element, 'offsetWidth', { value: popupWidth, configurable: true })
22
+ Object.defineProperty(element, 'offsetHeight', { value: popupHeight, configurable: true })
23
+ document.body.appendChild(element)
24
+ return new CommonPopup(element, { listElement: document.createElement('ul') })
25
+ }
26
+
27
+ afterEach(() => { document.body.innerHTML = '' })
28
+
29
+ test('clamps an anchor near the right/bottom edge back inside the bounds', () => {
30
+ const popup = build({ popupWidth: 320, popupHeight: 400 })
31
+ popup._boundsElement = boundsElement
32
+ // Anchor button hard against the right edge — would overflow the box.
33
+ // maxLeft = right - width - pad = 500 - 320 - 8 = 172; parent coords: 172 - 100 = 72
34
+ popup.updatePosition({ left: 450, bottom: 130 })
35
+ expect(element.style.left).toBe('72px')
36
+ // viewportTop = 130 + 4 = 134; within [108, 292] → 134; parent coords: 134 - 100 = 34
37
+ expect(element.style.top).toBe('34px')
38
+ })
39
+
40
+ test('caps max size to fit inside the bounds', () => {
41
+ const popup = build({ popupWidth: 320, popupHeight: 400 })
42
+ popup._boundsElement = boundsElement
43
+ popup.updatePosition({ left: 200, bottom: 200 })
44
+ // inner box = dimension - 2*pad
45
+ expect(element.style.maxWidth).toBe('384px')
46
+ expect(element.style.maxHeight).toBe('584px')
47
+ })
48
+
49
+ test('falls back to viewport clamping when no bounds element is set', () => {
50
+ const popup = build({ popupWidth: 320, popupHeight: 400 })
51
+ popup._boundsElement = null
52
+ // jsdom viewport defaults 1024x768; anchor at 0 → left clamps to padding 8, parent 0
53
+ Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
54
+ popup.updatePosition({ left: 0, bottom: 0 })
55
+ expect(element.style.maxHeight).toBe('')
56
+ expect(element.style.left).toBe('8px')
57
+ })
58
+ })