collavre 0.20.3 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/assets/stylesheets/collavre/popup.css +148 -0
  7. data/app/channels/collavre/agent_channel.rb +205 -0
  8. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  9. data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
  10. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  11. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  12. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  13. data/app/controllers/collavre/application_controller.rb +27 -0
  14. data/app/controllers/collavre/attachments_controller.rb +30 -2
  15. data/app/controllers/collavre/channels_controller.rb +23 -0
  16. data/app/controllers/collavre/comments_controller.rb +1 -1
  17. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  18. data/app/controllers/collavre/creatives_controller.rb +141 -7
  19. data/app/controllers/collavre/landing_controller.rb +8 -0
  20. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  21. data/app/controllers/collavre/tasks_controller.rb +12 -4
  22. data/app/controllers/collavre/topics_controller.rb +36 -30
  23. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  24. data/app/helpers/collavre/comments_helper.rb +7 -0
  25. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  26. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  27. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  28. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  29. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  30. data/app/javascript/controllers/comment_controller.js +15 -1
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  32. data/app/javascript/controllers/comments/form_controller.js +4 -0
  33. data/app/javascript/controllers/comments/list_controller.js +27 -9
  34. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  35. data/app/javascript/controllers/comments/presence_controller.js +137 -4
  36. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  37. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  38. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  39. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  40. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  41. data/app/javascript/controllers/index.js +4 -1
  42. data/app/javascript/controllers/landing_video_controller.js +53 -0
  43. data/app/javascript/controllers/link_creative_controller.js +451 -29
  44. data/app/javascript/creatives/tree_renderer.js +6 -0
  45. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  46. data/app/javascript/lib/api/creatives.js +13 -0
  47. data/app/javascript/lib/api/queue_manager.js +17 -5
  48. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  49. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  50. data/app/javascript/lib/lexical/color_import.js +186 -0
  51. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  52. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  53. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  54. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  55. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  56. data/app/javascript/modules/command_args_form.js +22 -4
  57. data/app/javascript/modules/command_menu.js +27 -0
  58. data/app/javascript/modules/creative_row_editor.js +227 -17
  59. data/app/javascript/modules/html_content_empty.js +12 -0
  60. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  61. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  62. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  63. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  64. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  65. data/app/mailers/collavre/application_mailer.rb +1 -1
  66. data/app/models/collavre/agent_subscription.rb +52 -0
  67. data/app/models/collavre/channel/injected_message.rb +5 -0
  68. data/app/models/collavre/channel.rb +87 -0
  69. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  70. data/app/models/collavre/comment.rb +70 -5
  71. data/app/models/collavre/creative/describable.rb +202 -3
  72. data/app/models/collavre/creative.rb +2 -0
  73. data/app/models/collavre/creative_share.rb +1 -0
  74. data/app/models/collavre/integration_setting.rb +35 -0
  75. data/app/models/collavre/preview_channel.rb +93 -0
  76. data/app/models/collavre/system_setting.rb +13 -2
  77. data/app/models/collavre/task.rb +34 -5
  78. data/app/models/collavre/topic.rb +8 -25
  79. data/app/models/collavre/user.rb +4 -0
  80. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  81. data/app/services/collavre/agent_session_abort.rb +28 -0
  82. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  83. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  84. data/app/services/collavre/ai_agent_service.rb +68 -49
  85. data/app/services/collavre/ai_client.rb +3 -3
  86. data/app/services/collavre/attachment_backfill.rb +26 -0
  87. data/app/services/collavre/channel_attacher.rb +58 -0
  88. data/app/services/collavre/comments/mcp_command.rb +31 -1
  89. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  90. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  91. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  92. data/app/services/collavre/creatives/index_query.rb +110 -8
  93. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  94. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  95. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  96. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  97. data/app/services/collavre/google_calendar_service.rb +4 -2
  98. data/app/services/collavre/markdown_converter.rb +130 -15
  99. data/app/services/collavre/markdown_importer.rb +7 -2
  100. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  101. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  102. data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
  103. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  104. data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
  105. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  106. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  107. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  108. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  109. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  110. data/app/services/collavre/topic_branch_service.rb +34 -26
  111. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  112. data/app/views/admin/shared/_tabs.html.erb +1 -0
  113. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  114. data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
  115. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  116. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  117. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  118. data/app/views/collavre/comments/_comment.html.erb +16 -2
  119. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  120. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  121. data/app/views/collavre/creatives/index.html.erb +10 -2
  122. data/app/views/collavre/landing/show.html.erb +130 -0
  123. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  124. data/app/views/layouts/collavre/landing.html.erb +33 -0
  125. data/config/locales/admin.en.yml +4 -2
  126. data/config/locales/admin.ko.yml +4 -2
  127. data/config/locales/channels.en.yml +13 -0
  128. data/config/locales/channels.ko.yml +13 -0
  129. data/config/locales/claude_channel.en.yml +16 -0
  130. data/config/locales/claude_channel.ko.yml +16 -0
  131. data/config/locales/comments.en.yml +5 -0
  132. data/config/locales/comments.ko.yml +5 -0
  133. data/config/locales/creatives.en.yml +11 -0
  134. data/config/locales/creatives.ko.yml +10 -0
  135. data/config/locales/integrations.en.yml +55 -0
  136. data/config/locales/integrations.ko.yml +55 -0
  137. data/config/locales/landing.en.yml +51 -0
  138. data/config/locales/landing.ko.yml +51 -0
  139. data/config/routes.rb +30 -0
  140. data/db/migrate/20260526000000_create_channels.rb +42 -0
  141. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  142. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  143. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  144. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  145. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  146. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  147. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  148. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  149. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  150. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  151. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  152. data/db/seeds.rb +19 -0
  153. data/lib/collavre/aws_credentials.rb +75 -0
  154. data/lib/collavre/engine.rb +50 -0
  155. data/lib/collavre/integration_settings/key_definition.rb +35 -0
  156. data/lib/collavre/integration_settings/registry.rb +60 -0
  157. data/lib/collavre/integration_settings/resolver.rb +71 -0
  158. data/lib/collavre/integration_settings.rb +46 -0
  159. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  160. data/lib/collavre/version.rb +1 -1
  161. data/lib/collavre.rb +3 -0
  162. metadata +82 -2
  163. data/app/services/collavre/openclaw_abort_service.rb +0 -45
@@ -0,0 +1,186 @@
1
+ import { $isElementNode, $isTextNode } from "lexical"
2
+
3
+ // Per text node, which format dimensions a nearer (more nested) span already
4
+ // decided this import. Child span `after` callbacks run before ancestors', so a
5
+ // nested span claims its dimensions first and ancestors must not override them.
6
+ const decidedFormats = new WeakMap()
7
+
8
+ // Replicates Lexical's (non-exported) inline-style format reading for <span>:
9
+ // font-weight / font-style / text-decoration / vertical-align. Pasted content
10
+ // (Google Docs, Word) carries bold/italic as inline styles, not <b>/<i> tags,
11
+ // so our custom span import must compose these formats with the color binding.
12
+ //
13
+ // Lexical only turns formats ON, so a nested reset (font-weight:normal) can't
14
+ // un-bold itself once an ancestor re-applies bold. We treat a dimension as
15
+ // decided by the FIRST (nearest) span that declares it at all, so the inner
16
+ // span owns it; an omitted declaration leaves it open to inherit from ancestors.
17
+ // For inherited CSS props (font-weight, font-style) a reset value also actively
18
+ // CLEARS a format an ancestor tag (<b>/<i>) already toggled. text-decoration /
19
+ // vertical-align stay add-only (they propagate additively in CSS).
20
+ function applyTextFormatFromStyle(node, domStyle) {
21
+ const fontWeight = domStyle.fontWeight
22
+ const textDecoration = domStyle.textDecoration
23
+ const fontStyle = domStyle.fontStyle
24
+ const verticalAlign = domStyle.verticalAlign
25
+ const decorations = (textDecoration || "").split(" ")
26
+
27
+ // [declared by this span?, value matches format?, format name, resettable?]
28
+ const rules = [
29
+ [Boolean(fontWeight), fontWeight === "700" || fontWeight === "bold", "bold", true],
30
+ [Boolean(textDecoration), decorations.includes("line-through"), "strikethrough", false],
31
+ [Boolean(fontStyle), fontStyle === "italic", "italic", true],
32
+ [Boolean(textDecoration), decorations.includes("underline"), "underline", false],
33
+ [Boolean(verticalAlign), verticalAlign === "sub", "subscript", false],
34
+ [Boolean(verticalAlign), verticalAlign === "super", "superscript", false]
35
+ ]
36
+
37
+ let decided = decidedFormats.get(node)
38
+ rules.forEach(([declared, shouldApply, format, resettable]) => {
39
+ if (decided && decided.has(format)) return
40
+ if (shouldApply && !node.hasFormat(format)) {
41
+ node.toggleFormat(format)
42
+ } else if (resettable && declared && !shouldApply && node.hasFormat(format)) {
43
+ node.toggleFormat(format) // explicit reset clears an ancestor's format
44
+ }
45
+ if (declared) {
46
+ if (!decided) {
47
+ decided = new Set()
48
+ decidedFormats.set(node, decided)
49
+ }
50
+ decided.add(format)
51
+ }
52
+ })
53
+ }
54
+
55
+ // The exact (property, value) pairs applyTextFormatFromStyle turns into a format
56
+ // or reset. Only these are kept OUT of the carried node style (carrying them too
57
+ // would double-represent the format). Value-aware on purpose: an unmapped value
58
+ // (font-weight:800, font-style:oblique, vertical-align:middle) must be carried
59
+ // as inline style or it's silently dropped on reopen.
60
+ const FORMAT_MAPPED_VALUES = {
61
+ "font-weight": new Set(["700", "bold", "normal"]),
62
+ "font-style": new Set(["italic", "normal"]),
63
+ "vertical-align": new Set(["sub", "super"])
64
+ }
65
+
66
+ // text-decoration is dropped wholesale: its tokens become formats that Lexical
67
+ // re-exports as its own text-decoration, so a remainder token would collide.
68
+ // white-space is Lexical's exportDOM artifact, not user intent.
69
+ const DROPPED_STYLE_PROPS = new Set(["text-decoration", "white-space"])
70
+
71
+ // Every inline declaration to carry onto the produced text nodes: all of them
72
+ // except format-mapped values and the dropped props above. Preserves color,
73
+ // background, font-size/family, text-transform, etc. without cherry-picking,
74
+ // mirroring the full-style copy the removed positional collector did.
75
+ function carriedStyleDeclarations(domStyle) {
76
+ const map = new Map()
77
+ for (let i = 0; i < domStyle.length; i++) {
78
+ const prop = domStyle.item(i)
79
+ if (DROPPED_STYLE_PROPS.has(prop)) continue
80
+ const value = domStyle.getPropertyValue(prop)
81
+ const mapped = FORMAT_MAPPED_VALUES[prop]
82
+ if (mapped && mapped.has(value)) continue
83
+ map.set(prop, value)
84
+ }
85
+ return map
86
+ }
87
+
88
+ function parseStyle(styleText) {
89
+ const map = new Map()
90
+ ;(styleText || "").split(";").forEach((decl) => {
91
+ const idx = decl.indexOf(":")
92
+ if (idx === -1) return
93
+ const key = decl.slice(0, idx).trim()
94
+ const value = decl.slice(idx + 1).trim()
95
+ if (key) map.set(key, value)
96
+ })
97
+ return map
98
+ }
99
+
100
+ function serializeStyle(map) {
101
+ return Array.from(map.entries())
102
+ .map(([key, value]) => `${key}: ${value}`)
103
+ .join("; ")
104
+ }
105
+
106
+ // Merge (not overwrite) inherited declarations: a node that already carries its
107
+ // own value (from a deeper colored span converted first) keeps it — inner wins,
108
+ // matching CSS inheritance. The parent only fills in what's missing.
109
+ function applyStyleToTextNodes(nodes, inherited, domStyle) {
110
+ nodes.forEach((node) => {
111
+ if ($isTextNode(node)) {
112
+ const merged = parseStyle(node.getStyle())
113
+ inherited.forEach((value, key) => {
114
+ if (!merged.has(key)) merged.set(key, value)
115
+ })
116
+ node.setStyle(serializeStyle(merged))
117
+ applyTextFormatFromStyle(node, domStyle)
118
+ } else if ($isElementNode(node)) {
119
+ applyStyleToTextNodes(node.getChildren(), inherited, domStyle)
120
+ }
121
+ })
122
+ }
123
+
124
+ // Custom <span> import: binds the span's inline style (color, background, and
125
+ // every other non-format/non-white-space prop) to its text nodes AT IMPORT TIME.
126
+ // The editor used to re-apply collected styles positionally to
127
+ // root.getAllTextNodes(), which drifts whenever Lexical's importer doesn't map
128
+ // DOM text nodes 1:1 (white-space:pre-wrap splits on \n/\t; whitespace-only
129
+ // nodes are dropped). Binding during conversion keeps style on the right node.
130
+ //
131
+ // We only take over when the span carries color/background — Lexical's default
132
+ // conversion already handles colorless spans, so deferring avoids behavior change.
133
+ export function colorAwareSpanImport(domNode) {
134
+ const { color, backgroundColor } = domNode.style
135
+ if (!color && !backgroundColor) {
136
+ return null
137
+ }
138
+
139
+ const inherited = carriedStyleDeclarations(domNode.style)
140
+
141
+ return {
142
+ conversion: () => ({
143
+ node: null,
144
+ after: (childLexicalNodes) => {
145
+ applyStyleToTextNodes(childLexicalNodes, inherited, domNode.style)
146
+ return childLexicalNodes
147
+ }
148
+ }),
149
+ priority: 1
150
+ }
151
+ }
152
+
153
+ export const lexicalHtmlConfig = {
154
+ import: {
155
+ span: colorAwareSpanImport
156
+ }
157
+ }
158
+
159
+ // colorAwareSpanImport only runs for <span>. Color on a non-span element (a
160
+ // colored <p>/<li>/<h1>, or a materialized data-lexical-color on a block) would
161
+ // be dropped, since Lexical's default block conversions ignore color. Before
162
+ // import (and AFTER syncLexicalStyleAttributes materializes data-lexical-*), wrap
163
+ // each colored non-span's direct text children in a span carrying its full style,
164
+ // so the span importer binds it. Nested colored elements keep their own style and
165
+ // win via the merge in applyStyleToTextNodes. Block-level props (text-align,
166
+ // margin) ride along harmlessly on the inline wrapper.
167
+ const TEXT_NODE = 3
168
+
169
+ export function normalizeColoredContainers(root) {
170
+ if (!root || typeof root.querySelectorAll !== "function") return
171
+ const ownerDocument = root.ownerDocument || document
172
+
173
+ root.querySelectorAll("[style]").forEach((element) => {
174
+ if (element.tagName === "SPAN") return
175
+ const { color, backgroundColor } = element.style
176
+ if (!color && !backgroundColor) return
177
+
178
+ Array.from(element.childNodes).forEach((child) => {
179
+ if (child.nodeType !== TEXT_NODE || !child.nodeValue) return
180
+ const span = ownerDocument.createElement("span")
181
+ span.setAttribute("style", element.getAttribute("style"))
182
+ element.replaceChild(span, child)
183
+ span.appendChild(child)
184
+ })
185
+ })
186
+ }
@@ -0,0 +1,182 @@
1
+ // Minimize the HTML that Lexical's `$generateHtmlFromNodes` produces before it
2
+ // is persisted as a creative description / comment body.
3
+ //
4
+ // Lexical emits verbose markup: every text node is wrapped in
5
+ // `<span style="white-space: pre-wrap;">`, bold/italic double-wrap as
6
+ // `<b><strong class="lexical-text-bold">`, and the whole document is nested in
7
+ // an extra `<div>` wrapper. None of that is needed once the content is rendered
8
+ // inside a `.creative-content` container.
9
+ //
10
+ // The goal (per product spec) is: a single line of text needs no markup at all,
11
+ // and styled regions keep a minimal `<span class="…">` / semantic tag. We only
12
+ // strip genuine redundancy — we never drop a tag or class that the renderer or
13
+ // the editor's re-import (`$generateNodesFromDOM`) relies on.
14
+
15
+ // Editor-only inline style declarations that carry no meaning once rendered.
16
+ // `white-space: pre-wrap` is usually redundant: plain text wraps normally and
17
+ // code blocks preserve whitespace via the kept `.lexical-code-block` rule (which
18
+ // children inherit). It is NOT redundant when the text carries significant
19
+ // whitespace (see `hasSignificantWhitespace`): the render container
20
+ // (`.creative-content`) has no `pre-wrap`, so dropping it would collapse runs of
21
+ // spaces / indentation that the user actually typed.
22
+ const REDUNDANT_STYLE_PROPS = ["white-space"]
23
+
24
+ // Editor-only attributes that never affect rendering of persisted content.
25
+ const REDUNDANT_ATTRS = ["spellcheck"]
26
+
27
+ // Same-format wrapper pairs Lexical emits as outer/inner duplicates. Either tag
28
+ // alone still conveys the format on re-import, so the attribute-less one can go.
29
+ const SAME_FORMAT_PAIRS = [
30
+ ["b", "strong"],
31
+ ["i", "em"]
32
+ ]
33
+
34
+ function hasNoAttributes(el) {
35
+ return el.attributes.length === 0
36
+ }
37
+
38
+ // Whitespace whose rendering changes once `white-space: pre-wrap` is dropped:
39
+ // a run of 2+ whitespace (collapses to one space) or a tab/newline/form-feed.
40
+ // A single leading/trailing space between inline runs renders fine without
41
+ // `pre-wrap`, so it is not significant and must not block minimization of the
42
+ // common case (e.g. the "Hello " before a bold word).
43
+ function hasSignificantWhitespace(text) {
44
+ return /\s{2,}|[\t\n\r\f]/.test(text)
45
+ }
46
+
47
+ // Block-level tags terminate an inline formatting context: whitespace never
48
+ // combines across them, so the document-order scan below must not look past one.
49
+ const BLOCK_TAGS = new Set([
50
+ "BODY", "DIV", "P", "H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL", "LI",
51
+ "BLOCKQUOTE", "PRE", "TABLE", "THEAD", "TBODY", "TR", "TD", "TH", "FIGURE", "HR"
52
+ ])
53
+
54
+ function isBlock(node) {
55
+ return node != null && node.nodeType === 1 && BLOCK_TAGS.has(node.tagName)
56
+ }
57
+
58
+ // The rendered character immediately adjacent to `el` in document order, within
59
+ // the same inline formatting context. `dir` is "previousSibling"/"nextSibling".
60
+ // Climbs out of inline wrappers (e.g. `<b><strong>`) so a space nested one level
61
+ // deep still sees its neighbour, but stops at a block edge (returns "").
62
+ function adjacentInlineChar(el, dir) {
63
+ let node = el
64
+ while (node && !node[dir]) {
65
+ node = node.parentNode
66
+ if (isBlock(node)) return ""
67
+ }
68
+ const sibling = node && node[dir]
69
+ if (!sibling) return ""
70
+ const text = sibling.textContent || ""
71
+ return dir === "previousSibling" ? text.slice(-1) : text.charAt(0)
72
+ }
73
+
74
+ // Significant whitespace can straddle an inline formatting boundary: e.g. the
75
+ // live editor serializes a bolded " bar" as `foo ` + `<b><strong> bar</strong></b>`,
76
+ // where each node holds a single edge space. Dropping `pre-wrap` from both sides
77
+ // would collapse the pair to one space. Detect each side independently so the
78
+ // kept `pre-wrap` lands on BOTH elements and the run survives unambiguously.
79
+ function hasBoundarySignificantWhitespace(el) {
80
+ const text = el.textContent || ""
81
+ if (/^\s/.test(text) && /\s/.test(adjacentInlineChar(el, "previousSibling"))) {
82
+ return true
83
+ }
84
+ if (/\s$/.test(text) && /\s/.test(adjacentInlineChar(el, "nextSibling"))) {
85
+ return true
86
+ }
87
+ return false
88
+ }
89
+
90
+ function stripRedundantStyle(el) {
91
+ if (!el.hasAttribute("style")) return
92
+ const keepWhiteSpace =
93
+ hasSignificantWhitespace(el.textContent) ||
94
+ hasBoundarySignificantWhitespace(el)
95
+ REDUNDANT_STYLE_PROPS.forEach((prop) => {
96
+ if (prop === "white-space" && keepWhiteSpace) return
97
+ el.style.removeProperty(prop)
98
+ })
99
+ if (el.style.length === 0 || !el.getAttribute("style")?.trim()) {
100
+ el.removeAttribute("style")
101
+ }
102
+ }
103
+
104
+ function isSameFormatPair(tagA, tagB) {
105
+ const a = tagA.toLowerCase()
106
+ const b = tagB.toLowerCase()
107
+ return SAME_FORMAT_PAIRS.some(
108
+ ([x, y]) => (a === x && b === y) || (a === y && b === x)
109
+ )
110
+ }
111
+
112
+ function unwrap(el) {
113
+ const parent = el.parentNode
114
+ if (!parent) return
115
+ while (el.firstChild) parent.insertBefore(el.firstChild, el)
116
+ parent.removeChild(el)
117
+ }
118
+
119
+ // Depth-first cleanup: normalize attributes, then collapse redundant wrappers.
120
+ function cleanElement(el) {
121
+ // Recurse first so inner redundancy is resolved before we inspect a parent.
122
+ Array.from(el.children).forEach(cleanElement)
123
+
124
+ if (el.tagName === "BODY") return
125
+
126
+ REDUNDANT_ATTRS.forEach((attr) => el.removeAttribute(attr))
127
+ stripRedundantStyle(el)
128
+
129
+ const tag = el.tagName.toLowerCase()
130
+
131
+ // Attribute-less <span> conveys nothing — unwrap it (plain text, link inner).
132
+ if (tag === "span" && hasNoAttributes(el)) {
133
+ unwrap(el)
134
+ return
135
+ }
136
+
137
+ // Collapse Lexical's outer/inner duplicate format wrappers (e.g. <b><strong>).
138
+ // Drop whichever element carries no attributes; the other keeps the format.
139
+ const onlyChild =
140
+ el.children.length === 1 && el.childNodes.length === 1 ? el.children[0] : null
141
+ if (onlyChild && isSameFormatPair(tag, onlyChild.tagName)) {
142
+ if (hasNoAttributes(el)) {
143
+ unwrap(el)
144
+ } else if (hasNoAttributes(onlyChild)) {
145
+ unwrap(onlyChild)
146
+ }
147
+ }
148
+ }
149
+
150
+ const MEDIA_SELECTOR = "img, action-text-attachment, figure, [data-trix-attachment]"
151
+
152
+ // A single top-level paragraph is just a line of text — emit its inline content
153
+ // without the <p> wrapper. Structural blocks (headings, lists, quotes, code,
154
+ // multiple paragraphs) are left intact.
155
+ function unwrapSingleParagraph(root) {
156
+ const blocks = Array.from(root.children)
157
+ if (blocks.length === 1 && blocks[0].tagName === "P") {
158
+ const paragraph = blocks[0]
159
+ // An empty line (just a <br> / whitespace, no media) collapses to "" so the
160
+ // stored value matches isHtmlEmpty and re-imports to a fresh paragraph.
161
+ if (!paragraph.textContent.trim() && !paragraph.querySelector(MEDIA_SELECTOR)) {
162
+ return ""
163
+ }
164
+ // A soft line break (<br>) inside the line must keep its <p> wrapper: a bare
165
+ // top-level <br> cannot be re-grouped into a paragraph on re-import and would
166
+ // break the document shape / split the line.
167
+ if (paragraph.querySelector("br")) {
168
+ return root.innerHTML
169
+ }
170
+ return paragraph.innerHTML
171
+ }
172
+ return root.innerHTML
173
+ }
174
+
175
+ // `root` is the element whose children are the serialized content blocks
176
+ // (i.e. the throwaway <div> wrapper Lexical's output was parsed into). Mutates
177
+ // `root` in place and returns the minimized HTML string.
178
+ export function minimizeContentHtml(root) {
179
+ if (!root) return ""
180
+ Array.from(root.children).forEach(cleanElement)
181
+ return unwrapSingleParagraph(root)
182
+ }
@@ -0,0 +1,96 @@
1
+ import {
2
+ $applyNodeReplacement,
3
+ DecoratorNode,
4
+ } from "lexical"
5
+
6
+ export class VideoNode extends DecoratorNode {
7
+ __src
8
+ __filename
9
+
10
+ static getType() {
11
+ return "video"
12
+ }
13
+
14
+ static clone(node) {
15
+ return new VideoNode(node.__src, node.__filename, node.__key)
16
+ }
17
+
18
+ static importJSON(serializedNode) {
19
+ const { src, filename } = serializedNode
20
+ return $createVideoNode({ src, filename })
21
+ }
22
+
23
+ exportDOM() {
24
+ const element = document.createElement("video")
25
+ element.setAttribute("src", this.__src)
26
+ element.setAttribute("controls", "")
27
+ return { element }
28
+ }
29
+
30
+ static importDOM() {
31
+ return {
32
+ video: () => ({ conversion: convertVideoElement, priority: 1 }),
33
+ }
34
+ }
35
+
36
+ constructor(src, filename, key) {
37
+ super(key)
38
+ this.__src = src
39
+ this.__filename = filename
40
+ }
41
+
42
+ exportJSON() {
43
+ return {
44
+ src: this.getSrc(),
45
+ filename: this.__filename,
46
+ type: "video",
47
+ version: 1,
48
+ }
49
+ }
50
+
51
+ createDOM(config) {
52
+ const span = document.createElement("span")
53
+ const className = config.theme?.video
54
+ if (className !== undefined) span.className = className
55
+ return span
56
+ }
57
+
58
+ updateDOM() {
59
+ return false
60
+ }
61
+
62
+ getSrc() {
63
+ return this.__src
64
+ }
65
+
66
+ getFilename() {
67
+ return this.__filename
68
+ }
69
+
70
+ decorate() {
71
+ return (
72
+ <video
73
+ src={this.__src}
74
+ controls
75
+ style={{ maxWidth: "100%", borderRadius: "4px" }}
76
+ />
77
+ )
78
+ }
79
+ }
80
+
81
+ function convertVideoElement(domNode) {
82
+ if (domNode instanceof HTMLVideoElement) {
83
+ const src = domNode.getAttribute("src")
84
+ if (!src) return null
85
+ return { node: $createVideoNode({ src, filename: null }) }
86
+ }
87
+ return null
88
+ }
89
+
90
+ export function $createVideoNode({ src, filename }) {
91
+ return $applyNodeReplacement(new VideoNode(src, filename))
92
+ }
93
+
94
+ export function $isVideoNode(node) {
95
+ return node instanceof VideoNode
96
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import CommandArgsForm from '../command_args_form'
5
+
6
+ const baseCommand = (overrides = {}) => ({
7
+ name: 'foo_tool',
8
+ label: '/foo_tool',
9
+ input_schema: [
10
+ { name: 'creative_id', type: 'string', required: false },
11
+ { name: 'topic_id', type: 'string', required: false },
12
+ { name: 'note', type: 'string', required: false }
13
+ ],
14
+ ...overrides
15
+ })
16
+
17
+ const fieldFor = (paramName) =>
18
+ document.querySelector(`[data-param-name="${paramName}"]`)
19
+
20
+ describe('CommandArgsForm context auto-fill', () => {
21
+ let container
22
+
23
+ beforeEach(() => {
24
+ container = document.createElement('div')
25
+ document.body.appendChild(container)
26
+ })
27
+
28
+ afterEach(() => {
29
+ document.body.innerHTML = ''
30
+ })
31
+
32
+ test('fills creative_id and topic_id from contextValuesFn', () => {
33
+ const form = new CommandArgsForm({
34
+ container,
35
+ contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455' })
36
+ })
37
+
38
+ form.show(baseCommand())
39
+
40
+ expect(fieldFor('creative_id').value).toBe('12508')
41
+ expect(fieldFor('topic_id').value).toBe('8455')
42
+ expect(fieldFor('note').value).toBe('')
43
+ })
44
+
45
+ test('explicit default_value beats context auto-fill', () => {
46
+ const form = new CommandArgsForm({
47
+ container,
48
+ contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455' })
49
+ })
50
+
51
+ form.show(baseCommand({
52
+ input_schema: [
53
+ { name: 'creative_id', type: 'string', required: false, default_value: '999' },
54
+ { name: 'topic_id', type: 'string', required: false }
55
+ ]
56
+ }))
57
+
58
+ expect(fieldFor('creative_id').value).toBe('999')
59
+ expect(fieldFor('topic_id').value).toBe('8455')
60
+ })
61
+
62
+ test('does not fill unrelated params even if names overlap', () => {
63
+ const form = new CommandArgsForm({
64
+ container,
65
+ contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455', note: 'ctx' })
66
+ })
67
+
68
+ form.show(baseCommand())
69
+
70
+ expect(fieldFor('note').value).toBe('')
71
+ })
72
+
73
+ test('missing context values leave fields empty', () => {
74
+ const form = new CommandArgsForm({
75
+ container,
76
+ contextValuesFn: () => ({ creative_id: null, topic_id: '' })
77
+ })
78
+
79
+ form.show(baseCommand())
80
+
81
+ expect(fieldFor('creative_id').value).toBe('')
82
+ expect(fieldFor('topic_id').value).toBe('')
83
+ })
84
+
85
+ test('contextValuesFn throwing does not break form', () => {
86
+ const form = new CommandArgsForm({
87
+ container,
88
+ contextValuesFn: () => { throw new Error('boom') }
89
+ })
90
+
91
+ expect(() => form.show(baseCommand())).not.toThrow()
92
+ expect(fieldFor('creative_id').value).toBe('')
93
+ })
94
+
95
+ test('no contextValuesFn provided behaves like before', () => {
96
+ const form = new CommandArgsForm({ container })
97
+
98
+ form.show(baseCommand())
99
+
100
+ expect(fieldFor('creative_id').value).toBe('')
101
+ expect(fieldFor('topic_id').value).toBe('')
102
+ })
103
+ })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { isHtmlEmpty } from '../html_content_empty';
5
+
6
+ describe('isHtmlEmpty', () => {
7
+ test('returns true for null/empty string', () => {
8
+ expect(isHtmlEmpty('')).toBe(true);
9
+ expect(isHtmlEmpty(null)).toBe(true);
10
+ expect(isHtmlEmpty(undefined)).toBe(true);
11
+ });
12
+
13
+ test('returns true for whitespace-only HTML', () => {
14
+ expect(isHtmlEmpty('<p> </p>')).toBe(true);
15
+ expect(isHtmlEmpty('<div><br></div>')).toBe(true);
16
+ });
17
+
18
+ test('returns false when there is text content', () => {
19
+ expect(isHtmlEmpty('<p>hello</p>')).toBe(false);
20
+ });
21
+
22
+ test('returns false for image-only HTML', () => {
23
+ expect(isHtmlEmpty('<p><img src="/blob/abc.png"></p>')).toBe(false);
24
+ });
25
+
26
+ test('returns false for action-text-attachment-only HTML', () => {
27
+ expect(isHtmlEmpty(
28
+ '<action-text-attachment sgid="abc" url="/blob" filename="file.png"></action-text-attachment>'
29
+ )).toBe(false);
30
+ });
31
+
32
+ test('returns false for figure.attachment-only HTML (trix-style)', () => {
33
+ expect(isHtmlEmpty(
34
+ '<figure class="attachment attachment--file" data-trix-attachment=\'{"sgid":"abc"}\'></figure>'
35
+ )).toBe(false);
36
+ });
37
+
38
+ test('returns false for [data-trix-attachment]-only HTML', () => {
39
+ expect(isHtmlEmpty('<div data-trix-attachment=\'{"sgid":"x"}\'></div>')).toBe(false);
40
+ });
41
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { reconcileMarkdownSource } from '../markdown_source_reconcile';
5
+
6
+ const DATA_URI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
7
+ const BLOB_PATH = '/rails/active_storage/blobs/redirect/abc123def/image.png';
8
+ const DATA_URI_2 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
9
+ const BLOB_PATH_2 = '/rails/active_storage/blobs/redirect/xyz789/two.gif';
10
+
11
+ describe('reconcileMarkdownSource', () => {
12
+ test('returns rewritten value when current still equals saved', () => {
13
+ const saved = `Hello ![pic](${DATA_URI}) world`;
14
+ const rewritten = `Hello ![pic](${BLOB_PATH}) world`;
15
+ expect(reconcileMarkdownSource(saved, rewritten, saved)).toBe(rewritten);
16
+ });
17
+
18
+ test('returns null when no rewrites happened', () => {
19
+ expect(reconcileMarkdownSource('a', 'a', 'a')).toBe(null);
20
+ });
21
+
22
+ test('returns null when saved has no data URI and current diverged from saved', () => {
23
+ expect(reconcileMarkdownSource('saved', 'rewritten', 'user typed something else')).toBe(null);
24
+ });
25
+
26
+ test('merges substitutions when user typed AFTER the data URI mid-save', () => {
27
+ const saved = `![pic](${DATA_URI})`;
28
+ const rewritten = `![pic](${BLOB_PATH})`;
29
+ const current = `![pic](${DATA_URI})\n\nNew paragraph user typed during save.`;
30
+ const expected = `![pic](${BLOB_PATH})\n\nNew paragraph user typed during save.`;
31
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
32
+ });
33
+
34
+ test('merges substitutions when user typed BEFORE the data URI mid-save', () => {
35
+ const saved = `start ![pic](${DATA_URI}) end`;
36
+ const rewritten = `start ![pic](${BLOB_PATH}) end`;
37
+ const current = `inserted at top\n\nstart ![pic](${DATA_URI}) end`;
38
+ const expected = `inserted at top\n\nstart ![pic](${BLOB_PATH}) end`;
39
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
40
+ });
41
+
42
+ test('handles multiple data URIs in a single save', () => {
43
+ const saved = `A ![1](${DATA_URI}) B ![2](${DATA_URI_2}) C`;
44
+ const rewritten = `A ![1](${BLOB_PATH}) B ![2](${BLOB_PATH_2}) C`;
45
+ const current = `A ![1](${DATA_URI}) B ![2](${DATA_URI_2}) C\n\nextra text`;
46
+ const expected = `A ![1](${BLOB_PATH}) B ![2](${BLOB_PATH_2}) C\n\nextra text`;
47
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
48
+ });
49
+
50
+ test('handles reference-style data URI definitions', () => {
51
+ const saved = `![pic][p]\n\n[p]: ${DATA_URI}`;
52
+ const rewritten = `![pic][p]\n\n[p]: ${BLOB_PATH}`;
53
+ const current = `![pic][p]\n\nuser typed more\n\n[p]: ${DATA_URI}`;
54
+ const expected = `![pic][p]\n\nuser typed more\n\n[p]: ${BLOB_PATH}`;
55
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
56
+ });
57
+
58
+ test('returns null when user removed the data URI before response', () => {
59
+ const saved = `![pic](${DATA_URI})`;
60
+ const rewritten = `![pic](${BLOB_PATH})`;
61
+ const current = 'user wiped everything';
62
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(null);
63
+ });
64
+
65
+ test('returns null on non-string inputs', () => {
66
+ expect(reconcileMarkdownSource(null, 'a', 'a')).toBe(null);
67
+ expect(reconcileMarkdownSource('a', null, 'a')).toBe(null);
68
+ expect(reconcileMarkdownSource('a', 'a', null)).toBe(null);
69
+ });
70
+ });