collavre 0.21.0 โ†’ 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +169 -64
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/popup.css +148 -0
  9. data/app/assets/stylesheets/collavre/tables.css +91 -0
  10. data/app/channels/collavre/agent_channel.rb +205 -0
  11. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  12. data/app/controllers/collavre/admin/integrations_controller.rb +16 -5
  13. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  14. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  15. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  16. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  17. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  18. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  19. data/app/controllers/collavre/attachments_controller.rb +30 -2
  20. data/app/controllers/collavre/comments_controller.rb +1 -1
  21. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  22. data/app/controllers/collavre/creatives_controller.rb +107 -6
  23. data/app/controllers/collavre/tasks_controller.rb +21 -4
  24. data/app/controllers/collavre/topics_controller.rb +64 -1
  25. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  26. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  27. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  28. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  29. data/app/helpers/collavre/application_helper.rb +1 -0
  30. data/app/javascript/collavre.js +2 -0
  31. data/app/javascript/components/ImageResizer.jsx +9 -3
  32. data/app/javascript/components/InlineLexicalEditor.jsx +196 -96
  33. data/app/javascript/components/creative_tree_row.js +20 -3
  34. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  35. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  36. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  37. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  38. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  39. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  40. data/app/javascript/controllers/comment_controller.js +11 -5
  41. data/app/javascript/controllers/comment_version_controller.js +2 -1
  42. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  43. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +111 -2
  44. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  45. data/app/javascript/controllers/comments/form_controller.js +21 -5
  46. data/app/javascript/controllers/comments/list_controller.js +35 -19
  47. data/app/javascript/controllers/comments/presence_controller.js +58 -6
  48. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  49. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  50. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  51. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  52. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  53. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  54. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  55. data/app/javascript/controllers/index.js +4 -1
  56. data/app/javascript/controllers/link_creative_controller.js +451 -29
  57. data/app/javascript/controllers/share_modal_controller.js +4 -3
  58. data/app/javascript/controllers/topic_search_controller.js +2 -1
  59. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  60. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  61. data/app/javascript/creatives/tree_renderer.js +11 -0
  62. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  63. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  64. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  65. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  66. data/app/javascript/lib/api/api_error.js +108 -0
  67. data/app/javascript/lib/api/creatives.js +13 -0
  68. data/app/javascript/lib/api/queue_manager.js +38 -4
  69. data/app/javascript/lib/common_popup.js +18 -5
  70. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  71. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  72. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  73. data/app/javascript/lib/editor/code_languages.js +173 -0
  74. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  75. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  76. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  77. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  78. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  79. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +278 -0
  80. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  81. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  82. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  83. data/app/javascript/lib/lexical/color_import.js +186 -0
  84. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  85. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  86. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  87. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  88. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  89. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  90. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  91. data/app/javascript/lib/turbo_confirm.js +46 -0
  92. data/app/javascript/lib/typo_correction.js +146 -0
  93. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  94. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  95. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  96. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  97. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  98. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  99. data/app/javascript/lib/utils/dialog.js +300 -0
  100. data/app/javascript/lib/utils/markdown.js +154 -67
  101. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  102. data/app/javascript/lib/utils/table_download.js +15 -0
  103. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  104. data/app/javascript/modules/creative_row_editor.js +110 -70
  105. data/app/javascript/modules/export_to_markdown.js +2 -1
  106. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  107. data/app/javascript/modules/slide_view.js +11 -2
  108. data/app/javascript/modules/typo_corrector.js +534 -0
  109. data/app/jobs/collavre/ai_agent_job.rb +92 -3
  110. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  111. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  112. data/app/jobs/collavre/compress_job.rb +6 -2
  113. data/app/models/collavre/agent_subscription.rb +52 -0
  114. data/app/models/collavre/comment/broadcastable.rb +46 -7
  115. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  116. data/app/models/collavre/comment/notifiable.rb +14 -4
  117. data/app/models/collavre/comment.rb +124 -11
  118. data/app/models/collavre/creative/describable.rb +220 -4
  119. data/app/models/collavre/creative_share.rb +1 -0
  120. data/app/models/collavre/task.rb +49 -5
  121. data/app/models/collavre/topic.rb +5 -0
  122. data/app/models/collavre/user.rb +61 -1
  123. data/app/services/collavre/agent_session_abort.rb +28 -0
  124. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  125. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  126. data/app/services/collavre/ai_agent_service.rb +68 -49
  127. data/app/services/collavre/ai_client.rb +28 -10
  128. data/app/services/collavre/attachment_backfill.rb +26 -0
  129. data/app/services/collavre/auto_theme_generator.rb +1 -1
  130. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  131. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  132. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  133. data/app/services/collavre/creatives/index_query.rb +195 -24
  134. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  135. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  136. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  137. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  138. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  139. data/app/services/collavre/inbox_reply_service.rb +5 -0
  140. data/app/services/collavre/markdown_converter.rb +13 -3
  141. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  142. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  143. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  144. data/app/services/collavre/orchestration/matcher.rb +79 -4
  145. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  146. data/app/services/collavre/orchestration/stuck_detector.rb +146 -19
  147. data/app/services/collavre/tools/creative_attach_files_service.rb +29 -63
  148. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  149. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  150. data/app/services/collavre/tools/creative_remove_attachment_service.rb +7 -5
  151. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  152. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  153. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  154. data/app/services/collavre/typo_corrector.rb +188 -0
  155. data/app/views/collavre/admin/integrations/_category.html.erb +1 -1
  156. data/app/views/collavre/admin/integrations/_setting_row.html.erb +27 -11
  157. data/app/views/collavre/comments/_comment.html.erb +15 -1
  158. data/app/views/collavre/comments/_comments_popup.html.erb +18 -3
  159. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  160. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  161. data/app/views/collavre/creatives/index.html.erb +14 -1
  162. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  163. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  164. data/app/views/collavre/users/show.html.erb +3 -0
  165. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  166. data/app/views/layouts/collavre/slide.html.erb +1 -0
  167. data/config/locales/channels.en.yml +2 -0
  168. data/config/locales/channels.ko.yml +2 -0
  169. data/config/locales/claude_channel.en.yml +16 -0
  170. data/config/locales/claude_channel.ko.yml +16 -0
  171. data/config/locales/comments.en.yml +18 -0
  172. data/config/locales/comments.ko.yml +18 -0
  173. data/config/locales/creatives.en.yml +2 -0
  174. data/config/locales/creatives.ko.yml +2 -0
  175. data/config/locales/integrations.en.yml +13 -2
  176. data/config/locales/integrations.ko.yml +13 -2
  177. data/config/locales/mobile.en.yml +16 -0
  178. data/config/locales/mobile.ko.yml +16 -0
  179. data/config/locales/orchestration.en.yml +1 -0
  180. data/config/locales/orchestration.ko.yml +1 -0
  181. data/config/locales/users.en.yml +15 -0
  182. data/config/locales/users.ko.yml +15 -0
  183. data/config/routes.rb +25 -0
  184. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  185. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  186. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  187. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  188. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  189. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  190. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  191. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  192. data/db/seeds.rb +51 -0
  193. data/lib/collavre/engine.rb +0 -1
  194. data/lib/collavre/integration_settings/key_definition.rb +6 -0
  195. data/lib/collavre/integration_settings/registry.rb +7 -2
  196. data/lib/collavre/version.rb +1 -1
  197. data/lib/generators/collavre/install/install_generator.rb +1 -0
  198. metadata +85 -3
  199. data/app/services/collavre/openclaw_abort_service.rb +0 -45
  200. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -5,6 +5,7 @@ module Tools
5
5
  class CronListService
6
6
  extend T::Sig
7
7
  extend ToolMeta
8
+ include Collavre::Crons::RecurringTaskArguments
8
9
 
9
10
  tool_name "cron_list"
10
11
  tool_description "List recurring scheduled jobs. Returns all cron jobs for creatives the current user has access to. Filter by creative_id to see jobs for a specific creative."
@@ -46,20 +47,6 @@ module Tools
46
47
 
47
48
  { success: true, cron_jobs: results, count: results.size }
48
49
  end
49
-
50
- private
51
-
52
- def parse_arguments(task)
53
- args = task.arguments
54
- return {} unless args.is_a?(Array) && args.first.is_a?(Hash)
55
-
56
- args.first.stringify_keys
57
- end
58
-
59
- def parse_creative_id_from_key(key)
60
- match = key.match(/\Acron_(\d+)_/)
61
- match[1].to_i if match
62
- end
63
50
  end
64
51
  end
65
52
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Topics
5
+ # When a Topic is deleted, find every non-static recurring cron task that
6
+ # targets that topic (topic_id stored inside the task's arguments JSON) and
7
+ # post a system message into the affected creative's Main topic so the user
8
+ # knows the cron is now orphaned.
9
+ #
10
+ # Decision: notify only. The recurring task is intentionally left in place
11
+ # so the user can decide whether to re-point or cancel it.
12
+ class OrphanedCronNotifier
13
+ include Collavre::Crons::RecurringTaskArguments
14
+
15
+ def initialize(topic_id:, topic_name:)
16
+ @topic_id = topic_id
17
+ @topic_name = topic_name
18
+ end
19
+
20
+ def call
21
+ return if @topic_id.blank?
22
+
23
+ matching_tasks.each do |task, args|
24
+ notify_for(task, args)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def matching_tasks
31
+ SolidQueue::RecurringTask.where(static: false).filter_map do |task|
32
+ args = parse_arguments(task)
33
+ target = args["topic_id"]
34
+ next if target.blank?
35
+ next unless target.to_i == @topic_id.to_i
36
+
37
+ [ task, args ]
38
+ end
39
+ end
40
+
41
+ def notify_for(task, args)
42
+ creative_id = args["creative_id"] || parse_creative_id_from_key(task.key)
43
+ creative = Creative.find_by(id: creative_id)
44
+ return unless creative
45
+
46
+ # The recurring task stores the original (possibly linked/child) creative
47
+ # id, but Comment#use_origin_creative rewrites the comment to the origin.
48
+ # Post to the origin's Main topic so the notice stays on the origin and
49
+ # is reachable from normal topic navigation (matches CronCreateService).
50
+ main_topic = creative.effective_origin.main_topic
51
+ return unless main_topic
52
+
53
+ Comment.create!(
54
+ creative: creative,
55
+ topic_id: main_topic.id,
56
+ user: nil, # System message
57
+ skip_default_user: true, # keep user nil even when a deleter is Current.user; don't trigger AI orchestration
58
+ content: I18n.t(
59
+ "collavre.topics.orphaned_cron_notice",
60
+ topic_name: @topic_name,
61
+ cron_key: task.key,
62
+ message: args["message"]
63
+ )
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,188 @@
1
+ module Collavre
2
+ # Server-side typo correction. Sends the text the user is typing to an LLM and
3
+ # returns a validated *structured edit list* โ€” never a rewritten string. Each
4
+ # edit is {original, suggestion, reason, confidence}. Validation rejects anything
5
+ # that is not a small, in-place spelling fix (style rewrites, hallucinated spans,
6
+ # edits inside code/URLs/mentions), so the UI can trust every returned edit.
7
+ class TypoCorrector
8
+ AGENT_EMAIL = "typo-corrector@collavre.local"
9
+
10
+ # Reject "corrections" that are really rewrites: an edit is kept only when the
11
+ # original is short and the suggestion is within this edit distance of it.
12
+ MAX_ORIGINAL_LENGTH = 40
13
+ MIN_EDIT_DISTANCE_CAP = 2
14
+ EDIT_DISTANCE_RATIO = 0.4
15
+
16
+ FALLBACK_SYSTEM_PROMPT = <<~PROMPT.freeze
17
+ You are a typo correction engine. Fix only spelling mistakes and obvious typos,
18
+ never style, grammar or meaning, in any language. `original` must be an exact
19
+ substring of the input. Return ONLY JSON:
20
+ {"edits":[{"original":"x","suggestion":"y","reason":"spelling","confidence":0.0}]}
21
+ PROMPT
22
+
23
+ def initialize(client: nil)
24
+ @client = client
25
+ end
26
+
27
+ # Returns an Array of Hashes with string keys: "original", "suggestion",
28
+ # "reason", "confidence". Empty array on blank input or LLM/parse failure.
29
+ def correct(text)
30
+ text = text.to_s
31
+ return [] if text.strip.blank?
32
+
33
+ response = client.chat([
34
+ { role: :user, parts: [ { text: "Text:\n#{text}" } ] }
35
+ ])
36
+
37
+ edits = parse_response(response)
38
+ validate(edits, text)
39
+ end
40
+
41
+ private
42
+
43
+ def client
44
+ @client ||= build_client
45
+ end
46
+
47
+ def build_client
48
+ agent = Collavre.user_class.find_by(email: AGENT_EMAIL)
49
+ AiClient.new(
50
+ vendor: agent&.llm_vendor.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_VENDOR", "gemini"),
51
+ model: agent&.llm_model.presence || ENV.fetch("COLLAVRE_DEFAULT_LLM_MODEL", "gemini-3.1-flash-lite"),
52
+ system_prompt: agent&.system_prompt.presence || FALLBACK_SYSTEM_PROMPT,
53
+ llm_api_key: agent&.llm_api_key,
54
+ gateway_url: agent&.gateway_url,
55
+ # Vendor adapters (e.g. OpenClaw) resolve the gateway/key from the context
56
+ # user, not the llm_api_key/gateway_url kwargs. Pass the agent so an
57
+ # OpenClaw-backed typo agent isn't built with user: nil (which makes the
58
+ # adapter bail with "Gateway URL not configured" and silently return no edits).
59
+ context: { user: agent },
60
+ # Runs on debounced typing of *unsubmitted* text โ€” never persist the draft
61
+ # to ActivityLog (RubyLlmInteractionLogger writes `messages` there).
62
+ log_interactions: false
63
+ )
64
+ end
65
+
66
+ def parse_response(content)
67
+ return [] if content.blank?
68
+
69
+ cleaned = content.gsub(/^```json\s*/, "").gsub(/\s*```$/, "").strip
70
+ parsed = JSON.parse(cleaned)
71
+ edits = parsed.is_a?(Hash) ? parsed["edits"] : parsed
72
+ edits.is_a?(Array) ? edits : []
73
+ rescue JSON::ParserError => e
74
+ # Never log the raw response: it can echo the user's unsubmitted draft,
75
+ # which would leak private text to application logs despite log_interactions: false.
76
+ Rails.logger.warn("[TypoCorrector] JSON parse error: #{e.message} (#{content.to_s.length} chars)")
77
+ []
78
+ end
79
+
80
+ def validate(edits, text)
81
+ skip_ranges = skip_ranges(text)
82
+ seen = {}
83
+
84
+ edits.filter_map do |edit|
85
+ next unless edit.is_a?(Hash)
86
+
87
+ original = edit["original"].to_s
88
+ suggestion = edit["suggestion"].to_s
89
+ next if original.empty? || suggestion.empty? || original == suggestion
90
+ next if original.length > MAX_ORIGINAL_LENGTH
91
+
92
+ # `original` must appear verbatim in the text, outside any skip region
93
+ # (code/URL/mention/markup). We return the offset of that first *valid*
94
+ # occurrence so the client anchors there โ€” otherwise the client's own
95
+ # left-to-right search would bind to an earlier occurrence sitting inside
96
+ # a protected span and edit it (a markdown-canonical/code-safety hole).
97
+ offset = first_offset_outside_skip_ranges(text, original, skip_ranges)
98
+ next if offset.nil?
99
+
100
+ # Reject big rewrites โ€” keep only minimal in-place fixes.
101
+ cap = [ MIN_EDIT_DISTANCE_CAP, (original.length * EDIT_DISTANCE_RATIO).ceil ].max
102
+ next if levenshtein(original, suggestion) > cap
103
+
104
+ key = [ original, suggestion ]
105
+ next if seen[key]
106
+ seen[key] = true
107
+
108
+ {
109
+ "original" => original,
110
+ "suggestion" => suggestion,
111
+ "reason" => edit["reason"].presence || "spelling",
112
+ "confidence" => clamp_confidence(edit["confidence"]),
113
+ # Emit the offset in UTF-16 code units, the coordinate space JS string
114
+ # indexing uses. A Ruby character index would be off by one per astral
115
+ # character (emoji, etc.) before the typo, so the client's substring
116
+ # check at `offset` would fail and fall back to indexOf โ€” which can bind
117
+ # to a protected occurrence the server already excluded.
118
+ "offset" => utf16_offset(text, offset)
119
+ }
120
+ end
121
+ end
122
+
123
+ def clamp_confidence(value)
124
+ return 0.5 if value.nil?
125
+
126
+ Float(value).clamp(0.0, 1.0)
127
+ rescue ArgumentError, TypeError
128
+ 0.5
129
+ end
130
+
131
+ # Character ranges that must never be edited: fenced code, inline code, URLs,
132
+ # @mentions, HTML tags, and markdown-canonical <span style> color fragments.
133
+ def skip_ranges(text)
134
+ ranges = []
135
+ patterns = [
136
+ /```.*?```/m, # fenced code blocks
137
+ /`[^`]*`/, # inline code
138
+ %r{https?://\S+}, # URLs
139
+ /@[^\s@]+/, # @mentions
140
+ # `[^<>]` (not just `[^>]`) keeps this linear: a stray run of "<<<<" can't
141
+ # make scan re-walk the tail from every "<" (CodeQL polynomial-ReDoS).
142
+ /<[^<>]+>/ # HTML / span-style markup
143
+ ]
144
+ patterns.each do |pattern|
145
+ text.scan(pattern) { ranges << Regexp.last_match.offset(0) }
146
+ end
147
+ ranges
148
+ end
149
+
150
+ # First character offset where `needle` occurs entirely outside every skip
151
+ # range, or nil if every occurrence overlaps a protected span.
152
+ def first_offset_outside_skip_ranges(text, needle, skip_ranges)
153
+ pos = 0
154
+ while (idx = text.index(needle, pos))
155
+ finish = idx + needle.length
156
+ inside = skip_ranges.any? { |(s, e)| idx < e && finish > s }
157
+ return idx unless inside
158
+
159
+ pos = idx + 1
160
+ end
161
+ nil
162
+ end
163
+
164
+ # Convert a Ruby character index into a UTF-16 code-unit offset (JS string
165
+ # coordinates). Characters outside the BMP count as two code units in JS.
166
+ def utf16_offset(text, char_index)
167
+ text[0, char_index].to_s.encode("UTF-16LE").bytesize / 2
168
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
169
+ char_index
170
+ end
171
+
172
+ # Iterative Levenshtein distance.
173
+ def levenshtein(a, b)
174
+ a = a.chars
175
+ b = b.chars
176
+ prev = (0..b.length).to_a
177
+ a.each_with_index do |ac, i|
178
+ curr = [ i + 1 ]
179
+ b.each_with_index do |bc, j|
180
+ cost = ac == bc ? 0 : 1
181
+ curr << [ curr[j] + 1, prev[j + 1] + 1, prev[j] + cost ].min
182
+ end
183
+ prev = curr
184
+ end
185
+ prev.last
186
+ end
187
+ end
188
+ end
@@ -4,7 +4,7 @@
4
4
  </h3>
5
5
 
6
6
  <div class="table-scroll">
7
- <table class="settings-table" style="width: 100%; min-width: 640px; border-collapse: collapse;">
7
+ <table class="settings-table" style="width: 100%; min-width: 640px; border-collapse: collapse; table-layout: fixed;">
8
8
  <thead>
9
9
  <tr>
10
10
  <th style="text-align: left; padding: 0.5em; width: 30%;"><%= t("collavre.admin.integrations.headers.key") %></th>
@@ -8,29 +8,45 @@
8
8
  end
9
9
  current_display = row[:present] ? row[:display_value].to_s : "โ€”"
10
10
  %>
11
+ <% description = t("collavre.admin.integrations.descriptions.#{key_str}", default: "") %>
11
12
  <tr style="border-top: 1px solid var(--color-border);">
12
- <td style="padding: 0.5em; vertical-align: top;">
13
- <code><%= key_str %></code>
13
+ <td style="padding: 0.5em; vertical-align: top; word-break: break-word; overflow-wrap: anywhere;">
14
+ <code style="word-break: break-all; overflow-wrap: anywhere;"><%= key_str %></code>
14
15
  <% if definition.requires_restart %>
15
16
  <span class="badge badge-warning" style="display: inline-block; margin-left: 0.5em; padding: 0.1em 0.5em; font-size: 0.75em; background: var(--color-warning); color: var(--text-on-badge); border-radius: 3px;">
16
17
  <%= t("collavre.admin.integrations.restart_required") %>
17
18
  </span>
18
19
  <% end %>
20
+ <% if description.present? %>
21
+ <div style="font-size: 0.85em; color: var(--color-text); margin-top: 0.4em; line-height: 1.4;">
22
+ <%= simple_format(description) %>
23
+ </div>
24
+ <% end %>
19
25
  <div style="font-size: 0.8em; color: var(--color-muted); margin-top: 0.25em;">
20
- <%= t("collavre.admin.integrations.env_var_label") %>: <code><%= definition.env_var %></code>
26
+ <%= t("collavre.admin.integrations.env_var_label") %>: <code style="word-break: break-all; overflow-wrap: anywhere;"><%= definition.env_var %></code>
21
27
  </div>
22
28
  </td>
23
29
  <td style="padding: 0.5em; vertical-align: top;">
24
30
  <%# Input references the bulk form via HTML5 `form` attribute so the table is not nested inside <form>. %>
25
31
  <%# Placeholder displays the current value (masked for sensitive); empty input keeps the existing value. %>
26
- <% input_tag = definition.sensitive ? :password_field_tag : :text_field_tag %>
27
- <%= send(input_tag,
28
- "integration_setting[#{key_str}]",
29
- nil,
30
- form: bulk_form_id,
31
- placeholder: current_display,
32
- autocomplete: "off",
33
- style: "width: 100%;") %>
32
+ <% if definition.input_type == :textarea %>
33
+ <%= text_area_tag "integration_setting[#{key_str}]",
34
+ nil,
35
+ form: bulk_form_id,
36
+ placeholder: current_display,
37
+ autocomplete: "off",
38
+ rows: 8,
39
+ style: "width: 100%; font-family: var(--font-mono, monospace); font-size: 0.85em; min-height: 8em;" %>
40
+ <% else %>
41
+ <% input_tag = definition.sensitive ? :password_field_tag : :text_field_tag %>
42
+ <%= send(input_tag,
43
+ "integration_setting[#{key_str}]",
44
+ nil,
45
+ form: bulk_form_id,
46
+ placeholder: current_display,
47
+ autocomplete: "off",
48
+ style: "width: 100%;") %>
49
+ <% end %>
34
50
  <div style="font-size: 0.8em; margin-top: 0.25em;">
35
51
  <span class="badge badge-source-<%= row[:source] %>" style="display: inline-block; padding: 0.1em 0.5em; font-size: 0.85em; border-radius: 3px; background: <%= source_bg %>; color: <%= source_fg %>;">
36
52
  <%= t("collavre.admin.integrations.source.#{row[:source]}", default: row[:source].to_s.upcase) %>
@@ -36,7 +36,11 @@
36
36
  <span class="comment-status-label private-label">๐Ÿ”’ <%= t('collavre.comments.private') %></span>
37
37
  <% end %>
38
38
  <% if comment.action_executed_at.present? %>
39
- <span class="comment-status-label approved-label">โœ… <%= t('collavre.comments.approved_label') %></span>
39
+ <% if comment.claude_channel_permission_denied? %>
40
+ <span class="comment-status-label denied-label">๐Ÿšซ <%= t('collavre.comments.denied_label') %></span>
41
+ <% else %>
42
+ <span class="comment-status-label approved-label">โœ… <%= t('collavre.comments.approved_label') %></span>
43
+ <% end %>
40
44
  <% end %>
41
45
  <% can_convert_comment = comment.user == Current.user || comment.creative.has_permission?(Current.user, :admin) %>
42
46
  </div>
@@ -45,6 +49,11 @@
45
49
  <button class="comment-stop-btn" type="button" data-action="click->comment#cancelTask" data-task-id="<%= comment.task_id %>" title="<%= t('collavre.comments.stop_agent') %>">
46
50
  <span class="agent-stop-icon">&#x25A0;</span> <%= t('collavre.comments.stop_agent') %>
47
51
  </button>
52
+ <% elsif comment.waiting_notice? && (blocking_task = comment.topic_blocking_task) %>
53
+ <%# Stop the in-progress blocker so the deferred waiter can proceed (stuck recovery). %>
54
+ <button class="comment-stop-btn" type="button" data-action="click->comment#cancelTask" data-task-id="<%= blocking_task.id %>" title="<%= t('collavre.comments.stop_blocking_agent') %>">
55
+ <span class="agent-stop-icon">&#x25A0;</span> <%= t('collavre.comments.stop_blocking_agent') %>
56
+ </button>
48
57
  <% end %>
49
58
  <button class="add-reaction-btn" type="button" data-action="click->comment#triggerReactionPicker" title="<%= t('collavre.comments.add_reaction') %>">
50
59
  <span class="grayscale-emoji">โ˜บ</span>
@@ -57,6 +66,11 @@
57
66
  <button class="approve-comment-btn comment-approve-hidden" data-comment-target="approveButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.approve_button') %>">
58
67
  <%= t('collavre.comments.approve_button') %>
59
68
  </button>
69
+ <% if comment.claude_channel_permission? %>
70
+ <button class="deny-comment-btn comment-approve-hidden" data-comment-target="denyButton" data-comment-id="<%= comment.id %>" title="<%= t('collavre.comments.deny_button') %>">
71
+ <%= t('collavre.comments.deny_button') %>
72
+ </button>
73
+ <% end %>
60
74
  <% end %>
61
75
  <button class="edit-comment-btn comment-owner-only" data-comment-target="ownerButton" data-comment-id="<%= comment.id %>" data-comment-content="<%= comment.content %>" data-comment-private="<%= comment.private? %>" title="<%= t('collavre.comments.update_comment') %>">
62
76
  <%= t('collavre.comments.edit_button') %>
@@ -61,7 +61,20 @@
61
61
  data-remove-from-history-label="<%= t('collavre.comments.remove_from_history') %>"
62
62
  data-inbox-reply-button="<%= t('collavre.comments.inbox_reply_button') %>"
63
63
  data-table-download-csv-text="<%= t('collavre.comments.table_download.csv') %>"
64
- data-table-download-excel-text="<%= t('collavre.comments.table_download.excel') %>">
64
+ data-table-download-excel-text="<%= t('collavre.comments.table_download.excel') %>"
65
+ <% if Current.user %>
66
+ data-typo-enabled="<%= Current.user.typo_correction_enabled %>"
67
+ data-typo-threshold="<%= Current.user.typo_correction_threshold %>"
68
+ data-typo-on-voice="<%= Current.user.typo_correction_on_voice %>"
69
+ data-typo-on-soft-keyboard="<%= Current.user.typo_correction_on_soft_keyboard %>"
70
+ data-typo-on-physical-keyboard="<%= Current.user.typo_correction_on_physical_keyboard %>"
71
+ data-typo-in-chat="<%= Current.user.typo_correction_in_chat %>"
72
+ data-typo-in-editor="<%= Current.user.typo_correction_in_editor %>"
73
+ data-typo-endpoint="<%= collavre.typo_corrections_path %>"
74
+ data-typo-keep-label="<%= t('collavre.comments.typo_keep_label') %>"
75
+ data-typo-custom-label="<%= t('collavre.comments.typo_custom_label') %>"
76
+ data-typo-input-label="<%= t('collavre.comments.typo_input_label') %>"
77
+ <% end %>>
65
78
  <div class="resize-handle resize-handle-left" data-comments--popup-target="leftHandle"></div>
66
79
  <div class="resize-handle resize-handle-right" data-comments--popup-target="rightHandle"></div>
67
80
  <div class="comments-popup-header" data-comments--popup-target="header">
@@ -120,8 +133,10 @@
120
133
  <div id="comments-list" data-comments--popup-target="list" data-comments--list-target="list"><%= t('app.loading') %></div>
121
134
  <div id="typing-indicator-row">
122
135
  <button type="button" id="scroll-prev-msg-btn" class="scroll-prev-msg-btn" data-action="click->comments--list#scrollToPreviousMessage" title="<%= t('collavre.comments.scroll_to_prev', default: 'Previous message') %>" aria-label="<%= t('collavre.comments.scroll_to_prev', default: 'Previous message') %>">&#8963;</button>
123
- <div id="channel-chips-container" data-comments--presence-target="channelChips"></div>
124
- <div id="typing-indicator" data-comments--presence-target="typingIndicator" data-comments--popup-target="typingIndicator" data-stop-agent-text="<%= t('collavre.comments.stop_agent') %>"></div>
136
+ <div id="typing-scroll-viewport" data-comments--presence-target="scrollRow">
137
+ <div id="channel-chips-container" data-comments--presence-target="channelChips"></div>
138
+ <div id="typing-indicator" data-comments--presence-target="typingIndicator" data-comments--popup-target="typingIndicator" data-stop-agent-text="<%= t('collavre.comments.stop_agent') %>"></div>
139
+ </div>
125
140
  </div>
126
141
  <form id="new-comment-form" data-comments--popup-target="form" data-comments--form-target="form" style="display:none;">
127
142
  <input type="hidden" name="comment[quoted_comment_id]" data-comments--form-target="quotedCommentId" value="" />
@@ -4,6 +4,7 @@
4
4
  <input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>" />
5
5
  <input type="hidden" id="inline-creative-description" name="creative[description]" />
6
6
  <input type="hidden" id="inline-content-type" name="creative[content_type_input]" value="html" />
7
+ <input type="hidden" id="inline-markdown-editor" name="creative[markdown_editor]" />
7
8
  <input type="hidden" id="inline-markdown-source" name="creative[markdown_source]" />
8
9
  <input type="hidden" id="inline-parent-id" name="creative[parent_id]" />
9
10
  <input type="hidden" id="inline-before-id" name="before_id" />
@@ -0,0 +1,42 @@
1
+ <%#
2
+ Popup shown after a topic is moved to another creative, offering to re-add
3
+ members who had access on the source creative but not on the target.
4
+ Rendered hidden; populated and shown by topic_move_members_popup.js using the
5
+ `missing_members` payload returned from TopicsController#move.
6
+ %>
7
+ <div id="topic-move-members-modal"
8
+ style="display: none; position: fixed; inset: 0; z-index: 10001; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3);"
9
+ data-shares-url-template="<%= collavre.creative_creative_shares_path('__CREATIVE_ID__') %>"
10
+ data-adding-text="<%= t('collavre.topics.move.add_members.adding') %>"
11
+ data-added-one="<%= t('collavre.topics.move.add_members.added.one') %>"
12
+ data-added-other="<%= t('collavre.topics.move.add_members.added.other', count: '__COUNT__') %>"
13
+ data-partial-text="<%= t('collavre.topics.move.add_members.partial', added: '__ADDED__', failed: '__FAILED__') %>"
14
+ data-failed-text="<%= t('collavre.topics.move.add_members.failed') %>"
15
+ data-description-template="<%= t('collavre.topics.move.add_members.description', creative: '__CREATIVE_NAME__') %>">
16
+ <div class="popup-box" style="max-width: 460px; width: 90%;">
17
+ <button type="button" id="topic-move-members-close" class="popup-close" aria-label="Close" style="position: absolute; top: 12px; right: 12px;">&times;</button>
18
+ <h2><%= t('collavre.topics.move.add_members.title') %></h2>
19
+ <p id="topic-move-members-description" style="opacity: 0.75; margin: 0.5em 0 1em;"></p>
20
+ <div id="topic-move-members-message" aria-live="polite"></div>
21
+ <ul id="topic-move-members-list" class="share-grid"></ul>
22
+ <div style="display: flex; gap: 0.5em; justify-content: flex-end; margin-top: 1em;">
23
+ <button type="button" id="topic-move-members-skip" class="btn btn-secondary"><%= t('collavre.topics.move.add_members.skip') %></button>
24
+ <button type="button" id="topic-move-members-add" class="btn btn-primary"><%= t('collavre.topics.move.add_members.add') %></button>
25
+ </div>
26
+ </div>
27
+
28
+ <template id="topic-move-member-row">
29
+ <li class="topic-move-member-row" style="display: flex; align-items: center; gap: 0.5em;">
30
+ <input type="checkbox" class="tmm-checkbox" checked>
31
+ <span class="avatar share-avatar tmm-avatar" style="display: inline-block; width: 20px; height: 20px; border-radius: 50%; background: var(--surface-3, #ddd); text-align: center; line-height: 20px; font-size: 10px; overflow: hidden;"></span>
32
+ <span class="tmm-name"></span>
33
+ <span class="tmm-email" style="opacity: 0.5; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"></span>
34
+ <select class="org-chart-permission-select share-modal-permission-select tmm-permission">
35
+ <% %w[admin write feedback read].each do |perm| %>
36
+ <option value="<%= perm %>"><%= t("collavre.creatives.index.permission_#{perm}") %></option>
37
+ <% end %>
38
+ </select>
39
+ <span class="tmm-status" style="width: 16px; text-align: center;"></span>
40
+ </li>
41
+ </template>
42
+ </div>
@@ -104,6 +104,17 @@
104
104
  <div data-share-modal-target="container"></div>
105
105
  <% end %>
106
106
 
107
+ <%# The "re-add dropped members" modal must be present wherever a topic can be
108
+ dropped onto another creative โ€” including the top-level index, where
109
+ @parent_creative/@creative are nil (moving a topic between root-level
110
+ creatives). Gating it on the write check above hid it exactly there, so
111
+ getModal() returned null and the popup silently never appeared. It stays
112
+ hidden until populated, and share creation is permission-checked
113
+ server-side, so rendering it for any authenticated user is safe. %>
114
+ <% if authenticated? %>
115
+ <%= render 'topic_move_members_modal' %>
116
+ <% end %>
117
+
107
118
  <%= render 'import_upload_zone' %>
108
119
 
109
120
  <% if can_manage_integrations %>
@@ -159,6 +170,7 @@
159
170
  title_effective_origin = @parent_creative.effective_origin(Set.new)
160
171
  title_content_type = title_effective_origin.data&.dig("content_type")
161
172
  title_markdown_source = title_can_write ? title_effective_origin.data&.dig("markdown_source") : nil
173
+ title_markdown_editor = title_effective_origin.data&.dig("editor")
162
174
  %>
163
175
  <%= content_tag(
164
176
  "creative-tree-row",
@@ -172,7 +184,8 @@
172
184
  "data-progress-value": @parent_creative.progress,
173
185
  "data-origin-id": @parent_creative.origin_id,
174
186
  "data-content-type": title_content_type,
175
- "data-markdown-source": title_markdown_source
187
+ "data-markdown-source": title_markdown_source,
188
+ "data-markdown-editor": title_markdown_editor
176
189
  ) %>
177
190
  <% else %>
178
191
  <%= content_tag(
@@ -8,7 +8,7 @@
8
8
  <% initial_index = params[:slide].to_i.clamp(0, @slide_ids.length - 1) %>
9
9
  <% initial_prompt = @creative.prompt_for(Current.user) %>
10
10
  <div id="slide-view" data-slide-ids="<%= @slide_ids.join(',') %>" data-initial-index="<%= initial_index %>" data-root-id="<%= @creative.id %>">
11
- <div id="slide-content"><%= content_tag(tag_name, sanitize(@creative.effective_description, tags: Rails::HTML5::SafeListSanitizer.allowed_tags.to_a + %w[table thead tbody tfoot tr th td], attributes: Rails::HTML5::SafeListSanitizer.allowed_attributes.to_a + %w[colspan rowspan data-lexical])) %></div>
11
+ <div id="slide-content"><%= content_tag(tag_name, embed_youtube_iframe(@creative.effective_description), class: 'creative-content') %></div>
12
12
  </div>
13
13
  <div id="slide-controls">
14
14
  <div id="slide-counter"><%= initial_index + 1 %> / <%= @slide_ids.length %></div>
@@ -1,6 +1,10 @@
1
- <div id="link-creative-modal" class="common-popup" style="display:none;" data-controller="link-creative">
1
+ <div id="link-creative-modal" class="common-popup" style="display:none;" data-controller="link-creative"
2
+ data-link-creative-loading-text="<%= t('collavre.creatives.index.link_loading', default: 'Loadingโ€ฆ') %>"
3
+ data-link-creative-no-results-text="<%= t('collavre.creatives.index.no_search_results', default: 'No creatives match your search.') %>"
4
+ data-link-creative-empty-text="<%= t('collavre.creatives.index.no_sub_creatives', default: 'No sub-creatives found.') %>"
5
+ data-link-creative-expand-text="<%= t('collavre.creatives.index.link_expand', default: 'Expand') %>">
2
6
  <button type="button" id="close-link-creative-modal" class="popup-close-btn" data-link-creative-target="close">&times;</button>
3
7
  <h3><%= t('collavre.creatives.index.link', default: 'Link') %></h3>
4
8
  <input type="text" id="link-creative-search" class="shared-input-surface" style="width:100%;margin-bottom:0.5em;" placeholder="<%= t('collavre.creatives.index.search_placeholder', default: 'Search creative') %>" data-link-creative-target="input">
5
- <ul id="link-creative-results" class="common-popup-list" data-popup-list data-link-creative-target="list"></ul>
9
+ <ul id="link-creative-results" class="common-popup-list link-creative-list" data-popup-list data-link-creative-target="list"></ul>
6
10
  </div>
@@ -101,6 +101,7 @@
101
101
  <%= f.label :timezone, t('collavre.users.timezone') %>
102
102
  <%= f.select :timezone, ActiveSupport::TimeZone.all.map { |tz| [tz.to_s, tz.tzinfo.name] } %>
103
103
  </div>
104
+
104
105
  <%= f.submit t('collavre.users.update_profile') %>
105
106
  <% end %>
106
107
 
@@ -109,6 +110,8 @@
109
110
  <br>
110
111
  <%= link_to t('collavre.users.webauthn.manage'), collavre.passkeys_user_path(@user) %>
111
112
  <br>
113
+ <%= link_to t('collavre.users.typo_correction.manage'), collavre.typo_correction_user_path(@user) %>
114
+ <br>
112
115
  <%= link_to t('doorkeeper.my_applications'), main_app.oauth_applications_path %>
113
116
  <% if @user.system_admin? %>
114
117
  <br>
@@ -0,0 +1,50 @@
1
+ <h1 class="no-top-margin"><%= t('collavre.users.typo_correction.legend') %></h1>
2
+
3
+ <%= form_with model: @user, url: collavre.user_path(@user), method: :patch, local: true, html: { class: 'profile-form' } do |f| %>
4
+ <p class="text-muted"><%= t('collavre.users.typo_correction.description') %></p>
5
+
6
+ <div class="checkbox-field">
7
+ <%= f.check_box :typo_correction_enabled %>
8
+ <%= f.label :typo_correction_enabled, t('collavre.users.typo_correction.enabled') %>
9
+ </div>
10
+
11
+ <div>
12
+ <%= f.label :typo_correction_threshold, t('collavre.users.typo_correction.threshold') %>
13
+ <%= f.number_field :typo_correction_threshold, in: 0..100, step: 1, required: true %>
14
+ <small class="text-muted"><%= t('collavre.users.typo_correction.threshold_hint') %></small>
15
+ </div>
16
+
17
+ <fieldset>
18
+ <legend><%= t('collavre.users.typo_correction.device_legend') %></legend>
19
+ <div class="checkbox-field">
20
+ <%= f.check_box :typo_correction_on_voice %>
21
+ <%= f.label :typo_correction_on_voice, t('collavre.users.typo_correction.on_voice') %>
22
+ </div>
23
+ <div class="checkbox-field">
24
+ <%= f.check_box :typo_correction_on_soft_keyboard %>
25
+ <%= f.label :typo_correction_on_soft_keyboard, t('collavre.users.typo_correction.on_soft_keyboard') %>
26
+ </div>
27
+ <div class="checkbox-field">
28
+ <%= f.check_box :typo_correction_on_physical_keyboard %>
29
+ <%= f.label :typo_correction_on_physical_keyboard, t('collavre.users.typo_correction.on_physical_keyboard') %>
30
+ </div>
31
+ </fieldset>
32
+
33
+ <fieldset>
34
+ <legend><%= t('collavre.users.typo_correction.location_legend') %></legend>
35
+ <div class="checkbox-field">
36
+ <%= f.check_box :typo_correction_in_chat %>
37
+ <%= f.label :typo_correction_in_chat, t('collavre.users.typo_correction.in_chat') %>
38
+ </div>
39
+ <div class="checkbox-field">
40
+ <%= f.check_box :typo_correction_in_editor %>
41
+ <%= f.label :typo_correction_in_editor, t('collavre.users.typo_correction.in_editor') %>
42
+ </div>
43
+ </fieldset>
44
+
45
+ <%= f.submit t('collavre.users.update') %>
46
+ <% end %>
47
+
48
+ <div style="margin-top: 1rem;">
49
+ <%= link_to t('collavre.users.back'), collavre.user_path(@user) %>
50
+ </div>
@@ -10,6 +10,7 @@
10
10
  <%= stylesheet_link_tag "collavre/design_tokens" %>
11
11
  <%= stylesheet_link_tag "collavre/dark_mode" %>
12
12
  <%= stylesheet_link_tag 'collavre/slide_view', media: 'all' %>
13
+ <%= stylesheet_link_tag 'collavre/tables', media: 'all' %>
13
14
  <%= javascript_include_tag 'actioncable', defer: true %>
14
15
  <%= javascript_include_tag 'slide_view', defer: true %>
15
16
  <%= render 'collavre/shared/custom_theme_style' %>
@@ -9,3 +9,5 @@ en:
9
9
  badge:
10
10
  running: "Running"
11
11
  stopped: "Stopped"
12
+ claude_channel:
13
+ disconnected: "๐Ÿ”Œ Not connected to Claude Channel โ€” your message will be delivered once it reconnects."
@@ -9,3 +9,5 @@ ko:
9
9
  badge:
10
10
  running: "์‹คํ–‰ ์ค‘"
11
11
  stopped: "์ค‘์ง€๋จ"
12
+ claude_channel:
13
+ disconnected: "๐Ÿ”Œ Claude Channel๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค โ€” ๋‹ค์‹œ ์—ฐ๊ฒฐ๋˜๋ฉด ๋ฉ”์‹œ์ง€๊ฐ€ ์ „๋‹ฌ๋ฉ๋‹ˆ๋‹ค."