collavre 0.20.3 → 0.22.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/assets/stylesheets/collavre/popup.css +148 -0
  7. data/app/channels/collavre/agent_channel.rb +205 -0
  8. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  9. data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
  10. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  11. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  12. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  13. data/app/controllers/collavre/application_controller.rb +27 -0
  14. data/app/controllers/collavre/attachments_controller.rb +30 -2
  15. data/app/controllers/collavre/channels_controller.rb +23 -0
  16. data/app/controllers/collavre/comments_controller.rb +1 -1
  17. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  18. data/app/controllers/collavre/creatives_controller.rb +141 -7
  19. data/app/controllers/collavre/landing_controller.rb +8 -0
  20. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  21. data/app/controllers/collavre/tasks_controller.rb +12 -4
  22. data/app/controllers/collavre/topics_controller.rb +36 -30
  23. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  24. data/app/helpers/collavre/comments_helper.rb +7 -0
  25. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  26. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  27. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  28. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  29. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  30. data/app/javascript/controllers/comment_controller.js +15 -1
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  32. data/app/javascript/controllers/comments/form_controller.js +4 -0
  33. data/app/javascript/controllers/comments/list_controller.js +27 -9
  34. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  35. data/app/javascript/controllers/comments/presence_controller.js +137 -4
  36. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  37. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  38. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  39. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  40. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  41. data/app/javascript/controllers/index.js +4 -1
  42. data/app/javascript/controllers/landing_video_controller.js +53 -0
  43. data/app/javascript/controllers/link_creative_controller.js +451 -29
  44. data/app/javascript/creatives/tree_renderer.js +6 -0
  45. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  46. data/app/javascript/lib/api/creatives.js +13 -0
  47. data/app/javascript/lib/api/queue_manager.js +17 -5
  48. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  49. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  50. data/app/javascript/lib/lexical/color_import.js +186 -0
  51. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  52. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  53. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  54. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  55. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  56. data/app/javascript/modules/command_args_form.js +22 -4
  57. data/app/javascript/modules/command_menu.js +27 -0
  58. data/app/javascript/modules/creative_row_editor.js +227 -17
  59. data/app/javascript/modules/html_content_empty.js +12 -0
  60. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  61. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  62. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  63. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  64. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  65. data/app/mailers/collavre/application_mailer.rb +1 -1
  66. data/app/models/collavre/agent_subscription.rb +52 -0
  67. data/app/models/collavre/channel/injected_message.rb +5 -0
  68. data/app/models/collavre/channel.rb +87 -0
  69. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  70. data/app/models/collavre/comment.rb +70 -5
  71. data/app/models/collavre/creative/describable.rb +202 -3
  72. data/app/models/collavre/creative.rb +2 -0
  73. data/app/models/collavre/creative_share.rb +1 -0
  74. data/app/models/collavre/integration_setting.rb +35 -0
  75. data/app/models/collavre/preview_channel.rb +93 -0
  76. data/app/models/collavre/system_setting.rb +13 -2
  77. data/app/models/collavre/task.rb +34 -5
  78. data/app/models/collavre/topic.rb +8 -25
  79. data/app/models/collavre/user.rb +4 -0
  80. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  81. data/app/services/collavre/agent_session_abort.rb +28 -0
  82. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  83. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  84. data/app/services/collavre/ai_agent_service.rb +68 -49
  85. data/app/services/collavre/ai_client.rb +3 -3
  86. data/app/services/collavre/attachment_backfill.rb +26 -0
  87. data/app/services/collavre/channel_attacher.rb +58 -0
  88. data/app/services/collavre/comments/mcp_command.rb +31 -1
  89. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  90. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  91. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  92. data/app/services/collavre/creatives/index_query.rb +110 -8
  93. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  94. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  95. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  96. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  97. data/app/services/collavre/google_calendar_service.rb +4 -2
  98. data/app/services/collavre/markdown_converter.rb +130 -15
  99. data/app/services/collavre/markdown_importer.rb +7 -2
  100. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  101. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  102. data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
  103. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  104. data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
  105. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  106. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  107. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  108. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  109. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  110. data/app/services/collavre/topic_branch_service.rb +34 -26
  111. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  112. data/app/views/admin/shared/_tabs.html.erb +1 -0
  113. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  114. data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
  115. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  116. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  117. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  118. data/app/views/collavre/comments/_comment.html.erb +16 -2
  119. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  120. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  121. data/app/views/collavre/creatives/index.html.erb +10 -2
  122. data/app/views/collavre/landing/show.html.erb +130 -0
  123. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  124. data/app/views/layouts/collavre/landing.html.erb +33 -0
  125. data/config/locales/admin.en.yml +4 -2
  126. data/config/locales/admin.ko.yml +4 -2
  127. data/config/locales/channels.en.yml +13 -0
  128. data/config/locales/channels.ko.yml +13 -0
  129. data/config/locales/claude_channel.en.yml +16 -0
  130. data/config/locales/claude_channel.ko.yml +16 -0
  131. data/config/locales/comments.en.yml +5 -0
  132. data/config/locales/comments.ko.yml +5 -0
  133. data/config/locales/creatives.en.yml +11 -0
  134. data/config/locales/creatives.ko.yml +10 -0
  135. data/config/locales/integrations.en.yml +55 -0
  136. data/config/locales/integrations.ko.yml +55 -0
  137. data/config/locales/landing.en.yml +51 -0
  138. data/config/locales/landing.ko.yml +51 -0
  139. data/config/routes.rb +30 -0
  140. data/db/migrate/20260526000000_create_channels.rb +42 -0
  141. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  142. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  143. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  144. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  145. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  146. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  147. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  148. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  149. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  150. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  151. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  152. data/db/seeds.rb +19 -0
  153. data/lib/collavre/aws_credentials.rb +75 -0
  154. data/lib/collavre/engine.rb +50 -0
  155. data/lib/collavre/integration_settings/key_definition.rb +35 -0
  156. data/lib/collavre/integration_settings/registry.rb +60 -0
  157. data/lib/collavre/integration_settings/resolver.rb +71 -0
  158. data/lib/collavre/integration_settings.rb +46 -0
  159. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  160. data/lib/collavre/version.rb +1 -1
  161. data/lib/collavre.rb +3 -0
  162. metadata +82 -2
  163. data/app/services/collavre/openclaw_abort_service.rb +0 -45
@@ -0,0 +1,447 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ const browse = jest.fn()
8
+ const search = jest.fn()
9
+
10
+ jest.unstable_mockModule('../../lib/api/creatives', () => ({
11
+ default: { browse, search },
12
+ browse,
13
+ search,
14
+ }))
15
+
16
+ const { Application } = await import('@hotwired/stimulus')
17
+ const LinkCreativeController = (await import('../link_creative_controller')).default
18
+
19
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
20
+
21
+ const installController = async () => {
22
+ document.body.innerHTML = `
23
+ <div id="link-creative-modal" class="common-popup" style="display:none;" data-controller="link-creative"
24
+ data-link-creative-loading-text="Loading…"
25
+ data-link-creative-no-results-text="No results"
26
+ data-link-creative-empty-text="Empty"
27
+ data-link-creative-expand-text="Expand">
28
+ <button type="button" class="popup-close-btn" data-link-creative-target="close">&times;</button>
29
+ <input type="text" data-link-creative-target="input">
30
+ <ul class="common-popup-list link-creative-list" data-popup-list data-link-creative-target="list"></ul>
31
+ </div>`
32
+
33
+ const application = Application.start()
34
+ application.register('link-creative', LinkCreativeController)
35
+ await flush() // Stimulus connects asynchronously
36
+ const element = document.getElementById('link-creative-modal')
37
+ const controller = application.getControllerForElementAndIdentifier(element, 'link-creative')
38
+ return { application, element, controller }
39
+ }
40
+
41
+ const rect = { left: 0, top: 0, bottom: 0, right: 0, width: 0, height: 0 }
42
+
43
+ describe('LinkCreativeController picker', () => {
44
+ beforeAll(() => {
45
+ // jsdom does not implement scrollIntoView (used when highlighting a node).
46
+ window.HTMLElement.prototype.scrollIntoView = jest.fn()
47
+ // jsdom does not compute layout, so offsetParent is null for every element;
48
+ // the picker's _visibleRows() filters on it. Treat any attached element as
49
+ // visible so keyboard navigation over the rendered rows is exercisable.
50
+ Object.defineProperty(window.HTMLElement.prototype, 'offsetParent', {
51
+ configurable: true,
52
+ get() {
53
+ return this.parentNode
54
+ },
55
+ })
56
+ })
57
+
58
+ afterEach(() => {
59
+ document.body.innerHTML = ''
60
+ jest.clearAllMocks()
61
+ })
62
+
63
+ test('renders a browsable tree on open with toggles for nodes that have children', async () => {
64
+ browse.mockResolvedValue([
65
+ { id: 1, description: 'Root A', progress: 0, has_children: true },
66
+ { id: 2, description: 'Root B', progress: 0, has_children: false },
67
+ ])
68
+
69
+ const { application, controller } = await installController()
70
+ controller.open(rect, jest.fn(), jest.fn())
71
+ await flush()
72
+
73
+ expect(browse).toHaveBeenCalledWith(null)
74
+ const items = document.querySelectorAll('.link-tree-item')
75
+ expect(items).toHaveLength(2)
76
+ // First node is expandable, second is a leaf
77
+ expect(items[0].querySelector('.link-tree-toggle:not(.link-tree-toggle-empty)')).not.toBeNull()
78
+ expect(items[1].querySelector('.link-tree-toggle-empty')).not.toBeNull()
79
+ expect(items[0].querySelector('.link-tree-label').textContent).toBe('Root A')
80
+
81
+ application.stop()
82
+ })
83
+
84
+ test('expands a node lazily, loading its children on toggle click', async () => {
85
+ browse
86
+ .mockResolvedValueOnce([{ id: 1, description: 'Root A', progress: 0, has_children: true }])
87
+ .mockResolvedValueOnce([{ id: 5, description: 'Child', progress: 0, has_children: false }])
88
+
89
+ const { application, controller } = await installController()
90
+ controller.open(rect, jest.fn(), jest.fn())
91
+ await flush()
92
+
93
+ const toggle = document.querySelector('.link-tree-toggle:not(.link-tree-toggle-empty)')
94
+ // Collapsed toggle renders the same SVG chevron as the main creative tree.
95
+ expect(toggle.querySelector('svg')).not.toBeNull()
96
+ expect(toggle.textContent).toBe('')
97
+
98
+ toggle.dispatchEvent(new MouseEvent('click', { bubbles: true }))
99
+ await flush()
100
+
101
+ expect(browse).toHaveBeenLastCalledWith('1')
102
+ const child = document.querySelector('.link-tree-children .link-tree-item .link-tree-label')
103
+ expect(child.textContent).toBe('Child')
104
+ // Expanded toggle still renders an SVG chevron (icon swapped, not text).
105
+ expect(toggle.querySelector('svg')).not.toBeNull()
106
+
107
+ application.stop()
108
+ })
109
+
110
+ test('does not search below the 2-character threshold', async () => {
111
+ browse.mockResolvedValue([])
112
+ const { application, controller } = await installController()
113
+ controller.open(rect, jest.fn(), jest.fn())
114
+ await flush()
115
+
116
+ controller.inputTarget.value = 'a'
117
+ controller.search()
118
+ await flush()
119
+
120
+ expect(search).not.toHaveBeenCalled()
121
+
122
+ application.stop()
123
+ })
124
+
125
+ test('renders flat results with a clickable breadcrumb at >= 2 characters', async () => {
126
+ browse.mockResolvedValue([])
127
+ search.mockResolvedValue([
128
+ {
129
+ id: 9,
130
+ description: 'Deep Leaf',
131
+ progress: 0,
132
+ path: [
133
+ { id: 1, description: 'Root' },
134
+ { id: 3, description: 'Mid' },
135
+ ],
136
+ },
137
+ ])
138
+
139
+ const { application, controller } = await installController()
140
+ controller.open(rect, jest.fn(), jest.fn())
141
+ await flush()
142
+
143
+ controller.inputTarget.value = 'lea'
144
+ controller.search()
145
+ await flush()
146
+
147
+ expect(search).toHaveBeenCalledWith('lea', { simple: true })
148
+ const result = document.querySelector('.link-result-item')
149
+ expect(result.querySelector('.link-result-label').textContent).toBe('Deep Leaf')
150
+ const crumbs = result.querySelectorAll('.link-crumb')
151
+ expect(Array.from(crumbs).map((c) => c.textContent)).toEqual(['Root', 'Mid'])
152
+
153
+ application.stop()
154
+ })
155
+
156
+ test('masks restricted ancestors in the breadcrumb and keeps them non-clickable', async () => {
157
+ browse.mockResolvedValue([])
158
+ search.mockResolvedValue([
159
+ {
160
+ id: 9,
161
+ description: 'Deep Leaf',
162
+ progress: 0,
163
+ path: [
164
+ { id: 1, description: null, restricted: true },
165
+ { id: 3, description: 'Mid' },
166
+ ],
167
+ },
168
+ ])
169
+
170
+ const { application, controller } = await installController()
171
+ controller.open(rect, jest.fn(), jest.fn())
172
+ await flush()
173
+
174
+ controller.inputTarget.value = 'lea'
175
+ controller.search()
176
+ await flush()
177
+
178
+ const restricted = document.querySelector('.link-crumb-restricted')
179
+ expect(restricted).not.toBeNull()
180
+ expect(restricted.tagName).toBe('SPAN') // not a button -> not clickable
181
+ expect(restricted.textContent).toBe('…')
182
+
183
+ application.stop()
184
+ })
185
+
186
+ test('localizes the expand toggle aria-label from the data attribute', async () => {
187
+ browse.mockResolvedValue([{ id: 1, description: 'Root A', progress: 0, has_children: true }])
188
+
189
+ const { application, controller } = await installController()
190
+ controller.open(rect, jest.fn(), jest.fn())
191
+ await flush()
192
+
193
+ const toggle = document.querySelector('.link-tree-toggle:not(.link-tree-toggle-empty)')
194
+ expect(toggle.getAttribute('aria-label')).toBe('Expand')
195
+
196
+ application.stop()
197
+ })
198
+
199
+ test('breadcrumb maps an origin id back to its linked shell root and expands it', async () => {
200
+ // Root browse returns a linked shell (id 100) whose effective origin is 1.
201
+ browse
202
+ .mockResolvedValueOnce([
203
+ { id: 100, description: 'Shared', progress: 0, has_children: true, origin_id: 1 },
204
+ ])
205
+ // Expanding the shell loads the origin's real children (id 3).
206
+ .mockResolvedValueOnce([{ id: 3, description: 'Mid', progress: 0, has_children: false }])
207
+ // Search match's breadcrumb carries origin ids (root crumb id 1 == shell origin).
208
+ search.mockResolvedValue([
209
+ {
210
+ id: 9,
211
+ description: 'Deep Leaf',
212
+ progress: 0,
213
+ path: [
214
+ { id: 1, description: 'Shared' },
215
+ { id: 3, description: 'Mid' },
216
+ ],
217
+ },
218
+ ])
219
+
220
+ const { application, controller } = await installController()
221
+ controller.open(rect, jest.fn(), jest.fn())
222
+ await flush() // caches root nodes (the shell)
223
+
224
+ controller.inputTarget.value = 'lea'
225
+ controller.search()
226
+ await flush()
227
+
228
+ // Click the second crumb (Mid): chain = [origin id 1], target = 3.
229
+ const crumbs = document.querySelectorAll('.link-crumb')
230
+ crumbs[1].dispatchEvent(new MouseEvent('click', { bubbles: true }))
231
+ await flush()
232
+ await flush()
233
+
234
+ // The shell (id 100) must have been expanded via its origin id (1), not skipped.
235
+ expect(browse).toHaveBeenLastCalledWith('100')
236
+ const mid = document.querySelector('.link-tree-children .link-tree-item[data-id="3"]')
237
+ expect(mid).not.toBeNull()
238
+
239
+ application.stop()
240
+ })
241
+
242
+ test('reveal_path expands local folders to reach a nested linked shell', async () => {
243
+ // The shell is nested under the user's own (collapsed) folder, so root
244
+ // browse returns only that folder — not the shell.
245
+ browse
246
+ .mockResolvedValueOnce([
247
+ { id: 50, description: 'Local Folder', progress: 0, has_children: true },
248
+ ])
249
+ // Expanding the local folder reveals the shell (id 100, origin 1).
250
+ .mockResolvedValueOnce([
251
+ { id: 100, description: 'Shared', progress: 0, has_children: true, origin_id: 1 },
252
+ ])
253
+ // Expanding the shell loads the origin's real children (id 3).
254
+ .mockResolvedValueOnce([{ id: 3, description: 'Mid', progress: 0, has_children: false }])
255
+ // Origin-space breadcrumb (ids 1,3) plus the user-local reveal path to the shell.
256
+ search.mockResolvedValue([
257
+ {
258
+ id: 9,
259
+ description: 'Deep Leaf',
260
+ progress: 0,
261
+ // Keyed by origin ancestor: origin 1's shell is reached via [50, 100].
262
+ reveal_path: { 1: [50, 100] },
263
+ path: [
264
+ { id: 1, description: 'Shared' },
265
+ { id: 3, description: 'Mid' },
266
+ ],
267
+ },
268
+ ])
269
+
270
+ const { application, controller } = await installController()
271
+ controller.open(rect, jest.fn(), jest.fn())
272
+ await flush() // caches root nodes (the local folder)
273
+
274
+ controller.inputTarget.value = 'lea'
275
+ controller.search()
276
+ await flush()
277
+
278
+ // Click the second crumb (Mid): anchor at origin 1 -> chain = [50, 100], target = 3.
279
+ const crumbs = document.querySelectorAll('.link-crumb')
280
+ crumbs[1].dispatchEvent(new MouseEvent('click', { bubbles: true }))
281
+ for (let i = 0; i < 5; i++) await flush()
282
+
283
+ // Local folder (50) and shell (100) were both expanded to surface the hit.
284
+ expect(browse).toHaveBeenCalledWith('50')
285
+ expect(browse).toHaveBeenCalledWith('100')
286
+ const mid = document.querySelector('.link-tree-children .link-tree-item[data-id="3"]')
287
+ expect(mid).not.toBeNull()
288
+
289
+ application.stop()
290
+ })
291
+
292
+ test('a higher ancestor crumb resolves through its own shell, not the deepest one', async () => {
293
+ // The user holds a shell for both origin 1 (under folder 50) and origin 3
294
+ // (under folder 60). Root browse returns both local folders.
295
+ browse
296
+ .mockResolvedValueOnce([
297
+ { id: 50, description: 'Folder A', progress: 0, has_children: true },
298
+ { id: 60, description: 'Folder D', progress: 0, has_children: true },
299
+ ])
300
+ // Expanding folder A (50) reveals origin 1's shell (id 100).
301
+ .mockResolvedValueOnce([
302
+ { id: 100, description: 'Ancestor', progress: 0, has_children: true, origin_id: 1 },
303
+ ])
304
+ // Expanding that shell loads origin 1's children.
305
+ .mockResolvedValueOnce([{ id: 3, description: 'Descendant', progress: 0, has_children: false }])
306
+ // Per-origin reveal map: each origin ancestor maps to its own shell path.
307
+ search.mockResolvedValue([
308
+ {
309
+ id: 9,
310
+ description: 'Deep Leaf',
311
+ progress: 0,
312
+ reveal_path: { 1: [50, 100], 3: [60, 200] },
313
+ path: [
314
+ { id: 1, description: 'Ancestor' },
315
+ { id: 3, description: 'Descendant' },
316
+ ],
317
+ },
318
+ ])
319
+
320
+ const { application, controller } = await installController()
321
+ controller.open(rect, jest.fn(), jest.fn())
322
+ await flush() // caches root nodes (both local folders)
323
+
324
+ controller.inputTarget.value = 'lea'
325
+ controller.search()
326
+ await flush()
327
+
328
+ // Click the FIRST crumb (Ancestor, id 1): must anchor at origin 1's shell
329
+ // (folder 50 -> shell 100), NOT the deeper origin 3 shell (folder 60).
330
+ const crumbs = document.querySelectorAll('.link-crumb')
331
+ crumbs[0].dispatchEvent(new MouseEvent('click', { bubbles: true }))
332
+ for (let i = 0; i < 5; i++) await flush()
333
+
334
+ expect(browse).toHaveBeenCalledWith('50')
335
+ expect(browse).toHaveBeenCalledWith('100')
336
+ expect(browse).not.toHaveBeenCalledWith('60')
337
+ expect(browse).not.toHaveBeenCalledWith('200')
338
+
339
+ application.stop()
340
+ })
341
+
342
+ test('selecting a tree row invokes the callback with id and label', async () => {
343
+ browse.mockResolvedValue([{ id: 7, description: 'Pick Me', progress: 0, has_children: false }])
344
+ const onSelect = jest.fn()
345
+
346
+ const { application, controller } = await installController()
347
+ controller.open(rect, onSelect, jest.fn())
348
+ await flush()
349
+
350
+ document.querySelector('.link-tree-row').dispatchEvent(new MouseEvent('click', { bubbles: true }))
351
+ await flush()
352
+
353
+ expect(onSelect).toHaveBeenCalledWith({ id: 7, label: 'Pick Me' })
354
+
355
+ application.stop()
356
+ })
357
+
358
+ test('selecting a linked shell row emits the effective origin id', async () => {
359
+ // Shell row (id 100) whose effective origin is 1; selecting it must hand the
360
+ // origin id to consumers so a new link is based on the real shared creative,
361
+ // not the user's shell (which would corrupt the permission base).
362
+ browse.mockResolvedValue([
363
+ { id: 100, description: 'Shared', progress: 0, has_children: false, origin_id: 1 },
364
+ ])
365
+ const onSelect = jest.fn()
366
+
367
+ const { application, controller } = await installController()
368
+ controller.open(rect, onSelect, jest.fn())
369
+ await flush()
370
+
371
+ document.querySelector('.link-tree-row').dispatchEvent(new MouseEvent('click', { bubbles: true }))
372
+ await flush()
373
+
374
+ expect(onSelect).toHaveBeenCalledWith({ id: 1, label: 'Shared' })
375
+
376
+ application.stop()
377
+ })
378
+
379
+ test('Tab in the initial browse state moves focus instead of linking the first row', async () => {
380
+ // Empty input => browse: the first root is auto-highlighted but unchosen.
381
+ // Tab (leaving the search field) must not link it; focus moves normally.
382
+ browse.mockResolvedValue([{ id: 7, description: 'Pick Me', progress: 0, has_children: false }])
383
+ const onSelect = jest.fn()
384
+
385
+ const { application, controller } = await installController()
386
+ controller.open(rect, onSelect, jest.fn())
387
+ await flush()
388
+
389
+ const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true, bubbles: true })
390
+ controller.inputTarget.dispatchEvent(tab)
391
+ await flush()
392
+
393
+ expect(onSelect).not.toHaveBeenCalled()
394
+ expect(tab.defaultPrevented).toBe(false)
395
+
396
+ application.stop()
397
+ })
398
+
399
+ test('Tab selects the highlighted row after the user navigates the tree', async () => {
400
+ browse.mockResolvedValue([
401
+ { id: 7, description: 'Pick Me', progress: 0, has_children: false },
402
+ { id: 8, description: 'Second', progress: 0, has_children: false },
403
+ ])
404
+ const onSelect = jest.fn()
405
+
406
+ const { application, controller } = await installController()
407
+ controller.open(rect, onSelect, jest.fn())
408
+ await flush()
409
+
410
+ // Explicit navigation: move the active row down to the second node.
411
+ controller.inputTarget.dispatchEvent(
412
+ new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true, bubbles: true }),
413
+ )
414
+ const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true, bubbles: true })
415
+ controller.inputTarget.dispatchEvent(tab)
416
+ await flush()
417
+
418
+ expect(onSelect).toHaveBeenCalledWith({ id: 8, label: 'Second' })
419
+ expect(tab.defaultPrevented).toBe(true)
420
+
421
+ application.stop()
422
+ })
423
+
424
+ test('Tab selects the top search result without prior navigation', async () => {
425
+ // A typed query is an explicit choice context, so Tab selects the top result.
426
+ browse.mockResolvedValue([])
427
+ search.mockResolvedValue([{ id: 9, description: 'Found', progress: 0, path: [] }])
428
+ const onSelect = jest.fn()
429
+
430
+ const { application, controller } = await installController()
431
+ controller.open(rect, onSelect, jest.fn())
432
+ await flush()
433
+
434
+ controller.inputTarget.value = 'fo'
435
+ controller.search()
436
+ await flush()
437
+
438
+ const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true, bubbles: true })
439
+ controller.inputTarget.dispatchEvent(tab)
440
+ await flush()
441
+
442
+ expect(onSelect).toHaveBeenCalledWith({ id: 9, label: 'Found' })
443
+ expect(tab.defaultPrevented).toBe(true)
444
+
445
+ application.stop()
446
+ })
447
+ })
@@ -10,7 +10,7 @@ if (!window._streamingCommentIds) window._streamingCommentIds = new Set()
10
10
 
11
11
  // Connects to data-controller="comment"
12
12
  export default class extends Controller {
13
- static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls"]
13
+ static targets = ["ownerButton", "deleteButton", "approveButton", "denyButton", "actionApproveControls"]
14
14
 
15
15
  get _commentId() {
16
16
  return this.element.dataset.commentId
@@ -123,6 +123,15 @@ export default class extends Controller {
123
123
  }
124
124
  }
125
125
 
126
+ const actionMarkdownElement = this.element.querySelector('.comment-action-markdown')
127
+ if (actionMarkdownElement && actionMarkdownElement.dataset.rendered !== 'true') {
128
+ const text = actionMarkdownElement.textContent || ''
129
+ actionMarkdownElement.innerHTML = renderCommentMarkdown(text)
130
+ addTableDownloadButtons(actionMarkdownElement)
131
+ renderMermaidDiagrams(actionMarkdownElement)
132
+ actionMarkdownElement.dataset.rendered = 'true'
133
+ }
134
+
126
135
  // Text selection quote support
127
136
  this.handleMouseUp = this.handleMouseUp.bind(this)
128
137
  document.addEventListener('mouseup', this.handleMouseUp)
@@ -157,6 +166,11 @@ export default class extends Controller {
157
166
  this.approveButtonTargets.forEach((button) => {
158
167
  button.classList.remove('comment-approve-hidden')
159
168
  })
169
+ // Deny button (Claude Channel permission prompts only) is gated the same
170
+ // way as approve.
171
+ this.denyButtonTargets.forEach((button) => {
172
+ button.classList.remove('comment-approve-hidden')
173
+ })
160
174
  // Also show action block approve controls (edit action button, form)
161
175
  this.actionApproveControlsTargets.forEach((el) => {
162
176
  el.classList.remove('comment-approve-hidden')
@@ -121,3 +121,111 @@ describe('CommentsPresenceController', () => {
121
121
  expect(close).not.toHaveBeenCalled()
122
122
  })
123
123
  })
124
+
125
+ describe('CommentsPresenceController typing-row horizontal scroll', () => {
126
+ let application
127
+ let container
128
+ let controller
129
+ let row
130
+ let scrollLeftValue
131
+
132
+ // jsdom has no layout engine, so scroll geometry must be stubbed. Treat the
133
+ // row as 100px wide with 300px of content: "at end" means scrollLeft >= 176.
134
+ function setGeometry({ clientWidth, scrollWidth, scrollLeft }) {
135
+ scrollLeftValue = scrollLeft
136
+ Object.defineProperty(row, 'clientWidth', { value: clientWidth, configurable: true })
137
+ Object.defineProperty(row, 'scrollWidth', { value: scrollWidth, configurable: true })
138
+ Object.defineProperty(row, 'scrollLeft', {
139
+ configurable: true,
140
+ get: () => scrollLeftValue,
141
+ set: (v) => { scrollLeftValue = v },
142
+ })
143
+ }
144
+
145
+ beforeEach(async () => {
146
+ document.body.dataset.currentUserId = '7'
147
+ global.fetch = jest.fn()
148
+
149
+ container = document.createElement('div')
150
+ container.innerHTML = `
151
+ <div id="comments-popup" data-controller="comments--presence">
152
+ <textarea data-comments--presence-target="textarea"></textarea>
153
+ <input type="checkbox" data-comments--presence-target="privateCheckbox" />
154
+ <div id="typing-indicator-row">
155
+ <div id="typing-scroll-viewport" data-comments--presence-target="scrollRow">
156
+ <div data-comments--presence-target="channelChips"></div>
157
+ <div data-comments--presence-target="typingIndicator"></div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ `
162
+ document.body.appendChild(container)
163
+
164
+ application = Application.start()
165
+ application.register('comments--presence', PresenceController)
166
+ await new Promise((resolve) => setTimeout(resolve, 0))
167
+
168
+ const el = document.getElementById('comments-popup')
169
+ controller = application.getControllerForElementAndIdentifier(el, 'comments--presence')
170
+ controller.creativeId = '123'
171
+ row = el.querySelector('#typing-scroll-viewport')
172
+ })
173
+
174
+ afterEach(() => {
175
+ application.stop()
176
+ document.body.innerHTML = ''
177
+ delete document.body.dataset.currentUserId
178
+ jest.restoreAllMocks()
179
+ })
180
+
181
+ test('auto-scrolls a new typer into view when parked at the right edge', () => {
182
+ setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 200 }) // at end
183
+
184
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
185
+
186
+ expect(scrollLeftValue).toBe(300)
187
+ })
188
+
189
+ test('does NOT scroll when the user has scrolled back to look at earlier items', () => {
190
+ setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 }) // scrolled back
191
+
192
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
193
+
194
+ expect(scrollLeftValue).toBe(0)
195
+ })
196
+
197
+ test('does not re-scroll on repeat typing pings from the same user (not a new item)', () => {
198
+ setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 200 })
199
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
200
+ expect(scrollLeftValue).toBe(300)
201
+
202
+ // User scrolls back; a heartbeat ping for the same (already-shown) typer
203
+ // must not yank them to the end again.
204
+ scrollLeftValue = 0
205
+ controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
206
+ expect(scrollLeftValue).toBe(0)
207
+ })
208
+
209
+ test("always scrolls the local user's own new typing indicator into view, even when scrolled back to badges", () => {
210
+ // currentUserId is '7'. The user is parked at the left looking at PR/Preview
211
+ // badges (scrollLeft 0, not at end). When they themselves start typing they
212
+ // always want to see their own indicator, so stick-to-end must not suppress it.
213
+ setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 })
214
+
215
+ controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
216
+
217
+ expect(scrollLeftValue).toBe(300)
218
+ })
219
+
220
+ test('does not re-scroll on repeat self typing pings (only the first appearance forces scroll)', () => {
221
+ setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 })
222
+ controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
223
+ expect(scrollLeftValue).toBe(300)
224
+
225
+ // A heartbeat ping for the same (already-shown) self typer while scrolled
226
+ // back must not yank to the end again — only the first appearance forces it.
227
+ scrollLeftValue = 0
228
+ controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
229
+ expect(scrollLeftValue).toBe(0)
230
+ })
231
+ })
@@ -145,6 +145,10 @@ export default class extends Controller {
145
145
  onPopupOpened({ creativeId, canComment }) {
146
146
  this.creativeId = creativeId
147
147
  this.element.dataset.creativeId = creativeId || ''
148
+ // Stale topic ids from the previous creative are cleared by the popup
149
+ // controller BEFORE topics loadTopics() dispatches comments--topics:change,
150
+ // so by the time we get here, currentTopicId already reflects the new
151
+ // creative's restored topic. Do not re-clear it.
148
152
  this.formTarget.style.display = canComment ? '' : 'none'
149
153
  this.resetForm()
150
154
  if (canComment && this.shouldAutoFocusOnOpen()) {
@@ -432,6 +432,11 @@ export default class extends Controller {
432
432
  this.approveComment(target)
433
433
  return
434
434
  }
435
+ if (target.classList.contains('deny-comment-btn')) {
436
+ event.preventDefault()
437
+ this.denyComment(target)
438
+ return
439
+ }
435
440
  if (target.classList.contains('edit-comment-btn')) {
436
441
  event.preventDefault()
437
442
  this.editComment(target)
@@ -956,12 +961,22 @@ export default class extends Controller {
956
961
  }
957
962
 
958
963
  approveComment(button) {
959
- // ... (Existing logic) ...
964
+ this.decideComment(button, 'approve')
965
+ }
966
+
967
+ // Deny a Claude Channel tool-permission prompt. Mirrors approveComment but
968
+ // hits the /deny endpoint, which relays a "deny" decision to the suspended
969
+ // session.
970
+ denyComment(button) {
971
+ this.decideComment(button, 'deny')
972
+ }
973
+
974
+ decideComment(button, action) {
960
975
  if (button.disabled) return
961
976
  button.disabled = true
962
977
  const commentId = button.getAttribute('data-comment-id')
963
978
  const topicQuery = this.topicQueryString()
964
- fetch(`/creatives/${this.creativeId}/comments/${commentId}/approve${topicQuery}`, { method: 'POST', headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content } })
979
+ fetch(`/creatives/${this.creativeId}/comments/${commentId}/${action}${topicQuery}`, { method: 'POST', headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content } })
965
980
  .then(r => r.ok ? r.text() : r.json().then(j => { throw new Error(j.error) }))
966
981
  .then(html => {
967
982
  if (!html) { button.disabled = false; return; }
@@ -1123,25 +1138,28 @@ export default class extends Controller {
1123
1138
  openActionEditor(container) {
1124
1139
  if (!container) return
1125
1140
  const json = container.querySelector('.comment-action-json')
1141
+ const md = container.querySelector('.comment-action-markdown')
1126
1142
  const form = container.querySelector('.comment-action-edit-form')
1127
1143
  const btn = container.querySelector('.edit-comment-action-btn')
1128
1144
  const txt = form?.querySelector('.comment-action-edit-textarea')
1129
- if (json && form && txt) {
1130
- txt.value = json.textContent || ''
1131
- form.style.display = 'block'
1132
- if (btn) btn.style.display = 'none'
1133
- json.style.display = 'none'
1134
- txt.focus()
1135
- }
1145
+ if (!form || !txt) return
1146
+ if (json) txt.value = json.textContent || ''
1147
+ form.style.display = 'block'
1148
+ if (btn) btn.style.display = 'none'
1149
+ if (json) json.style.display = 'none'
1150
+ if (md) md.style.display = 'none'
1151
+ txt.focus()
1136
1152
  }
1137
1153
 
1138
1154
  closeActionEditor(container) {
1139
1155
  if (!container) return
1140
1156
  const json = container.querySelector('.comment-action-json')
1157
+ const md = container.querySelector('.comment-action-markdown')
1141
1158
  const form = container.querySelector('.comment-action-edit-form')
1142
1159
  const btn = container.querySelector('.edit-comment-action-btn')
1143
1160
  if (form) form.style.display = 'none'
1144
1161
  if (json) json.style.display = ''
1162
+ if (md) md.style.display = ''
1145
1163
  if (btn) btn.style.display = ''
1146
1164
  }
1147
1165