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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/assets/stylesheets/collavre/actiontext.css +251 -90
- data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
- data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
- data/app/assets/stylesheets/collavre/creatives.css +11 -2
- data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
- data/app/assets/stylesheets/collavre/tables.css +91 -0
- data/app/channels/collavre/inbox_badge_channel.rb +30 -0
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
- data/app/controllers/collavre/creatives_controller.rb +16 -5
- data/app/controllers/collavre/tasks_controller.rb +13 -4
- data/app/controllers/collavre/topics_controller.rb +49 -1
- data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
- data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
- data/app/helpers/collavre/application_helper.rb +1 -0
- data/app/javascript/collavre.js +2 -0
- data/app/javascript/components/ImageResizer.jsx +9 -3
- data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
- data/app/javascript/components/creative_tree_row.js +20 -3
- data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
- data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
- data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
- data/app/javascript/controllers/comment_controller.js +5 -4
- data/app/javascript/controllers/comment_version_controller.js +2 -1
- data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
- data/app/javascript/controllers/comments/form_controller.js +21 -5
- data/app/javascript/controllers/comments/list_controller.js +18 -17
- data/app/javascript/controllers/comments/presence_controller.js +2 -1
- data/app/javascript/controllers/comments/topics_controller.js +14 -8
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
- data/app/javascript/controllers/creatives/import_controller.js +2 -1
- data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
- data/app/javascript/controllers/creatives/tree_controller.js +142 -1
- data/app/javascript/controllers/image_lightbox_controller.js +2 -1
- data/app/javascript/controllers/inbox_badge_controller.js +33 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/share_modal_controller.js +4 -3
- data/app/javascript/controllers/topic_search_controller.js +2 -1
- data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
- data/app/javascript/creatives/topic_move_members_popup.js +156 -0
- data/app/javascript/creatives/tree_renderer.js +11 -0
- data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
- data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
- data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
- data/app/javascript/lib/api/api_error.js +108 -0
- data/app/javascript/lib/api/queue_manager.js +38 -4
- data/app/javascript/lib/common_popup.js +18 -5
- data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
- data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
- data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
- data/app/javascript/lib/editor/code_languages.js +173 -0
- data/app/javascript/lib/editor/code_token_theme.js +41 -0
- data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
- data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
- data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
- data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
- data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
- data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
- data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
- data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
- data/app/javascript/lib/lexical/selection_boundary.js +58 -0
- data/app/javascript/lib/lexical/table_transformer.js +182 -0
- data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
- data/app/javascript/lib/turbo_confirm.js +46 -0
- data/app/javascript/lib/typo_correction.js +146 -0
- data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
- data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
- data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
- data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
- data/app/javascript/lib/utils/confirm_dialog.js +10 -0
- data/app/javascript/lib/utils/dialog.js +300 -0
- data/app/javascript/lib/utils/markdown.js +154 -67
- data/app/javascript/lib/utils/sanitize_description.js +31 -0
- data/app/javascript/lib/utils/table_download.js +15 -0
- data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
- data/app/javascript/modules/creative_row_editor.js +110 -70
- data/app/javascript/modules/export_to_markdown.js +2 -1
- data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
- data/app/javascript/modules/slide_view.js +11 -2
- data/app/javascript/modules/typo_corrector.js +534 -0
- data/app/jobs/collavre/ai_agent_job.rb +7 -4
- data/app/jobs/collavre/compress_job.rb +6 -2
- data/app/models/collavre/comment/broadcastable.rb +46 -7
- data/app/models/collavre/comment/notifiable.rb +14 -4
- data/app/models/collavre/comment.rb +79 -31
- data/app/models/collavre/creative/describable.rb +89 -10
- data/app/models/collavre/task.rb +15 -0
- data/app/models/collavre/user.rb +57 -1
- data/app/services/collavre/ai_client.rb +28 -10
- data/app/services/collavre/auto_theme_generator.rb +1 -1
- data/app/services/collavre/creatives/index_query.rb +85 -16
- data/app/services/collavre/creatives/tree_builder.rb +2 -1
- data/app/services/collavre/gemini_parent_recommender.rb +1 -1
- data/app/services/collavre/inbox_reply_service.rb +5 -0
- data/app/services/collavre/markdown_converter.rb +13 -3
- data/app/services/collavre/mobile/event_summarizer.rb +40 -0
- data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
- data/app/services/collavre/orchestration/arbiter.rb +16 -0
- data/app/services/collavre/orchestration/matcher.rb +79 -4
- data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
- data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
- data/app/services/collavre/tools/creative_batch_service.rb +3 -2
- data/app/services/collavre/tools/creative_create_service.rb +8 -8
- data/app/services/collavre/tools/creative_update_service.rb +23 -8
- data/app/services/collavre/typo_corrector.rb +188 -0
- data/app/views/collavre/comments/_comment.html.erb +5 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
- data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
- data/app/views/collavre/creatives/index.html.erb +14 -1
- data/app/views/collavre/creatives/slide_view.html.erb +1 -1
- data/app/views/collavre/users/show.html.erb +3 -0
- data/app/views/collavre/users/typo_correction.html.erb +50 -0
- data/app/views/layouts/collavre/slide.html.erb +1 -0
- data/config/locales/comments.en.yml +15 -0
- data/config/locales/comments.ko.yml +15 -0
- data/config/locales/integrations.en.yml +1 -1
- data/config/locales/integrations.ko.yml +1 -1
- data/config/locales/mobile.en.yml +16 -0
- data/config/locales/mobile.ko.yml +16 -0
- data/config/locales/orchestration.en.yml +1 -0
- data/config/locales/orchestration.ko.yml +1 -0
- data/config/locales/users.en.yml +15 -0
- data/config/locales/users.ko.yml +15 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
- data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
- data/db/seeds.rb +51 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/generators/collavre/install/install_generator.rb +1 -0
- metadata +55 -2
- 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
|
-
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
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 =
|
|
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:
|
|
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 ||=
|
|
118
|
+
count ||= inbox_badge_count(inbox_creative, owner)
|
|
116
119
|
|
|
117
|
-
|
|
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
|