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