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,182 @@
1
+ import {
2
+ $createTableCellNode,
3
+ $createTableNode,
4
+ $createTableRowNode,
5
+ $isTableCellNode,
6
+ $isTableNode,
7
+ $isTableRowNode,
8
+ TableCellHeaderStates
9
+ } from "@lexical/table"
10
+ import { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown"
11
+ import { $isParagraphNode, $isTextNode } from "lexical"
12
+
13
+ // GFM pipe-table support for the markdown-canonical editor. Ported from the
14
+ // Lexical playground's MarkdownTransformers TABLE ElementTransformer, adapted to
15
+ // .js and to a lazily-injected cell transformer list. Unlike the color/decorator
16
+ // transformers (export-only), this one is bidirectional: it exports Lexical
17
+ // tables to GFM and reconstructs them when markdown is parsed back.
18
+
19
+ const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/
20
+ // -+ (not -*) is required per cell: a GFM divider must have at least one dash,
21
+ // and the mandatory dash run anchors the surrounding :? colons so they can't both
22
+ // compete for the same character — which would cause exponential backtracking.
23
+ const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-+:? ?)+\|\s?$/
24
+
25
+ // Each table cell is (de)serialized with the full transformer list (which
26
+ // includes this TABLE transformer). markdown_serialize.js injects the list once
27
+ // at load via setCellTransformers to avoid a circular import. Cells never nest
28
+ // tables, so the self-reference stays bounded.
29
+ let cellTransformers = []
30
+ export function setCellTransformers(list) {
31
+ cellTransformers = list
32
+ }
33
+
34
+ function getTableColumnsSize(table) {
35
+ const row = table.getFirstChild()
36
+ return $isTableRowNode(row) ? row.getChildrenSize() : 0
37
+ }
38
+
39
+ function $createTableCell(textContent) {
40
+ // Backslashes are governed by CommonMark and round-trip through the per-cell
41
+ // $convertFromMarkdownString below; we must NOT pre-decode them here. An earlier
42
+ // ".replace(/\\n/g, \"\\n\")" mis-fired on literal "a\nb" (backslash + n typed by
43
+ // the user, e.g. a regex or Windows path), corrupting it into a real newline
44
+ // that breaks the single-line row (CodeQL alert #42). Cells are single-line, so
45
+ // no newline decoding is needed at all.
46
+ const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS)
47
+ $convertFromMarkdownString(textContent, cellTransformers, cell)
48
+ return cell
49
+ }
50
+
51
+ // Split a row's inner text on cell boundaries and reverse the export escaping in
52
+ // one left-to-right pass. Export escapes (in order) backslash -> "\\" then pipe
53
+ // -> "\|", so here every backslash starts a 2-char escape: it consumes the next
54
+ // char ("\\" -> "\", "\|" -> a literal in-cell pipe). Only a *bare* pipe is a
55
+ // column boundary. The result still carries CommonMark's own backslash escapes,
56
+ // which $convertFromMarkdownString decodes per cell. Undoing the backslash escape
57
+ // here (not just "\|") is what keeps the escaping complete and unambiguous
58
+ // (CodeQL js/incomplete-sanitization #43). Each cell is trimmed because GFM treats
59
+ // the spaces around "| cell |" as insignificant padding (export re-adds them).
60
+ function splitRowCells(inner) {
61
+ const cells = []
62
+ let current = ""
63
+ for (let i = 0; i < inner.length; i++) {
64
+ if (inner[i] === "\\" && i + 1 < inner.length) {
65
+ current += inner[i + 1] // "\\" -> "\", "\|" -> "|" (any other "\x" -> "x")
66
+ i++
67
+ } else if (inner[i] === "|") {
68
+ cells.push(current.trim())
69
+ current = ""
70
+ } else {
71
+ current += inner[i]
72
+ }
73
+ }
74
+ cells.push(current.trim())
75
+ return cells
76
+ }
77
+
78
+ function mapToTableCells(textContent) {
79
+ const match = textContent.match(TABLE_ROW_REG_EXP)
80
+ if (!match || !match[1]) return null
81
+ return splitRowCells(match[1]).map((text) => $createTableCell(text))
82
+ }
83
+
84
+ export const TABLE = {
85
+ dependencies: [],
86
+ export: (node) => {
87
+ if (!$isTableNode(node)) return null
88
+ const output = []
89
+ for (const row of node.getChildren()) {
90
+ if (!$isTableRowNode(row)) continue
91
+ const rowOutput = []
92
+ let isHeaderRow = false
93
+ for (const cell of row.getChildren()) {
94
+ if ($isTableCellNode(cell)) {
95
+ rowOutput.push(
96
+ $convertToMarkdownString(cellTransformers, cell)
97
+ // A GFM row is one line, so collapse any real newline to a space
98
+ // (multi-line cells aren't representable in GFM anyway). NOT "\\n":
99
+ // that re-imported as a literal backslash-n and then mis-decoded
100
+ // back to a newline, corrupting genuine "a\nb" text (CodeQL #42).
101
+ .replace(/\n+/g, " ")
102
+ // Escape the escape char FIRST, then pipes, so the escaping is
103
+ // complete and unambiguous (CodeQL js/incomplete-sanitization #43).
104
+ // splitRowCells reverses both ("\\"->"\", "\|"->"|") on import,
105
+ // restoring exactly what CommonMark emitted before re-decoding it.
106
+ .replace(/\\/g, "\\\\")
107
+ .replace(/\|/g, "\\|")
108
+ .trim()
109
+ )
110
+ if (cell.__headerState === TableCellHeaderStates.ROW) {
111
+ isHeaderRow = true
112
+ }
113
+ }
114
+ }
115
+ output.push(`| ${rowOutput.join(" | ")} |`)
116
+ if (isHeaderRow) {
117
+ output.push(`| ${rowOutput.map(() => "---").join(" | ")} |`)
118
+ }
119
+ }
120
+ return output.join("\n")
121
+ },
122
+ regExp: TABLE_ROW_REG_EXP,
123
+ replace: (parentNode, _children, match) => {
124
+ // A divider row ("| --- | --- |") promotes the previous table's last row to
125
+ // a header row, then removes itself.
126
+ if (TABLE_ROW_DIVIDER_REG_EXP.test(match[0])) {
127
+ const table = parentNode.getPreviousSibling()
128
+ if (!table || !$isTableNode(table)) return
129
+ const rows = table.getChildren()
130
+ const lastRow = rows[rows.length - 1]
131
+ if (!lastRow || !$isTableRowNode(lastRow)) return
132
+ lastRow.getChildren().forEach((cell) => {
133
+ if (!$isTableCellNode(cell)) return
134
+ cell.setHeaderStyles(TableCellHeaderStates.ROW, TableCellHeaderStates.ROW)
135
+ })
136
+ parentNode.remove()
137
+ return
138
+ }
139
+
140
+ const matchCells = mapToTableCells(match[0])
141
+ if (matchCells == null) return
142
+
143
+ // Walk backwards over preceding single-text paragraphs that are also table
144
+ // rows, accumulating them so a multi-line table is parsed as one node.
145
+ const rows = [matchCells]
146
+ let sibling = parentNode.getPreviousSibling()
147
+ let maxCells = matchCells.length
148
+ while (sibling) {
149
+ if (!$isParagraphNode(sibling) || sibling.getChildrenSize() !== 1) break
150
+ const firstChild = sibling.getFirstChild()
151
+ if (!$isTextNode(firstChild)) break
152
+ const cells = mapToTableCells(firstChild.getTextContent())
153
+ if (cells == null) break
154
+ maxCells = Math.max(maxCells, cells.length)
155
+ rows.unshift(cells)
156
+ const previousSibling = sibling.getPreviousSibling()
157
+ sibling.remove()
158
+ sibling = previousSibling
159
+ }
160
+
161
+ const table = $createTableNode()
162
+ for (const cells of rows) {
163
+ const tableRow = $createTableRowNode()
164
+ table.append(tableRow)
165
+ for (let i = 0; i < maxCells; i++) {
166
+ tableRow.append(i < cells.length ? cells[i] : $createTableCell(""))
167
+ }
168
+ }
169
+
170
+ // Merge with an adjacent table of the same width (the divider row already
171
+ // removed itself, leaving the header table directly before this body row).
172
+ const previousSibling = parentNode.getPreviousSibling()
173
+ if ($isTableNode(previousSibling) && getTableColumnsSize(previousSibling) === maxCells) {
174
+ previousSibling.append(...table.getChildren())
175
+ parentNode.remove()
176
+ } else {
177
+ parentNode.replace(table)
178
+ }
179
+ table.selectEnd()
180
+ },
181
+ type: "element"
182
+ }
@@ -0,0 +1,29 @@
1
+ import { $createParagraphNode, $isDecoratorNode, RootNode } from "lexical"
2
+
3
+ // A top-level DecoratorNode (image / video / attachment) renders as
4
+ // non-editable content. When one is the last child of the root there is no
5
+ // text position after it, so the caret can't land below it and the whole
6
+ // document becomes uneditable — the reported "an image steals focus / can't
7
+ // edit" bug, worst when the image is the only block. Guarantee the root always
8
+ // ends in a paragraph so there is always an editable landing spot after a
9
+ // trailing decorator.
10
+ //
11
+ // Must run inside an editor.update()/transform.
12
+ export function ensureTrailingParagraph(root) {
13
+ const last = root.getLastChild()
14
+ if (last !== null && $isDecoratorNode(last)) {
15
+ last.insertAfter($createParagraphNode())
16
+ return true
17
+ }
18
+ return false
19
+ }
20
+
21
+ // Keep the guarantee while editing too: deleting the text below an image, or
22
+ // pasting an image as the last block, would otherwise re-trap the caret.
23
+ // Appending leaves the root dirty, so the transform re-runs once more and then
24
+ // no-ops (last child is now the paragraph) — it terminates.
25
+ export function registerTrailingParagraph(editor) {
26
+ return editor.registerNodeTransform(RootNode, (root) => {
27
+ ensureTrailingParagraph(root)
28
+ })
29
+ }
@@ -0,0 +1,46 @@
1
+ // turbo_confirm — route Turbo's declarative `data-turbo-confirm` prompts through
2
+ // the in-app confirm dialog instead of the native window.confirm().
3
+ //
4
+ // Why: Rails/Turbo forms gate destructive submits with `data-turbo-confirm`.
5
+ // Turbo's default confirm method calls window.confirm(), which in the packaged
6
+ // desktop app (Tauri WKWebView) returns false with no UI — so the submit
7
+ // silently no-ops (e.g. token revoke, user delete, share removal) and looks
8
+ // like the button is broken. Converting hand-written confirm() calls does not
9
+ // cover these, because Turbo intercepts them before any of our code runs. The
10
+ // fix is a single global override of Turbo's confirm method.
11
+ //
12
+ // Returns a Promise<boolean>; Turbo awaits it before submitting (Turbo 8
13
+ // `config.forms.confirm`, with a fallback to the deprecated setConfirmMethod).
14
+
15
+ import { Turbo } from "@hotwired/turbo-rails"
16
+ import { confirmDialog } from "./utils/dialog"
17
+
18
+ // Turbo invokes this with (message, formElement, submitter). Style the confirm
19
+ // button as destructive when the submit deletes (the common case for
20
+ // data-turbo-confirm), so it matches the danger styling used elsewhere.
21
+ function turboConfirm(message, formElement, submitter) {
22
+ const method = (
23
+ submitter?.getAttribute?.("data-turbo-method") ||
24
+ formElement?.getAttribute?.("data-turbo-method") ||
25
+ formElement?.querySelector?.("input[name='_method']")?.value ||
26
+ formElement?.getAttribute?.("method") ||
27
+ ""
28
+ ).toLowerCase()
29
+ const danger = method === "delete" || method === "destroy"
30
+ return confirmDialog(message, { danger })
31
+ }
32
+
33
+ // Install on both the bundled Turbo and any pre-existing global window.Turbo,
34
+ // mirroring turbo_stream_actions.js — the two can be distinct instances.
35
+ function installTurboConfirm(turbo) {
36
+ if (!turbo) return
37
+ if (turbo.config && turbo.config.forms) {
38
+ turbo.config.forms.confirm = turboConfirm
39
+ } else if (typeof turbo.setConfirmMethod === "function") {
40
+ // Turbo < 8 fallback.
41
+ turbo.setConfirmMethod(turboConfirm)
42
+ }
43
+ }
44
+
45
+ installTurboConfirm(Turbo)
46
+ if (window.Turbo && window.Turbo !== Turbo) installTurboConfirm(window.Turbo)
@@ -0,0 +1,146 @@
1
+ // Pure logic for client-side typo correction (Phase 1: chat input).
2
+ //
3
+ // This module holds the framework-free core so it can be unit-tested without a
4
+ // DOM: device classification, the 2D gating decision (mirrors the server's
5
+ // `Collavre::User#typo_correction_active_for?`), candidate-list construction for
6
+ // the selection popup, and edit anchoring/application with re-anchoring of the
7
+ // remaining edits after one is applied.
8
+ //
9
+ // The overlay highlight is a *volatile* presentation layer — the textarea value
10
+ // is the only canonical text. Nothing here ever serializes a highlight.
11
+
12
+ export const DEVICE_VOICE = 'voice'
13
+ export const DEVICE_SOFT_KEYBOARD = 'soft_keyboard'
14
+ export const DEVICE_PHYSICAL_KEYBOARD = 'physical_keyboard'
15
+
16
+ // Classify the active typing device from the signals we can observe.
17
+ //
18
+ // - Voice dictation is explicit (the composer exposes its recognition state).
19
+ // - A physical keyboard fires a `keydown` for a printable key immediately
20
+ // before the resulting `input` event. Soft keyboards and IME composition
21
+ // insert text without that printable keydown (key is 'Unidentified'/'Process'
22
+ // or no keydown at all), which is the standard heuristic we rely on.
23
+ export function detectDevice({ voiceActive = false, lastPrintableKeydownAt = null, inputAt = null } = {}) {
24
+ if (voiceActive) return DEVICE_VOICE
25
+ if (lastPrintableKeydownAt == null || inputAt == null) return DEVICE_SOFT_KEYBOARD
26
+ // A printable keydown that landed within the same input tick means a physical
27
+ // key produced this character.
28
+ const delta = inputAt - lastPrintableKeydownAt
29
+ if (delta >= 0 && delta <= 50) return DEVICE_PHYSICAL_KEYBOARD
30
+ return DEVICE_SOFT_KEYBOARD
31
+ }
32
+
33
+ // Mirror of the server-side 2D gate so the client avoids a pointless round-trip
34
+ // when the feature is off for this device/location. The server re-checks; this
35
+ // is an optimization, not the source of truth.
36
+ export function shouldRun(settings, { device, location }) {
37
+ if (!settings || settings.enabled !== true) return false
38
+
39
+ const deviceOn =
40
+ device === DEVICE_VOICE ? settings.onVoice
41
+ : device === DEVICE_SOFT_KEYBOARD ? settings.onSoftKeyboard
42
+ : device === DEVICE_PHYSICAL_KEYBOARD ? settings.onPhysicalKeyboard
43
+ : false
44
+ if (deviceOn !== true) return false
45
+
46
+ const locationOn =
47
+ location === 'chat' ? settings.inChat
48
+ : location === 'editor' ? settings.inEditor
49
+ : false
50
+ return locationOn === true
51
+ }
52
+
53
+ // Build the popup's option list for one edit: the word currently sitting in the
54
+ // document first (so Enter = keep), then the suggestions in confidence order,
55
+ // de-duplicated. `currentValue` is whatever text is actually in the textarea at
56
+ // the edit's span right now (original word, or the corrected word if it was
57
+ // auto-applied). `originalWord` is the pre-correction text, surfaced as the undo
58
+ // option when the current value is already the suggestion.
59
+ export function buildCandidateList({ currentValue, originalWord, suggestions = [] }) {
60
+ const seen = new Set()
61
+ const list = []
62
+ const push = (value, role) => {
63
+ if (value == null || value === '') return
64
+ if (seen.has(value)) return
65
+ seen.add(value)
66
+ list.push({ value, label: value, isCurrent: value === currentValue, role })
67
+ }
68
+
69
+ // Current document value is always the pre-selected, pre-filled option.
70
+ push(currentValue, currentValue === originalWord ? 'original' : 'applied')
71
+ // If we're showing a corrected word, the original is the undo option.
72
+ push(originalWord, 'original')
73
+ // Suggestions, highest confidence first (assumed already sorted; sort defensively).
74
+ ;[...suggestions]
75
+ .sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
76
+ .forEach((s) => push(s.suggestion, 'suggestion'))
77
+
78
+ return list
79
+ }
80
+
81
+ // Attach character offsets to each edit. Prefer the server-supplied `offset`
82
+ // (the first occurrence the server verified is *outside* a protected span) so a
83
+ // duplicate word sitting inside code/URL/markup is never anchored — anchoring by
84
+ // our own search would bind the first textual occurrence, which can be the
85
+ // protected one the server already excluded. We only trust `offset` if the
86
+ // substring still matches there (text is identical to what the server saw).
87
+ // Without an offset we fall back to a left-to-right search, advancing the cursor
88
+ // so repeated words bind to distinct occurrences. Edits whose original can no
89
+ // longer be found are dropped — the caller re-runs the detector.
90
+ export function anchorEdits(text, edits = []) {
91
+ const anchored = []
92
+ let cursor = 0
93
+ for (const edit of edits) {
94
+ if (!edit || !edit.original) continue
95
+ let idx
96
+ if (
97
+ Number.isInteger(edit.offset)
98
+ && text.slice(edit.offset, edit.offset + edit.original.length) === edit.original
99
+ ) {
100
+ idx = edit.offset
101
+ } else {
102
+ idx = text.indexOf(edit.original, cursor)
103
+ }
104
+ if (idx === -1) continue
105
+ anchored.push({ ...edit, start: idx, end: idx + edit.original.length })
106
+ cursor = Math.max(cursor, idx + edit.original.length)
107
+ }
108
+ return anchored
109
+ }
110
+
111
+ // Replace the span [start, end) of `text` with `replacement` and return the new
112
+ // text plus the delta, so remaining edits can be shifted. We re-anchor by delta
113
+ // rather than re-searching to avoid mis-binding when the same word repeats.
114
+ export function applyEditAt(text, { start, end, replacement }) {
115
+ const before = text.slice(0, start)
116
+ const after = text.slice(end)
117
+ const newText = before + replacement + after
118
+ const delta = replacement.length - (end - start)
119
+ return { text: newText, delta }
120
+ }
121
+
122
+ // Shift every edit positioned at/after `fromOffset` by `delta`. Edits before the
123
+ // applied span are untouched; the applied edit itself should be removed by the
124
+ // caller before calling this.
125
+ export function shiftEditsAfter(edits, fromOffset, delta) {
126
+ return edits.map((edit) => {
127
+ if (edit.start >= fromOffset) {
128
+ return { ...edit, start: edit.start + delta, end: edit.end + delta }
129
+ }
130
+ return edit
131
+ })
132
+ }
133
+
134
+ // Partition edits the server returned into auto-applied (confidence at/above the
135
+ // user's threshold) and candidates (below it). Threshold is 0–100 to match the
136
+ // profile setting; confidence is 0–1 from the model.
137
+ export function partitionByThreshold(edits = [], threshold = 80) {
138
+ const cut = threshold / 100
139
+ const autoApplied = []
140
+ const candidates = []
141
+ for (const edit of edits) {
142
+ if ((edit.confidence ?? 0) >= cut) autoApplied.push(edit)
143
+ else candidates.push(edit)
144
+ }
145
+ return { autoApplied, candidates }
146
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { confirmDialog } from '../confirm_dialog'
5
+
6
+ const modal = () => document.querySelector('.modal-dialog')
7
+ const overlay = () => document.querySelector('.modal-dialog-overlay')
8
+ const message = () => document.querySelector('.confirm-dialog-message')
9
+
10
+ describe('confirmDialog', () => {
11
+ afterEach(() => {
12
+ document.body.innerHTML = ''
13
+ document.documentElement.lang = ''
14
+ })
15
+
16
+ test('renders a modal with the message and OK/Cancel buttons', () => {
17
+ confirmDialog('Delete this?')
18
+ expect(modal()).not.toBeNull()
19
+ expect(overlay()).not.toBeNull()
20
+ expect(message().textContent).toBe('Delete this?')
21
+ expect(document.querySelector('.modal-dialog-btn-secondary')).not.toBeNull()
22
+ expect(document.querySelector('.modal-dialog-btn-primary')).not.toBeNull()
23
+ })
24
+
25
+ test('resolves true when confirm is clicked, then removes the modal', async () => {
26
+ const p = confirmDialog('ok?')
27
+ document.querySelector('.modal-dialog-btn-primary').click()
28
+ await expect(p).resolves.toBe(true)
29
+ expect(modal()).toBeNull()
30
+ expect(overlay()).toBeNull()
31
+ })
32
+
33
+ test('resolves false when cancel is clicked', async () => {
34
+ const p = confirmDialog('x')
35
+ document.querySelector('.modal-dialog-btn-secondary').click()
36
+ await expect(p).resolves.toBe(false)
37
+ })
38
+
39
+ test('resolves false when the backdrop is clicked', async () => {
40
+ const p = confirmDialog('x')
41
+ overlay().click()
42
+ await expect(p).resolves.toBe(false)
43
+ })
44
+
45
+ test('Enter resolves true, Escape resolves false', async () => {
46
+ const p1 = confirmDialog('x')
47
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
48
+ await expect(p1).resolves.toBe(true)
49
+
50
+ const p2 = confirmDialog('x')
51
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
52
+ await expect(p2).resolves.toBe(false)
53
+ })
54
+
55
+ test('danger option styles the confirm button as destructive', () => {
56
+ confirmDialog('Delete?', { danger: true })
57
+ expect(document.querySelector('.modal-dialog-btn-danger')).not.toBeNull()
58
+ expect(document.querySelector('.modal-dialog-btn-primary')).toBeNull()
59
+ })
60
+
61
+ test('renders the message as text, never HTML (no injection)', () => {
62
+ confirmDialog('<img src=x onerror=alert(1)>')
63
+ expect(message().textContent).toBe('<img src=x onerror=alert(1)>')
64
+ expect(message().querySelector('img')).toBeNull()
65
+ })
66
+
67
+ test('localizes the buttons from <html lang>', () => {
68
+ document.documentElement.lang = 'ko'
69
+ confirmDialog('삭제?')
70
+ expect(document.querySelector('.modal-dialog-btn-primary').textContent).toBe('확인')
71
+ expect(document.querySelector('.modal-dialog-btn-secondary').textContent).toBe('취소')
72
+ })
73
+
74
+ test('only the topmost stacked dialog reacts to keys', async () => {
75
+ const p1 = confirmDialog('first')
76
+ const p2 = confirmDialog('second')
77
+ expect(document.querySelectorAll('.modal-dialog').length).toBe(2)
78
+
79
+ // Enter resolves only the topmost (second) dialog.
80
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
81
+ await expect(p2).resolves.toBe(true)
82
+ expect(document.querySelectorAll('.modal-dialog').length).toBe(1)
83
+
84
+ // The first dialog is now topmost and reacts.
85
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
86
+ await expect(p1).resolves.toBe(false)
87
+ })
88
+ })
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { alertDialog, confirmDialog, promptDialog } from '../dialog'
5
+
6
+ const modal = () => document.querySelector('.modal-dialog')
7
+ const overlay = () => document.querySelector('.modal-dialog-overlay')
8
+ const message = () => document.querySelector('.confirm-dialog-message')
9
+ const input = () => document.querySelector('.modal-dialog-input')
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = ''
13
+ document.documentElement.lang = ''
14
+ })
15
+
16
+ describe('alertDialog', () => {
17
+ test('renders a single OK button (no Cancel) with the message', () => {
18
+ alertDialog('Failed to save')
19
+ expect(modal()).not.toBeNull()
20
+ expect(message().textContent).toBe('Failed to save')
21
+ expect(document.querySelector('.modal-dialog-btn-primary')).not.toBeNull()
22
+ expect(document.querySelector('.modal-dialog-btn-secondary')).toBeNull()
23
+ })
24
+
25
+ test('resolves (undefined) and removes the modal when OK is clicked', async () => {
26
+ const p = alertDialog('done')
27
+ document.querySelector('.modal-dialog-btn-primary').click()
28
+ await expect(p).resolves.toBeUndefined()
29
+ expect(modal()).toBeNull()
30
+ expect(overlay()).toBeNull()
31
+ })
32
+
33
+ test('Enter and Escape both dismiss', async () => {
34
+ const p1 = alertDialog('x')
35
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
36
+ await expect(p1).resolves.toBeUndefined()
37
+
38
+ const p2 = alertDialog('x')
39
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
40
+ await expect(p2).resolves.toBeUndefined()
41
+ })
42
+
43
+ test('renders the message as text, never HTML (no injection)', () => {
44
+ alertDialog('<img src=x onerror=alert(1)>')
45
+ expect(message().textContent).toBe('<img src=x onerror=alert(1)>')
46
+ expect(message().querySelector('img')).toBeNull()
47
+ })
48
+ })
49
+
50
+ describe('promptDialog', () => {
51
+ test('renders a text input seeded with defaultValue plus OK/Cancel', () => {
52
+ promptDialog('New name?', { defaultValue: 'old' })
53
+ expect(message().textContent).toBe('New name?')
54
+ expect(input()).not.toBeNull()
55
+ expect(input().value).toBe('old')
56
+ expect(document.querySelector('.modal-dialog-btn-secondary')).not.toBeNull()
57
+ })
58
+
59
+ test('resolves the entered value when confirmed', async () => {
60
+ const p = promptDialog('Name?')
61
+ input().value = 'typed'
62
+ document.querySelector('.modal-dialog-btn-primary').click()
63
+ await expect(p).resolves.toBe('typed')
64
+ })
65
+
66
+ test('resolves null when cancelled (parity with native prompt)', async () => {
67
+ const p = promptDialog('Name?', { defaultValue: 'x' })
68
+ document.querySelector('.modal-dialog-btn-secondary').click()
69
+ await expect(p).resolves.toBeNull()
70
+ })
71
+
72
+ test('resolves null when dismissed via Escape', async () => {
73
+ const p = promptDialog('Name?', { defaultValue: 'x' })
74
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
75
+ await expect(p).resolves.toBeNull()
76
+ })
77
+
78
+ test('Enter submits the current input value', async () => {
79
+ const p = promptDialog('Name?')
80
+ input().value = 'viaEnter'
81
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }))
82
+ await expect(p).resolves.toBe('viaEnter')
83
+ })
84
+ })
85
+
86
+ describe('confirmDialog (re-exported from dialog)', () => {
87
+ test('still resolves true/false', async () => {
88
+ const p = confirmDialog('ok?')
89
+ document.querySelector('.modal-dialog-btn-primary').click()
90
+ await expect(p).resolves.toBe(true)
91
+ })
92
+ })