collavre 0.22.0 → 0.23.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,534 @@
1
+ // Inline typo correction for the chat composer (Phase 1).
2
+ //
3
+ // Wires the chat textarea to the server endpoint and paints a *volatile*
4
+ // highlight overlay: a mirror "backdrop" div sitting exactly behind the textarea
5
+ // with <mark> spans over each edit span. The textarea value stays the single
6
+ // source of truth — the backdrop is aria-hidden and never serialized, so
7
+ // markdown-canonical storage never sees a highlight.
8
+ //
9
+ // Two highlight states (shape + colour, for colour-blind safety):
10
+ // - auto-applied (confidence >= threshold): faint straight underline
11
+ // - candidate (below threshold): wavy dotted underline (needs decision)
12
+ //
13
+ // Clicking a highlight opens a creatable combobox (reusing CommonPopup for
14
+ // positioning + list + keyboard nav) pre-filled with the word currently in the
15
+ // document, so Enter = keep. Typing adds the typed word as an always-present,
16
+ // auto-selected custom option.
17
+
18
+ import CommonPopup from '../lib/common_popup'
19
+ import csrfFetch from '../lib/api/csrf_fetch'
20
+ import {
21
+ detectDevice,
22
+ shouldRun,
23
+ buildCandidateList,
24
+ anchorEdits,
25
+ applyEditAt,
26
+ shiftEditsAfter,
27
+ partitionByThreshold,
28
+ } from '../lib/typo_correction'
29
+
30
+ const DEBOUNCE_MS = 600
31
+ const PRINTABLE_KEYDOWN_TTL_MS = 50
32
+
33
+ let initialized = false
34
+
35
+ // Mirror the textarea's box metrics onto the backdrop so highlight spans land on
36
+ // the exact glyphs. Only the properties that affect text layout are copied.
37
+ const MIRRORED_STYLES = [
38
+ 'boxSizing', 'width', 'height', 'paddingTop', 'paddingRight', 'paddingBottom',
39
+ 'paddingLeft', 'borderTopWidth', 'borderRightWidth', 'borderBottomWidth',
40
+ 'borderLeftWidth', 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle',
41
+ 'lineHeight', 'letterSpacing', 'textTransform', 'wordSpacing', 'textIndent',
42
+ ]
43
+
44
+ // Escape by round-tripping through textContent: the browser never reinterprets
45
+ // it as markup, and it's the canonical DOM-text sanitizer (recognized as a
46
+ // barrier by static XSS analysis) for the values we splice into the backdrop and
47
+ // popup HTML — both of which carry user-typed / model-suggested text.
48
+ function escapeHtml(value) {
49
+ const el = document.createElement('div')
50
+ el.textContent = String(value)
51
+ return el.innerHTML
52
+ }
53
+
54
+ export class TypoCorrector {
55
+ constructor(textarea, { settings, location = 'chat', endpoint = '/typo_corrections', getVoiceActive = () => false, labels = {} } = {}) {
56
+ this.textarea = textarea
57
+ this.settings = settings
58
+ this.location = location
59
+ this.endpoint = endpoint
60
+ this.getVoiceActive = getVoiceActive
61
+ this.labels = {
62
+ keep: labels.keep || 'keep',
63
+ custom: labels.custom || 'custom',
64
+ inputLabel: labels.inputLabel || 'correction',
65
+ }
66
+
67
+ this.edits = [] // anchored edits currently painted: {start,end,original,suggestion,confidence,state}
68
+ this.lastPrintableKeydownAt = null
69
+ this.lastInputAt = null
70
+ this.debounceTimer = null
71
+ this.requestSeq = 0
72
+
73
+ this._buildBackdrop()
74
+ this._bind()
75
+ }
76
+
77
+ _buildBackdrop() {
78
+ const ta = this.textarea
79
+ const wrap = document.createElement('div')
80
+ wrap.className = 'typo-input-wrap'
81
+ ta.parentNode.insertBefore(wrap, ta)
82
+
83
+ this.backdrop = document.createElement('div')
84
+ this.backdrop.className = 'typo-backdrop'
85
+ this.backdrop.setAttribute('aria-hidden', 'true')
86
+ this.highlightLayer = document.createElement('div')
87
+ this.highlightLayer.className = 'typo-highlights'
88
+ this.backdrop.appendChild(this.highlightLayer)
89
+
90
+ wrap.appendChild(this.backdrop)
91
+ wrap.appendChild(ta)
92
+ this._syncBackdropStyle()
93
+ }
94
+
95
+ _syncBackdropStyle() {
96
+ const computed = getComputedStyle(this.textarea)
97
+ MIRRORED_STYLES.forEach((prop) => {
98
+ this.highlightLayer.style[prop] = computed[prop]
99
+ })
100
+ // The textarea's own border is transparent-mirrored via padding offsets.
101
+ this.highlightLayer.style.borderColor = 'transparent'
102
+ this.highlightLayer.style.borderStyle = 'solid'
103
+ }
104
+
105
+ _bind() {
106
+ this._onKeydown = (event) => {
107
+ // A printable keydown immediately preceding the input marks a physical key.
108
+ if (event.key && event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
109
+ this.lastPrintableKeydownAt = performance.now()
110
+ }
111
+ }
112
+ this._onInput = () => {
113
+ // Stamp the input time *now*, while the keydown→input relationship is still
114
+ // fresh. Classifying at detect time (after the debounce) would read a
115
+ // ~600ms gap and misread every physical keypress as a soft keyboard,
116
+ // defeating the default-off physical-keyboard gate.
117
+ this.lastInputAt = performance.now()
118
+ this._syncScroll()
119
+ this._repaint() // keep existing highlights aligned with edited text
120
+ // Our own auto-apply dispatches a synthetic `input` so other listeners
121
+ // (auto-resize, draft save, send-button state) update. We must NOT let it
122
+ // re-trigger detection: the corrected text has no typos, so the follow-up
123
+ // would return [] and wipe the just-applied underline/undo affordance
124
+ // before the user can click it. Real keystrokes still schedule detection.
125
+ if (this.suppressNextDetect) {
126
+ this.suppressNextDetect = false
127
+ return
128
+ }
129
+ this._scheduleDetect()
130
+ }
131
+ this._onScroll = () => this._syncScroll()
132
+ // form.reset() (after a comment sends, or on cancel) empties the textarea
133
+ // WITHOUT firing an `input` event, so our repaint never runs and stale marks
134
+ // would linger over the now-empty composer — still clickable, applying stale
135
+ // offsets to the empty value. The `reset` event fires before the control is
136
+ // cleared, so drop the edits now and repaint next frame once it's empty.
137
+ this._onReset = () => {
138
+ clearTimeout(this.debounceTimer)
139
+ this.requestSeq++ // invalidate any in-flight detection response
140
+ this.edits = []
141
+ requestAnimationFrame(() => this._repaint())
142
+ }
143
+
144
+ this.form = this.textarea.form
145
+ this.textarea.addEventListener('keydown', this._onKeydown)
146
+ this.textarea.addEventListener('input', this._onInput)
147
+ this.textarea.addEventListener('scroll', this._onScroll)
148
+ this.form?.addEventListener('reset', this._onReset)
149
+ }
150
+
151
+ destroy() {
152
+ clearTimeout(this.debounceTimer)
153
+ this.textarea.removeEventListener('keydown', this._onKeydown)
154
+ this.textarea.removeEventListener('input', this._onInput)
155
+ this.textarea.removeEventListener('scroll', this._onScroll)
156
+ this.form?.removeEventListener('reset', this._onReset)
157
+ // Drop the combobox popup: it lives on document.body, so without this a Turbo
158
+ // page-cache snapshot serializes an orphan popup (stale options, no live
159
+ // instance) and the next corrector appends a duplicate. hide() also detaches
160
+ // CommonPopup's document-level outside-click listeners.
161
+ this.popup?.hide()
162
+ this.popupEl?.remove()
163
+ this.popupEl = null
164
+ this.popupInput = null
165
+ this.popup = null
166
+ // Unwrap the textarea and drop the injected backdrop so a Turbo page-cache
167
+ // snapshot doesn't serialize stale overlay DOM (it would duplicate on the
168
+ // next build) or the bind marker (a restored snapshot keeps typoBound=true
169
+ // but loses the JS listeners, so re-init is skipped and correction stays
170
+ // dead until a full reload).
171
+ const wrap = this.backdrop?.parentNode
172
+ if (wrap && wrap.parentNode) {
173
+ wrap.parentNode.insertBefore(this.textarea, wrap)
174
+ wrap.remove()
175
+ }
176
+ delete this.textarea.dataset.typoBound
177
+ }
178
+
179
+ _syncScroll() {
180
+ this.backdrop.scrollTop = this.textarea.scrollTop
181
+ this.backdrop.scrollLeft = this.textarea.scrollLeft
182
+ }
183
+
184
+ _currentDevice() {
185
+ return detectDevice({
186
+ voiceActive: !!this.getVoiceActive(),
187
+ lastPrintableKeydownAt: this.lastPrintableKeydownAt,
188
+ inputAt: this.lastInputAt,
189
+ })
190
+ }
191
+
192
+ _scheduleDetect() {
193
+ clearTimeout(this.debounceTimer)
194
+ this.debounceTimer = setTimeout(() => this._detect(), DEBOUNCE_MS)
195
+ }
196
+
197
+ async _detect() {
198
+ const device = this._currentDevice();
199
+ // Reset the printable-keydown marker so a stale value can't mis-classify the
200
+ // next standalone input (e.g. soft-keyboard insert after a physical keypress).
201
+ if (this.lastPrintableKeydownAt != null && performance.now() - this.lastPrintableKeydownAt > PRINTABLE_KEYDOWN_TTL_MS) {
202
+ this.lastPrintableKeydownAt = null
203
+ }
204
+
205
+ if (!shouldRun(this.settings, { device, location: this.location })) return
206
+ const text = this.textarea.value
207
+ if (!text.trim()) { this._clear(); return }
208
+
209
+ const seq = ++this.requestSeq
210
+ let payload
211
+ try {
212
+ const res = await csrfFetch(this.endpoint, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
215
+ body: JSON.stringify({ text, device, location: this.location }),
216
+ })
217
+ if (!res.ok) return
218
+ payload = await res.json()
219
+ } catch {
220
+ return
221
+ }
222
+ if (seq !== this.requestSeq) return // a newer keystroke superseded this one
223
+ // The text may have changed while the request was in flight; only act if the
224
+ // edits still anchor to the *current* textarea value.
225
+ if (this.textarea.value !== text) return
226
+
227
+ this._applyResult(payload)
228
+ }
229
+
230
+ _applyResult({ edits = [], threshold = 80 } = {}) {
231
+ const original = this.textarea.value
232
+ const anchored = anchorEdits(original, edits)
233
+ const { autoApplied, candidates } = partitionByThreshold(anchored, threshold)
234
+
235
+ // This round's edits. `apply` rewrites the word; `keep` leaves the text as-is
236
+ // (a low-confidence candidate awaiting a decision).
237
+ const incoming = [
238
+ ...autoApplied.map((e) => ({ ...e, kind: 'apply' })),
239
+ ...candidates.map((e) => ({ ...e, kind: 'keep', state: 'candidate', currentValue: e.original })),
240
+ ]
241
+
242
+ // Carry forward earlier corrections that are still anchored in the live text
243
+ // and don't collide with this round. The server is stateless and never
244
+ // re-reports an already-corrected word, so without this every detection round
245
+ // would wipe the prior correction's mark and leave only the most recent one
246
+ // clickable. Already-applied survivors sit in the text unchanged, so they're
247
+ // re-emitted in place (kind 'keep'), not re-applied.
248
+ const survivors = this.edits
249
+ .filter((p) => original.slice(p.start, p.end) === p.currentValue)
250
+ .filter((p) => !incoming.some((n) => n.start < p.end && p.start < n.end))
251
+ .map((p) => ({ ...p, kind: 'keep' }))
252
+
253
+ // Walk all edits left-to-right in one pass, rebuilding the text while
254
+ // tracking a running delta. This assigns each tracked edit an *exact* final
255
+ // span, rather than re-searching the corrected word by value afterwards —
256
+ // that search would bind to an earlier identical word (e.g. correcting the
257
+ // second "teh" in "the teh" lands the highlight on the first "the", so undo
258
+ // would rewrite the wrong word).
259
+ const events = [...incoming, ...survivors].sort((a, b) => a.start - b.start)
260
+
261
+ const originalCaret = this.textarea.selectionStart
262
+ let out = ''
263
+ let cursor = 0
264
+ let delta = 0
265
+ let caret = originalCaret
266
+ const tracked = []
267
+ for (const e of events) {
268
+ if (e.start < cursor) continue // overlapping span; skip defensively
269
+ out += original.slice(cursor, e.start)
270
+ const finalStart = e.start + delta
271
+ if (e.kind === 'apply') {
272
+ out += e.suggestion
273
+ const d = e.suggestion.length - (e.end - e.start)
274
+ if (e.start < originalCaret) caret += d
275
+ delta += d
276
+ tracked.push({
277
+ original: e.original, suggestion: e.suggestion, confidence: e.confidence,
278
+ state: 'applied', currentValue: e.suggestion,
279
+ start: finalStart, end: finalStart + e.suggestion.length,
280
+ })
281
+ } else {
282
+ // 'keep': a new candidate or a carried-forward survivor — text unchanged,
283
+ // so re-emit the current slice and preserve its existing state.
284
+ const slice = original.slice(e.start, e.end)
285
+ out += slice
286
+ tracked.push({
287
+ original: e.original, suggestion: e.suggestion, confidence: e.confidence,
288
+ state: e.state, currentValue: e.currentValue,
289
+ start: finalStart, end: finalStart + slice.length,
290
+ })
291
+ }
292
+ cursor = e.end
293
+ }
294
+ out += original.slice(cursor)
295
+
296
+ this.edits = tracked
297
+ if (out !== original) {
298
+ this.textarea.value = out
299
+ this.textarea.setSelectionRange(caret, caret)
300
+ // Our own mutation: notify other listeners but don't re-run detection (it
301
+ // would return [] on the now-clean text and wipe these fresh highlights).
302
+ this.suppressNextDetect = true
303
+ this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
304
+ }
305
+ this._repaint()
306
+ }
307
+
308
+ _clear() {
309
+ this.edits = []
310
+ this._repaint()
311
+ }
312
+
313
+ // Paint the backdrop: plain text with <mark> spans over each edit. Marks carry
314
+ // the state class so CSS renders the two underline styles.
315
+ _repaint() {
316
+ // Drop edits that no longer match the live text (user kept typing).
317
+ const text = this.textarea.value
318
+ this.edits = this.edits.filter((e) => text.slice(e.start, e.end) === e.currentValue)
319
+
320
+ if (this.edits.length === 0) {
321
+ this.highlightLayer.textContent = text
322
+ return
323
+ }
324
+ const sorted = [...this.edits].sort((a, b) => a.start - b.start)
325
+ let html = ''
326
+ let cursor = 0
327
+ sorted.forEach((edit, i) => {
328
+ if (edit.start < cursor) return // overlapping; skip
329
+ html += escapeHtml(text.slice(cursor, edit.start))
330
+ const cls = edit.state === 'applied' ? 'typo-mark typo-mark-applied' : 'typo-mark typo-mark-candidate'
331
+ html += `<mark class="${cls}" data-typo-index="${i}">${escapeHtml(text.slice(edit.start, edit.end))}</mark>`
332
+ cursor = edit.end
333
+ })
334
+ html += escapeHtml(text.slice(cursor))
335
+ this.highlightLayer.innerHTML = html
336
+ this._wireMarks(sorted)
337
+ this._syncScroll()
338
+ }
339
+
340
+ // <mark> has pointer-events:auto (the rest of the backdrop is none), so clicks
341
+ // land on a highlighted word and open the combobox there.
342
+ _wireMarks(sorted) {
343
+ this.highlightLayer.querySelectorAll('.typo-mark').forEach((mark) => {
344
+ const idx = Number(mark.dataset.typoIndex)
345
+ const edit = sorted[idx]
346
+ if (!edit) return
347
+ mark.addEventListener('mousedown', (event) => {
348
+ event.preventDefault()
349
+ this._openPopup(edit, mark)
350
+ })
351
+ })
352
+ }
353
+
354
+ _ensurePopup() {
355
+ if (this.popupEl) return
356
+ const el = document.createElement('div')
357
+ el.className = 'typo-popup common-popup'
358
+ el.style.display = 'none'
359
+ el.innerHTML = `
360
+ <input type="text" class="typo-popup-input" />
361
+ <ul class="typo-popup-list" data-popup-list></ul>`
362
+ document.body.appendChild(el)
363
+ this.popupEl = el
364
+ this.popupInput = el.querySelector('.typo-popup-input')
365
+ // Localized assistive label (set via setAttribute, not innerHTML, so the
366
+ // value is never parsed as markup).
367
+ this.popupInput.setAttribute('aria-label', this.labels.inputLabel)
368
+
369
+ this.popup = new CommonPopup(el, {
370
+ listElement: el.querySelector('.typo-popup-list'),
371
+ // item.label is user-typed / model-suggested text; escapeHtml round-trips
372
+ // it through textContent so it can never be reinterpreted as markup.
373
+ renderItem: (item) => {
374
+ const tag = item.role === 'original' ? ` <span class="typo-popup-role">(${escapeHtml(this.labels.keep)})</span>`
375
+ : item.role === 'custom' ? ` <span class="typo-popup-role">(${escapeHtml(this.labels.custom)})</span>` : ''
376
+ return `<span class="typo-popup-value">${escapeHtml(item.label)}</span>${tag}`
377
+ },
378
+ onSelect: (item) => this._chooseValue(item.value),
379
+ onClose: () => { this._activeEdit = null },
380
+ })
381
+
382
+ // Typing builds a creatable option and keeps input ↔ list bound.
383
+ this.popupInput.addEventListener('input', () => this._refreshPopupItems(this.popupInput.value))
384
+ this.popupInput.addEventListener('keydown', (event) => {
385
+ if (event.key === 'Enter') {
386
+ event.preventDefault()
387
+ this._chooseValue(this.popupInput.value)
388
+ return
389
+ }
390
+ if (this.popup.handleKey(event)) {
391
+ // Sync the input to the active list item (arrow navigation).
392
+ const active = this.popup.items[this.popup.activeIndex]
393
+ if (active) this.popupInput.value = active.value
394
+ }
395
+ })
396
+ }
397
+
398
+ _refreshPopupItems(typed) {
399
+ const edit = this._activeEdit
400
+ if (!edit) return
401
+ let items = buildCandidateList({
402
+ currentValue: edit.currentValue,
403
+ originalWord: edit.original,
404
+ suggestions: [{ suggestion: edit.suggestion, confidence: edit.confidence }],
405
+ })
406
+ const trimmed = (typed || '').trim()
407
+ if (trimmed && !items.some((i) => i.value === trimmed)) {
408
+ // Creatable: the typed word is always present and auto-selected.
409
+ items = [{ value: trimmed, label: trimmed, role: 'custom', isCurrent: false }, ...items]
410
+ }
411
+ this.popup.setItems(items)
412
+ // Auto-select the typed custom entry, else the current document value.
413
+ const selectIndex = trimmed
414
+ ? Math.max(0, items.findIndex((i) => i.value === trimmed))
415
+ : Math.max(0, items.findIndex((i) => i.isCurrent))
416
+ this.popup.setActiveIndex(selectIndex)
417
+ }
418
+
419
+ _openPopup(edit, anchorEl) {
420
+ this._ensurePopup()
421
+ this._activeEdit = edit
422
+ this.popupInput.value = edit.currentValue
423
+ this._refreshPopupItems('')
424
+ this.popup.showAt(anchorEl.getBoundingClientRect())
425
+ // Desktop: focus + select-all so a keystroke immediately replaces the word.
426
+ // Mobile keyboard is left to an explicit tap on the input (no auto-focus) —
427
+ // chip taps are the primary path.
428
+ // showAt() keeps the popup visibility:hidden until its own rAF (so it can
429
+ // position off-screen first); focus()/select() are no-ops while hidden, so
430
+ // defer them into a rAF that runs after showAt's (FIFO, registered later).
431
+ if (!this._isCoarsePointer()) {
432
+ requestAnimationFrame(() => {
433
+ this.popupInput.focus()
434
+ this.popupInput.select()
435
+ })
436
+ }
437
+ }
438
+
439
+ _isCoarsePointer() {
440
+ return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches
441
+ }
442
+
443
+ _chooseValue(value) {
444
+ const edit = this._activeEdit
445
+ if (edit == null || value == null) { this.popup?.hide(); return }
446
+ const replacement = String(value)
447
+
448
+ if (replacement !== edit.currentValue) {
449
+ const { text, delta } = applyEditAt(this.textarea.value, {
450
+ start: edit.start, end: edit.end, replacement,
451
+ })
452
+ let caret = this.textarea.selectionStart
453
+ if (edit.start < caret) caret += delta
454
+ this.textarea.value = text
455
+ this.textarea.setSelectionRange(caret, caret)
456
+ // Shift later edits, then drop this one (it's now user-confirmed).
457
+ this.edits = shiftEditsAfter(
458
+ this.edits.filter((e) => e !== edit),
459
+ edit.start,
460
+ delta,
461
+ )
462
+ // Our own mutation: don't re-detect (it would overwrite the remaining
463
+ // candidate highlights we just shifted to keep them aligned).
464
+ this.suppressNextDetect = true
465
+ this.textarea.dispatchEvent(new Event('input', { bubbles: true }))
466
+ } else {
467
+ // "Keep" — user resolved it; remove the highlight.
468
+ this.edits = this.edits.filter((e) => e !== edit)
469
+ }
470
+
471
+ this.popup?.hide()
472
+ this._repaint()
473
+ this.textarea.focus()
474
+ }
475
+ }
476
+
477
+ // Module-scope init, mirroring mention_menu.js. Reads settings exposed by the
478
+ // server on #comments-popup and only attaches when the master switch is on.
479
+ function readSettings(root) {
480
+ if (!root) return null
481
+ const d = root.dataset
482
+ if (d.typoEnabled == null) return null
483
+ return {
484
+ enabled: d.typoEnabled === 'true',
485
+ threshold: parseInt(d.typoThreshold, 10) || 80,
486
+ onVoice: d.typoOnVoice === 'true',
487
+ onSoftKeyboard: d.typoOnSoftKeyboard === 'true',
488
+ onPhysicalKeyboard: d.typoOnPhysicalKeyboard === 'true',
489
+ inChat: d.typoInChat === 'true',
490
+ inEditor: d.typoInEditor === 'true',
491
+ }
492
+ }
493
+
494
+ let activeInstance = null
495
+
496
+ export function initTypoCorrector() {
497
+ const textarea = document.querySelector('#new-comment-form textarea')
498
+ const root = document.getElementById('comments-popup')
499
+ const settings = readSettings(root)
500
+ if (!textarea || !settings || !settings.enabled) return null
501
+ if (textarea.dataset.typoBound === 'true') return null
502
+ textarea.dataset.typoBound = 'true'
503
+
504
+ const voiceButton = document.getElementById('voice-comments-btn')
505
+ activeInstance = new TypoCorrector(textarea, {
506
+ settings,
507
+ location: 'chat',
508
+ // Use the mounted engine path (e.g. /collavre/typo_corrections) so requests
509
+ // don't 404 when Collavre is mounted at a subpath instead of root.
510
+ endpoint: root.dataset.typoEndpoint || '/typo_corrections',
511
+ getVoiceActive: () => voiceButton?.dataset.voiceState === 'listening',
512
+ labels: {
513
+ keep: root.dataset.typoKeepLabel,
514
+ custom: root.dataset.typoCustomLabel,
515
+ inputLabel: root.dataset.typoInputLabel,
516
+ },
517
+ })
518
+ return activeInstance
519
+ }
520
+
521
+ export function teardownTypoCorrector() {
522
+ if (!activeInstance) return
523
+ activeInstance.destroy()
524
+ activeInstance = null
525
+ }
526
+
527
+ if (!initialized) {
528
+ initialized = true
529
+ document.addEventListener('turbo:load', initTypoCorrector)
530
+ // Turbo caches a DOM snapshot on navigation but not the JS listeners. Tear the
531
+ // instance down (clearing the bind marker + injected DOM) before the snapshot
532
+ // is taken so a restored page re-initializes cleanly instead of skipping init.
533
+ document.addEventListener('turbo:before-cache', teardownTypoCorrector)
534
+ }
@@ -101,11 +101,14 @@ module Collavre
101
101
 
102
102
  # Reserve resources before starting work
103
103
  tracker = Orchestration::ResourceTracker.for(agent)
104
- # Claude Channel tasks live past this job (MCP reply happens later), so
105
- # reserve under the stable task.id that's the key reply / cancel /
106
- # stuck-recovery will use to release the slot.
104
+ # Reserve under the stable task.id, not the per-run job_id: a task can
105
+ # outlive the job that reserved its slot (Claude Channel tasks wait on an
106
+ # MCP reply, pending_approval tasks pause on ApprovalPendingError), and
107
+ # every external release path — TasksController#cancel, stuck recovery,
108
+ # agent teardown — releases! by task.id. A job_id key would leave those
109
+ # releases unmatched, leaking the slot until the cache expiry.
107
110
  is_claude_channel_agent = agent.claude_channel_agent?
108
- resource_id = is_claude_channel_agent ? task.id : (job_id || task.id)
111
+ resource_id = task.id
109
112
  tracker.reserve!(resource_id)
110
113
  should_release = true
111
114
 
@@ -77,9 +77,13 @@ module Collavre
77
77
  # Store comment IDs to delete before creating the new one (all originals including /compress command)
78
78
  comment_ids_to_delete = all_comments.pluck(:id)
79
79
 
80
- # Create the summary comment in the same topic
80
+ # Create the summary comment in the same topic.
81
+ # Author it as the AI agent (not the human who ran /compress) so the
82
+ # comment is recognized as AI-generated content: this makes ai_user? true,
83
+ # which is what gates the "Review" button in the comment view. The summary
84
+ # body is AI output, so the agent is the correct author.
81
85
  summary_comment = creative.comments.create!(
82
- user: user,
86
+ user: agent,
83
87
  topic_id: topic_id,
84
88
  content: summary_content,
85
89
  skip_dispatch: true # system-generated summary, not user input
@@ -3,6 +3,9 @@ module Collavre
3
3
  module Broadcastable
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ # The desktop and mobile inbox badge DOM ids kept in sync in real time.
7
+ INBOX_BADGE_TARGETS = %w[desktop-inbox-badge mobile-inbox-badge].freeze
8
+
6
9
  included do
7
10
  after_create_commit :broadcast_create
8
11
  after_update_commit :broadcast_update
@@ -112,21 +115,57 @@ module Collavre
112
115
  def broadcast_inbox_badge(inbox_creative, owner, count: nil)
113
116
  return unless inbox_creative && owner
114
117
 
115
- count ||= Collavre::Inbox::BadgeComponent.new(user: owner, creative: inbox_creative).count
118
+ count ||= inbox_badge_count(inbox_creative, owner)
116
119
 
117
- %w[desktop-inbox-badge mobile-inbox-badge].each do |target_id|
120
+ INBOX_BADGE_TARGETS.each do |target_id|
118
121
  Turbo::StreamsChannel.broadcast_replace_to(
119
122
  [ "inbox", owner ],
120
123
  target: target_id,
121
124
  partial: "inbox/badge_component/count",
122
- locals: {
123
- count: count,
124
- badge_id: target_id,
125
- show_zero: false
126
- }
125
+ locals: inbox_badge_locals(count, target_id)
127
126
  )
128
127
  end
129
128
  end
129
+
130
+ # Render the same inbox badge replacements as a Turbo Stream string so a
131
+ # channel can transmit them straight to its own confirmed subscriber
132
+ # (see InboxBadgeChannel), instead of re-broadcasting to the sibling
133
+ # ["inbox", user] stream and risking a reconnect race. Returns nil when
134
+ # there is nothing to render.
135
+ def inbox_badge_turbo_stream(inbox_creative, owner, count: nil)
136
+ return unless inbox_creative && owner
137
+
138
+ count ||= inbox_badge_count(inbox_creative, owner)
139
+
140
+ INBOX_BADGE_TARGETS.map do |target_id|
141
+ Turbo::StreamsChannel.turbo_stream_action_tag(
142
+ :replace,
143
+ target: target_id,
144
+ template: ApplicationController.render(
145
+ partial: "inbox/badge_component/count",
146
+ formats: [ :html ],
147
+ locals: inbox_badge_locals(count, target_id)
148
+ )
149
+ )
150
+ end.join.html_safe
151
+ end
152
+
153
+ private
154
+
155
+ # Inbox badge count when no caller-supplied count is available (e.g. the
156
+ # reconnect snapshot in InboxBadgeChannel). Mirrors broadcast_badge's
157
+ # suppression: a user actively viewing the inbox (present in
158
+ # CommentPresenceStore) sees 0, so a reconnect can't repaint unread items
159
+ # over the suppressed badge.
160
+ def inbox_badge_count(inbox_creative, owner)
161
+ return 0 if CommentPresenceStore.list(inbox_creative.id).include?(owner.id)
162
+
163
+ Collavre::Inbox::BadgeComponent.new(user: owner, creative: inbox_creative).count
164
+ end
165
+
166
+ def inbox_badge_locals(count, target_id)
167
+ { count: count, badge_id: target_id, show_zero: false }
168
+ end
130
169
  end
131
170
 
132
171
  private