collavre 0.22.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. 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
@@ -19,7 +19,7 @@ class PermissionCacheJob < ApplicationJob
19
19
  when :rebuild_for_creative
20
20
  rebuild_for_creative(args[:creative_id])
21
21
  when :propagate_share
22
- propagate_share(args[:creative_share_id])
22
+ propagate_share(args[:creative_share_id], purge_stale: args.fetch(:purge_stale, false))
23
23
  when :remove_share
24
24
  remove_share(args[:creative_share_id], args[:creative_id], args[:user_id])
25
25
  when :rebuild_user_cache_for_subtree
@@ -49,7 +49,18 @@ class PermissionCacheJob < ApplicationJob
49
49
  Creatives::PermissionCacheBuilder.rebuild_for_creative(creative)
50
50
  end
51
51
 
52
- def propagate_share(creative_share_id)
52
+ def propagate_share(creative_share_id, purge_stale: false)
53
+ # A relocated/reassigned share leaves stale cache rows keyed to it at the old
54
+ # location. When asked, delete them here — in this same job, immediately
55
+ # before re-propagating — never as a separate job. Both the purge and the
56
+ # propagate key on source_share_id, and the authz queue runs two threads, so
57
+ # a standalone purge could execute AFTER propagate and delete the rows it
58
+ # just wrote, leaving the share with no cache until an unrelated rebuild.
59
+ # Folding the purge in guarantees purge-before-propagate ordering while
60
+ # keeping the work off the commit path (the async, prolonged-grant window the
61
+ # CTO accepted is preserved). Purge runs before the find_by so it still fires
62
+ # even if the share was destroyed after enqueue.
63
+ purge_share_cache(creative_share_id) if purge_stale
53
64
  share = CreativeShare.find_by(id: creative_share_id)
54
65
  return unless share
55
66
  Creatives::PermissionCacheBuilder.propagate_share(share)
@@ -62,6 +73,13 @@ class PermissionCacheJob < ApplicationJob
62
73
  Creatives::PermissionCacheBuilder.rebuild_from_ancestors_for_user(creative, user_id)
63
74
  end
64
75
 
76
+ # Purge the stale cache rows a relocated/reassigned share left behind. Only
77
+ # ever called from propagate_share (never enqueued standalone) so the delete
78
+ # cannot race the re-propagation on a separate authz-queue thread.
79
+ def purge_share_cache(creative_share_id)
80
+ CreativeSharesCache.where(source_share_id: creative_share_id).delete_all
81
+ end
82
+
65
83
  def rebuild_user_cache_for_subtree(creative_id, user_id)
66
84
  creative = Creative.find_by(id: creative_id)
67
85
  return unless creative
@@ -86,17 +86,34 @@ module Collavre
86
86
  # These are NOT valid task results — the agent never actually worked.
87
87
  MAX_INFRA_RETRIES = 3
88
88
 
89
- INFRASTRUCTURE_ERROR_PATTERNS = [
90
- /OpenClaw Error/i,
89
+ # Generic, vendor-neutral infrastructure-error signatures. Vendor engines
90
+ # register their own provider-specific signatures (e.g. an OpenClaw gateway
91
+ # error banner) via register_infrastructure_error_pattern, so core names no
92
+ # vendor here.
93
+ BUILTIN_INFRASTRUCTURE_ERROR_PATTERNS = [
91
94
  /(?:request|connection|server)\s+timed?\s*out/i,
92
95
  /connection\s*(refused|reset|closed)/i,
93
96
  /internal\s*server\s*error/i,
94
97
  /\b50[234]\b.*(?:error|gateway|unavailable)/i
95
98
  ].freeze
96
99
 
100
+ class << self
101
+ def registered_infrastructure_error_patterns
102
+ @registered_infrastructure_error_patterns ||= []
103
+ end
104
+
105
+ def register_infrastructure_error_pattern(pattern)
106
+ registered_infrastructure_error_patterns << pattern unless registered_infrastructure_error_patterns.include?(pattern)
107
+ end
108
+
109
+ def infrastructure_error_patterns
110
+ BUILTIN_INFRASTRUCTURE_ERROR_PATTERNS + registered_infrastructure_error_patterns
111
+ end
112
+ end
113
+
97
114
  def infrastructure_error?(comment)
98
115
  content = comment.content.to_s
99
- INFRASTRUCTURE_ERROR_PATTERNS.any? { |pattern| content.match?(pattern) }
116
+ self.class.infrastructure_error_patterns.any? { |pattern| content.match?(pattern) }
100
117
  end
101
118
 
102
119
  # Retry without consuming an iteration — the agent never actually worked.
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Re-derives a creative's MCP tool definitions from its description HTML.
5
+ # Runs off the request/save path (enqueued from Creative after_commit) so the
6
+ # Nokogiri parsing and tool upserts never block a synchronous save.
7
+ class UpdateMcpToolsJob < ApplicationJob
8
+ queue_as :default
9
+
10
+ def perform(creative_id)
11
+ creative = Creative.find_by(id: creative_id)
12
+ return unless creative
13
+
14
+ McpService.new.update_from_creative(creative)
15
+ end
16
+ end
17
+ end