collavre 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) 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 +118 -61
  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/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -328,6 +328,13 @@ body.chat-fullscreen {
328
328
  margin-top: 0.2em;
329
329
  }
330
330
 
331
+ .comment-content img,
332
+ .comment-content video {
333
+ max-width: 100%;
334
+ height: auto;
335
+ border-radius: var(--radius-2);
336
+ }
337
+
331
338
  .comment-content pre {
332
339
  white-space: pre-wrap;
333
340
  word-break: break-word;
@@ -360,67 +367,8 @@ body.chat-fullscreen {
360
367
  margin: 0;
361
368
  }
362
369
 
363
- /* Table styling */
364
- .comment-content table {
365
- border-collapse: collapse;
366
- width: 100%;
367
- font-size: 0.85em;
368
- margin: 0;
369
- }
370
-
371
- .comment-content table th,
372
- .comment-content table td {
373
- border: 1px solid var(--color-border);
374
- padding: 0.3em 0.6em;
375
- text-align: left;
376
- white-space: nowrap;
377
- }
378
-
379
- .comment-content table th {
380
- background: var(--color-section-bg);
381
- font-weight: 600;
382
- }
383
-
384
- .comment-content table tr:hover td {
385
- background: color-mix(in srgb, var(--color-section-bg) 40%, transparent);
386
- }
387
-
388
- /* Table download wrapper */
389
- .table-download-wrapper {
390
- position: relative;
391
- overflow-x: auto;
392
- margin: 0.4em 0;
393
- }
394
-
395
- .table-download-toolbar {
396
- display: flex;
397
- justify-content: flex-end;
398
- gap: 0.3em;
399
- padding: 0.15em 0;
400
- opacity: 0;
401
- transition: opacity 0.15s ease;
402
- }
403
-
404
- .table-download-wrapper:hover .table-download-toolbar {
405
- opacity: 1;
406
- }
407
-
408
- .table-download-btn {
409
- background: none;
410
- border: 1px solid var(--color-border);
411
- border-radius: var(--radius-2);
412
- color: var(--color-muted);
413
- font-size: 0.75em;
414
- padding: 0.15em 0.5em;
415
- cursor: pointer;
416
- line-height: 1.4;
417
- transition: color 0.15s ease, border-color 0.15s ease;
418
- }
419
-
420
- .table-download-btn:hover {
421
- color: var(--color-active);
422
- border-color: var(--color-active);
423
- }
370
+ /* Table + download-toolbar styling moved to collavre/tables.css (shared with
371
+ creative description tables). Load 'collavre/tables' alongside this file. */
424
372
 
425
373
  /* Quote indicator in form */
426
374
  .comment-quote-indicator {
@@ -1989,3 +1937,112 @@ body.chat-fullscreen {
1989
1937
  li:hover .topic-create-option {
1990
1938
  text-decoration: underline;
1991
1939
  }
1940
+
1941
+ /* ── Inline typo correction (volatile overlay; never serialized) ───────────── */
1942
+ /* The backdrop mirrors the textarea text exactly and paints <mark> spans over
1943
+ it. The backdrop sits ON TOP of the textarea (higher z-index) but is
1944
+ pointer-events:none, so clicks pass through to the textarea everywhere EXCEPT
1945
+ over a <mark> (pointer-events:auto) — that's what makes the marks clickable.
1946
+ If the textarea were on top, the browser would hit-test it first and the mark
1947
+ click handlers would never fire. The backdrop's text is transparent, so the
1948
+ textarea's real (opaque) glyphs show through and only the underlines paint. */
1949
+ .typo-input-wrap {
1950
+ position: relative;
1951
+ display: block;
1952
+ }
1953
+
1954
+ .typo-input-wrap > textarea {
1955
+ position: relative;
1956
+ background: transparent;
1957
+ z-index: 0;
1958
+ }
1959
+
1960
+ .typo-backdrop {
1961
+ position: absolute;
1962
+ inset: 0;
1963
+ overflow: hidden;
1964
+ pointer-events: none;
1965
+ z-index: 1;
1966
+ }
1967
+
1968
+ .typo-highlights {
1969
+ margin: 0;
1970
+ color: transparent;
1971
+ white-space: pre-wrap;
1972
+ overflow-wrap: break-word;
1973
+ word-break: break-word;
1974
+ }
1975
+
1976
+ /* Two states, distinguished by BOTH shape and colour (colour-blind safe). */
1977
+ .typo-mark {
1978
+ background: transparent;
1979
+ color: transparent;
1980
+ pointer-events: auto;
1981
+ cursor: pointer;
1982
+ border-radius: 2px;
1983
+ }
1984
+
1985
+ /* Auto-applied (>= threshold): faint straight underline — resolved, dim. */
1986
+ .typo-mark-applied {
1987
+ text-decoration: underline solid var(--color-link);
1988
+ text-decoration-thickness: 1px;
1989
+ text-underline-offset: 2px;
1990
+ opacity: 0.55;
1991
+ }
1992
+
1993
+ /* Candidate (< threshold): wavy dotted amber underline — needs a decision. */
1994
+ .typo-mark-candidate {
1995
+ text-decoration: underline wavy var(--color-warning);
1996
+ text-decoration-thickness: 1px;
1997
+ text-underline-offset: 2px;
1998
+ }
1999
+
2000
+ /* Creatable combobox reusing CommonPopup positioning. */
2001
+ .typo-popup {
2002
+ position: absolute;
2003
+ z-index: 100000;
2004
+ min-width: 180px;
2005
+ max-width: 320px;
2006
+ background: var(--surface-section);
2007
+ border: 1px solid var(--border-color);
2008
+ border-radius: 8px;
2009
+ box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
2010
+ padding: 6px;
2011
+ }
2012
+
2013
+ .typo-popup-input {
2014
+ width: 100%;
2015
+ box-sizing: border-box;
2016
+ padding: 6px 8px;
2017
+ border: 1px solid var(--border-color);
2018
+ border-radius: 6px;
2019
+ font: inherit;
2020
+ margin-bottom: 6px;
2021
+ }
2022
+
2023
+ .typo-popup-list {
2024
+ list-style: none;
2025
+ margin: 0;
2026
+ padding: 0;
2027
+ max-height: 200px;
2028
+ overflow-y: auto;
2029
+ }
2030
+
2031
+ .typo-popup-list .common-popup-item {
2032
+ padding: 6px 8px;
2033
+ border-radius: 6px;
2034
+ cursor: pointer;
2035
+ display: flex;
2036
+ justify-content: space-between;
2037
+ gap: 8px;
2038
+ }
2039
+
2040
+ .typo-popup-list .common-popup-item.active,
2041
+ .typo-popup-list .common-popup-item:hover {
2042
+ background: var(--surface-hover);
2043
+ }
2044
+
2045
+ .typo-popup-role {
2046
+ color: var(--text-muted);
2047
+ font-size: 0.85em;
2048
+ }
@@ -257,14 +257,23 @@ creative-tree-row.show-edit .creative-row {
257
257
  word-break: break-word;
258
258
  }
259
259
 
260
- /* Allow attachments and images to be clickable independently of the parent link */
260
+ /* Allow attachments, images, and videos to be interactive independently of the
261
+ parent link. Without pointer-events on the video, the clickable creative row
262
+ would swallow clicks on the native playback controls (play/pause/fullscreen). */
261
263
  .creative-content a[download],
262
- .creative-content img {
264
+ .creative-content img,
265
+ .creative-content video {
263
266
  pointer-events: auto;
264
267
  position: relative;
265
268
  z-index: var(--layer-1);
266
269
  }
267
270
 
271
+ .creative-content video {
272
+ max-width: 100%;
273
+ height: auto;
274
+ border-radius: var(--radius-2);
275
+ }
276
+
268
277
  .creative-row:hover .creative-content {
269
278
  cursor: pointer;
270
279
  }
@@ -32,6 +32,17 @@
32
32
  to { opacity: 1; }
33
33
  }
34
34
 
35
+ /* Top-layer dialogs (alert/confirm/prompt rendered as <dialog>.showModal())
36
+ * use the native ::backdrop instead of the .modal-dialog-overlay div, so the
37
+ * backdrop covers everything below — including other top-layer surfaces such
38
+ * as the image lightbox. Only matches real <dialog> elements; the <div>
39
+ * consumers of .modal-dialog never get a ::backdrop. */
40
+ dialog.modal-dialog::backdrop {
41
+ background: rgba(0, 0, 0, 0.5);
42
+ backdrop-filter: blur(4px);
43
+ -webkit-backdrop-filter: blur(4px);
44
+ }
45
+
35
46
  /* ── Panel ── */
36
47
  .modal-dialog {
37
48
  display: none;
@@ -180,6 +191,27 @@ textarea.modal-dialog-input {
180
191
  opacity: 0.9;
181
192
  }
182
193
 
194
+ /* Destructive confirm action (delete/unlink). Matches .btn-danger convention. */
195
+ .modal-dialog-btn-danger {
196
+ background: var(--color-danger);
197
+ color: var(--text-on-badge);
198
+ border-color: var(--color-danger);
199
+ }
200
+
201
+ .modal-dialog-btn-danger:hover {
202
+ opacity: 0.9;
203
+ }
204
+
205
+ /* ── Confirm dialog message ──
206
+ * pre-wrap preserves the \n line breaks that native confirm() honored. */
207
+ .confirm-dialog-message {
208
+ white-space: pre-wrap;
209
+ overflow-wrap: break-word;
210
+ color: var(--text-primary);
211
+ font-size: var(--text-1);
212
+ line-height: var(--leading-3, 1.5);
213
+ }
214
+
183
215
  /* ── Footer hints (keyboard shortcuts) ── */
184
216
  .modal-dialog-hints {
185
217
  display: flex;
@@ -0,0 +1,91 @@
1
+ /*
2
+ * Shared markdown table styling + CSV/Excel download toolbar.
3
+ *
4
+ * Single source of truth so chat-message tables (.comment-content) and creative
5
+ * description tables (.creative-content / .creative-title-content) render
6
+ * identically and both expose the same top-right download buttons. The download
7
+ * toolbar markup is produced by lib/utils/table_download.js.
8
+ *
9
+ * Load this wherever either context renders: the creatives index, the slide
10
+ * layout, and any page that shows the comments popup.
11
+ */
12
+
13
+ /* Table styling */
14
+ .comment-content table,
15
+ .creative-content table,
16
+ .creative-title-content table {
17
+ border-collapse: collapse;
18
+ width: 100%;
19
+ font-size: 0.85em;
20
+ margin: 0;
21
+ /* Override legacy actiontext.css fixed layout so creative tables expand to
22
+ content and horizontally scroll like chat tables (tables.css loads after
23
+ actiontext.css; same specificity, so it must restate this property). */
24
+ table-layout: auto;
25
+ }
26
+
27
+ .comment-content table th,
28
+ .comment-content table td,
29
+ .creative-content table th,
30
+ .creative-content table td,
31
+ .creative-title-content table th,
32
+ .creative-title-content table td {
33
+ border: 1px solid var(--color-border);
34
+ padding: 0.3em 0.6em;
35
+ text-align: left;
36
+ white-space: nowrap;
37
+ /* Clear actiontext.css cell floors so creative cells size to content like
38
+ chat cells instead of forcing a min width / breaking long words. */
39
+ min-width: 0;
40
+ word-break: normal;
41
+ }
42
+
43
+ .comment-content table th,
44
+ .creative-content table th,
45
+ .creative-title-content table th {
46
+ background: var(--color-section-bg);
47
+ font-weight: 600;
48
+ }
49
+
50
+ .comment-content table tr:hover td,
51
+ .creative-content table tr:hover td,
52
+ .creative-title-content table tr:hover td {
53
+ background: color-mix(in srgb, var(--color-section-bg) 40%, transparent);
54
+ }
55
+
56
+ /* Table download wrapper */
57
+ .table-download-wrapper {
58
+ position: relative;
59
+ overflow-x: auto;
60
+ margin: 0.4em 0;
61
+ }
62
+
63
+ .table-download-toolbar {
64
+ display: flex;
65
+ justify-content: flex-end;
66
+ gap: 0.3em;
67
+ padding: 0.15em 0;
68
+ opacity: 0;
69
+ transition: opacity 0.15s ease;
70
+ }
71
+
72
+ .table-download-wrapper:hover .table-download-toolbar {
73
+ opacity: 1;
74
+ }
75
+
76
+ .table-download-btn {
77
+ background: none;
78
+ border: 1px solid var(--color-border);
79
+ border-radius: var(--radius-2);
80
+ color: var(--color-muted);
81
+ font-size: 0.75em;
82
+ padding: 0.15em 0.5em;
83
+ cursor: pointer;
84
+ line-height: 1.4;
85
+ transition: color 0.15s ease, border-color 0.15s ease;
86
+ }
87
+
88
+ .table-download-btn:hover {
89
+ color: var(--color-active);
90
+ border-color: var(--color-active);
91
+ }
@@ -0,0 +1,30 @@
1
+ module Collavre
2
+ # Keeps the global inbox badge in sync without a full page reload.
3
+ #
4
+ # The badge is updated in real time by fire-and-forget Turbo Stream
5
+ # broadcasts on the ["inbox", user] stream (see Comment::Broadcastable).
6
+ # ActionCable does not replay messages missed while a socket is down, so any
7
+ # badge update broadcast during a WebSocket gap (Turbo navigation, sleep/wake,
8
+ # network blip, server restart, the window before the cable connects) was lost
9
+ # until the next full page render — the "badge only shows up after refresh" bug.
10
+ #
11
+ # This channel closes that gap with the established pull-on-subscribe pattern:
12
+ # ActionCable re-runs #subscribed on every (re)connect, so we re-push the
13
+ # authoritative count each time. No client polling, no time window — the server
14
+ # self-heals the count on reconnect.
15
+ class InboxBadgeChannel < ApplicationCable::Channel
16
+ def subscribed
17
+ return reject unless current_user
18
+
19
+ # Deliver the snapshot through THIS subscription (transmit), not by
20
+ # re-broadcasting to the sibling ["inbox", user] Turbo stream. On reconnect
21
+ # the two subscriptions re-attach independently, so a broadcast from here
22
+ # could fire before that stream re-attaches and be dropped — leaving the
23
+ # badge stale. transmit only reaches this just-confirmed subscriber, so the
24
+ # snapshot can never be sent while no client is listening.
25
+ inbox = Creative.inbox_for(current_user)
26
+ snapshot = Comment.inbox_badge_turbo_stream(inbox, current_user)
27
+ transmit(snapshot) if snapshot
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Api
5
+ module V1
6
+ module Mobile
7
+ # The heart of the loop. GET surfaces pending approvals (+ recent agent
8
+ # replies) with a stable spoken ref number and a decision-ready summary.
9
+ # POST :id/respond branches on the referenced event KIND:
10
+ # (A) approval/permission comment → the spoken response is a BUTTON
11
+ # press (allow|deny) → decide_claude_channel_permission! + relay.
12
+ # (B) ordinary agent message → the spoken response is FREE TEXT passed
13
+ # verbatim back to the agent as a reply comment (no server parsing).
14
+ class AgentEventsController < BaseController
15
+ # Spoken decision verbs. The app no longer addresses approvals by
16
+ # ordinal, so a reply to an approval event is just an allow/deny verb;
17
+ # anything else asks for clarification rather than firing a decision.
18
+ APPROVE = /(승인|허용|적용|approve|allow|confirm|네|좋아|오케이|\bok\b|\byes\b)/i
19
+ DENY = /(거절|거부|반려|deny|reject|취소|아니|\bno\b)/i
20
+
21
+ def index
22
+ approvals = pending_approvals
23
+ notices = system_inbox_messages
24
+
25
+ # Every undecided approval is emitted on every poll — the server keeps
26
+ # no per-client cursor. Re-speaking is prevented on the CLIENT (an
27
+ # in-memory "already spoken" set), the same at-least-once shape as
28
+ # notices: a pending approval keeps surfacing until it is decided
29
+ # (which clears it from pending_approvals) or the app restarts (when a
30
+ # still-pending approval SHOULD be re-surfaced). The old `since` cursor
31
+ # filtered here, but a batch high-water-mark could burn past an approval
32
+ # whose created_at trailed a newer notice, losing it forever.
33
+ events = approvals.map do |c|
34
+ {
35
+ id: c.id, type: "approval_requested",
36
+ title: title_for_topic(c.topic_id),
37
+ summary: summarizer.approval_summary(comment: c, label: label_for_topic(c.topic_id)),
38
+ speak: true, requires_response: true, topic_id: c.topic_id,
39
+ created_at: c.created_at.iso8601(6)
40
+ }
41
+ end
42
+
43
+ events += notices.map do |c|
44
+ # The notice stands in for the origin comment it quotes; the app
45
+ # lists/reads it against the ORIGIN thread and replies route there.
46
+ origin_topic_id = c.quoted_comment&.topic_id || c.topic_id
47
+ {
48
+ id: c.id, type: "agent_reply",
49
+ title: title_for_topic(origin_topic_id),
50
+ summary: speakable(c.content),
51
+ speak: true, requires_response: c.quoted_comment_id.present?,
52
+ topic_id: origin_topic_id, created_at: c.created_at.iso8601(6)
53
+ }
54
+ end
55
+
56
+ render json: events.sort_by { |e| e[:created_at] }
57
+ end
58
+
59
+ def respond
60
+ comment = Collavre::Comment.find_by(id: params[:id])
61
+ unless comment && authorized_comment?(comment)
62
+ return render json: { error: "Event not found" }, status: :not_found
63
+ end
64
+
65
+ # Acting on a notice means the user has heard it — clear it from the
66
+ # unread inbox set so it isn't read again on the next poll.
67
+ mark_inbox_read(comment)
68
+
69
+ if comment.claude_channel_permission?
70
+ decide_on(comment)
71
+ else
72
+ relay_free_text(comment)
73
+ end
74
+ end
75
+
76
+ # The app calls this when it finishes reading a message ALOUD (whether or
77
+ # not the user then replies). Reading = hearing it, so the inbox read
78
+ # pointer advances and the notice stops surfacing — the SAME read-state
79
+ # the inbox badge uses. A crash before this call leaves the notice unread,
80
+ # so the next poll re-reads it (at-least-once, not the lossy old cursor).
81
+ def read
82
+ comment = Collavre::Comment.find_by(id: params[:id])
83
+ unless comment && authorized_comment?(comment)
84
+ return render json: { error: "Event not found" }, status: :not_found
85
+ end
86
+
87
+ mark_inbox_read(comment)
88
+ head :ok
89
+ end
90
+
91
+ private
92
+
93
+ # The user's Inbox → System topic is the per-user alarm stream: mentions,
94
+ # agent replies, share notices, … land here as system-authored
95
+ # (user_id: nil) comments that QUOTE the origin comment. This is the
96
+ # canonical, agent-ownership-independent feed the voice loop reads aloud.
97
+ #
98
+ # Emission is gated by the inbox's OWN read-state — the CommentReadPointer
99
+ # (comments with id > last_read_comment_id are unread) that also drives the
100
+ # inbox badge — NOT by the client's `since` cursor. A `since` cursor is a
101
+ # fetch-time high-water-mark: any notice fetched-but-never-spoken (crash,
102
+ # background, busy queue, restart) gets burned past and is lost forever,
103
+ # even though it stays unread in the inbox. Driving off the read pointer
104
+ # makes "unread in the inbox" and "read aloud by the app" the same set; the
105
+ # app marks a notice read (POST :id/read) only after actually speaking it.
106
+ def system_inbox_messages
107
+ inbox = current_user_inbox or return []
108
+ last_read = inbox_read_pointer&.last_read_comment_id || 0
109
+
110
+ scope = Collavre::Comment.where(creative_id: inbox.id, topic_id: inbox.system_topic.id)
111
+ .where("comments.id > ?", last_read)
112
+ .order(:id)
113
+ .limit(50)
114
+ # Approval prompts are surfaced (actionably) by pending_approvals; their
115
+ # System-topic FYI duplicate must not be read a second time.
116
+ scope.reject { |c| c.quoted_comment&.claude_channel_permission? }
117
+ end
118
+
119
+ def inbox_read_pointer
120
+ inbox = current_user_inbox or return nil
121
+
122
+ Collavre::CommentReadPointer.find_by(user: current_user, creative: inbox)
123
+ end
124
+
125
+ # Advance the inbox read pointer to (at least) this notice — forward-only,
126
+ # so a later message is never un-read. Only inbox-creative comments move the
127
+ # inbox pointer; calling this for an approval (which lives in the origin
128
+ # creative and is owned by pending_approvals) is a harmless no-op.
129
+ def mark_inbox_read(comment)
130
+ inbox = current_user_inbox or return
131
+ return unless comment.creative_id == inbox.id
132
+
133
+ pointer = Collavre::CommentReadPointer.find_or_initialize_by(user: current_user, creative: inbox)
134
+ new_id = [ pointer.last_read_comment_id || 0, comment.id ].max
135
+ pointer.update(last_read_comment_id: new_id)
136
+ end
137
+
138
+ def current_user_inbox
139
+ return @current_user_inbox if defined?(@current_user_inbox)
140
+
141
+ @current_user_inbox = Collavre::Creative.inbox_for(current_user)
142
+ end
143
+
144
+ # Read the whole message aloud, but spoken: strip markdown link syntax to
145
+ # its text and collapse whitespace so TTS doesn't read raw URLs.
146
+ def speakable(text)
147
+ text.to_s.gsub(/\[([^\]]+)\]\([^)]*\)/, '\1').gsub(/\s+/, " ").strip
148
+ end
149
+
150
+ def authorized_comment?(comment)
151
+ return true if comment.approver_id == current_user.id
152
+ return true if own_inbox_notice?(comment)
153
+
154
+ author = comment.user
155
+ author.respond_to?(:created_by_id) && agent_ids.include?(author.id)
156
+ end
157
+
158
+ # A system-authored notice in the caller's own Inbox#System topic. Owning
159
+ # the inbox is the authorization proof (the notice has no approver/author).
160
+ def own_inbox_notice?(comment)
161
+ inbox = current_user_inbox or return false
162
+
163
+ comment.user_id.nil? &&
164
+ comment.creative_id == inbox.id &&
165
+ comment.topic_id == inbox.system_topic.id
166
+ end
167
+
168
+ # (A) bounded decision: spoken response IS the approve/deny button.
169
+ def decide_on(comment)
170
+ behavior = behavior_from(params[:response])
171
+
172
+ unless %w[allow deny].include?(behavior)
173
+ return render_speak(:clarify_decision, action: { type: "needs_clarification", comment_id: comment.id })
174
+ end
175
+
176
+ case decide_permission(comment, behavior)
177
+ when :unauthorized
178
+ render_speak(:not_authorized, action: { type: "not_authorized", comment_id: comment.id }, status: :forbidden)
179
+ when :already_decided
180
+ render_speak(:already_decided, action: { type: "already_decided", comment_id: comment.id })
181
+ else
182
+ render_speak(
183
+ behavior == "allow" ? :approved : :denied,
184
+ action: { type: behavior == "allow" ? "approved" : "denied", comment_id: comment.id }
185
+ )
186
+ end
187
+ end
188
+
189
+ # (B) free-form: relay the utterance verbatim as a human reply.
190
+ def relay_free_text(comment)
191
+ text = params[:response].to_s.strip
192
+ return render_speak(:empty_response, action: { type: "noop" }) if text.blank?
193
+
194
+ # A System-inbox notice is a stand-in for the origin comment it quotes;
195
+ # the reply must land on that origin thread (the System topic itself
196
+ # never dispatches AI), not on the notice.
197
+ target = comment.quoted_comment || comment
198
+ topic = target.topic
199
+ creative = topic&.creative&.effective_origin || target.creative
200
+ # quoted_comment threads the reply to the message being answered, but
201
+ # review_type: :question keeps review_message? false — otherwise the
202
+ # agent's response would update the quoted comment IN PLACE (review flow)
203
+ # instead of posting a new reply, so no new comment is created and the
204
+ # Inbox#System alarm never fires. Same guard as InboxReplyService (#1301).
205
+ reply = creative.comments.create!(
206
+ content: text, user: current_user, topic: topic,
207
+ quoted_comment: target, review_type: :question
208
+ )
209
+
210
+ render_speak(:relayed, action: { type: "relayed", comment_id: reply.id, topic_id: topic&.id })
211
+ end
212
+
213
+ def behavior_from(response)
214
+ text = response.to_s.strip
215
+ return "allow" if text.match?(APPROVE)
216
+ return "deny" if text.match?(DENY)
217
+
218
+ nil
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end