collavre 0.24.0 → 0.25.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 +0 -1
- data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
- data/app/assets/stylesheets/collavre/creatives.css +346 -37
- data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
- data/app/assets/stylesheets/collavre/gnb.css +64 -3
- data/app/assets/stylesheets/collavre/landing.css +108 -3
- data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
- data/app/assets/stylesheets/collavre/popup.css +14 -0
- data/app/assets/stylesheets/collavre/print.css +0 -1
- data/app/assets/stylesheets/collavre/search_popup.css +1 -1
- data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
- data/app/assets/stylesheets/collavre/workspace.css +307 -0
- data/app/channels/collavre/agent_channel.rb +14 -21
- data/app/channels/collavre/comments_presence_channel.rb +181 -21
- data/app/channels/collavre/inbox_badge_channel.rb +7 -1
- data/app/components/collavre/inbox/badge_component.rb +6 -13
- data/app/components/collavre/progress_filter_component.html.erb +2 -2
- data/app/components/collavre/progress_filter_component.rb +20 -0
- data/app/controllers/collavre/admin/settings_controller.rb +4 -1
- data/app/controllers/collavre/agent_connections_controller.rb +165 -0
- data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
- data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
- data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
- data/app/controllers/collavre/application_controller.rb +13 -2
- data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
- data/app/controllers/collavre/comments_controller.rb +45 -131
- data/app/controllers/collavre/concerns/shareable.rb +9 -3
- data/app/controllers/collavre/creatives_controller.rb +144 -11
- data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
- data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
- data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
- data/app/controllers/collavre/features_controller.rb +57 -0
- data/app/controllers/collavre/invites_controller.rb +12 -1
- data/app/controllers/collavre/llm_models_controller.rb +8 -0
- data/app/controllers/collavre/notices_controller.rb +20 -0
- data/app/controllers/collavre/tasks_controller.rb +40 -6
- data/app/controllers/collavre/topics_controller.rb +130 -53
- data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
- data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
- data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
- data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
- data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
- data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
- data/app/errors/collavre/turn_deadline_error.rb +20 -0
- data/app/helpers/collavre/application_helper.rb +38 -0
- data/app/helpers/collavre/creatives_helper.rb +56 -22
- data/app/helpers/collavre/navigation_helper.rb +12 -0
- data/app/javascript/collavre.js +1 -2
- data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
- data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
- data/app/javascript/components/creative_tree_row.js +62 -22
- data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
- data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
- data/app/javascript/controllers/__tests__/index.test.js +42 -0
- data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
- data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
- data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
- data/app/javascript/controllers/agent_connection_controller.js +357 -0
- data/app/javascript/controllers/agent_vendor_controller.js +23 -0
- data/app/javascript/controllers/comment_controller.js +3 -1
- data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
- data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
- data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
- data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
- data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
- data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
- data/app/javascript/controllers/comments/form_controller.js +795 -14
- data/app/javascript/controllers/comments/list_controller.js +338 -52
- data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
- data/app/javascript/controllers/comments/popup_controller.js +425 -83
- data/app/javascript/controllers/comments/presence_controller.js +317 -55
- data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
- data/app/javascript/controllers/comments/topics_controller.js +1772 -100
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
- data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
- data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
- data/app/javascript/controllers/creatives/tree_controller.js +140 -31
- data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
- data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
- data/app/javascript/controllers/gateway_check_controller.js +36 -0
- data/app/javascript/controllers/index.js +25 -1
- data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
- data/app/javascript/controllers/link_creative_controller.js +89 -18
- data/app/javascript/controllers/progress_filter_controller.js +12 -22
- data/app/javascript/controllers/search_popup_controller.js +64 -46
- data/app/javascript/controllers/share_user_search_controller.js +2 -2
- data/app/javascript/controllers/topic_list_controller.js +44 -7
- data/app/javascript/controllers/workspace_tree_controller.js +662 -0
- data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
- data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
- data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
- data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
- data/app/javascript/creatives/tree_renderer.js +65 -22
- data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
- data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
- data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
- data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
- data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
- data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
- data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
- data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
- data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
- data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
- data/app/javascript/lib/api/creatives.js +32 -13
- data/app/javascript/lib/api/csrf_fetch.js +6 -4
- data/app/javascript/lib/api/topics.js +63 -5
- data/app/javascript/lib/chat_drafts.js +786 -0
- data/app/javascript/lib/common_popup.js +164 -25
- data/app/javascript/lib/creative_link_prefetch.js +27 -0
- data/app/javascript/lib/last_visited_creative.js +97 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
- data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
- data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
- data/app/javascript/lib/lexical/creative_link_node.js +101 -0
- data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
- data/app/javascript/lib/lexical/link_toolbar.js +23 -0
- data/app/javascript/lib/turbo_stream_actions.js +23 -16
- data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
- data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
- data/app/javascript/lib/utils/filter_navigation.js +100 -0
- data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
- data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
- data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
- data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
- data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
- data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
- data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
- data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
- data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
- data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
- data/app/javascript/modules/command_args_form.js +0 -12
- data/app/javascript/modules/command_menu.js +80 -38
- data/app/javascript/modules/creative_link_picker.js +56 -0
- data/app/javascript/modules/creative_row_editor.js +446 -243
- data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
- data/app/javascript/modules/creative_tree_dom.js +4 -0
- data/app/javascript/modules/creative_tree_empty_state.js +80 -0
- data/app/javascript/modules/dom_listener_registry.js +23 -0
- data/app/javascript/modules/mention_menu.js +2 -3
- data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
- data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
- data/app/javascript/utils/caret_position.js +10 -0
- data/app/javascript/utils/chevron_icons.js +7 -0
- data/app/jobs/collavre/ai_agent_job.rb +262 -72
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
- data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
- data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
- data/app/jobs/collavre/comment_notification_job.rb +14 -0
- data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
- data/app/jobs/collavre/compress_job.rb +33 -22
- data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
- data/app/jobs/collavre/cron_action_job.rb +36 -18
- data/app/jobs/collavre/drop_trigger_job.rb +23 -9
- data/app/jobs/collavre/merge_comments_job.rb +33 -16
- data/app/jobs/collavre/push_notification_job.rb +62 -18
- data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
- data/app/jobs/collavre/stuck_detector_job.rb +1 -1
- data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
- data/app/models/collavre/agent_gateway.rb +200 -0
- data/app/models/collavre/agent_subscription.rb +4 -4
- data/app/models/collavre/agent_workspace.rb +210 -0
- data/app/models/collavre/comment/broadcastable.rb +22 -52
- data/app/models/collavre/comment/notifiable.rb +154 -77
- data/app/models/collavre/comment/topic_membership.rb +41 -0
- data/app/models/collavre/comment.rb +336 -31
- data/app/models/collavre/comment_notification_delivery.rb +61 -0
- data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
- data/app/models/collavre/comment_presence_store.rb +272 -12
- data/app/models/collavre/comment_read_pointer.rb +9 -15
- data/app/models/collavre/creative/describable.rb +8 -2
- data/app/models/collavre/creative/permissible.rb +11 -0
- data/app/models/collavre/creative.rb +29 -4
- data/app/models/collavre/creative_share.rb +109 -4
- data/app/models/collavre/current.rb +1 -0
- data/app/models/collavre/llm_model.rb +62 -0
- data/app/models/collavre/system_setting.rb +12 -1
- data/app/models/collavre/task.rb +121 -2
- data/app/models/collavre/topic.rb +51 -0
- data/app/models/collavre/user.rb +101 -41
- data/app/models/collavre/user_creative_preference.rb +3 -1
- data/app/services/collavre/agent_provisioning/archive.rb +108 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
- data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
- data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
- data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
- data/app/services/collavre/ai_agent/message_builder.rb +103 -16
- data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
- data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
- data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
- data/app/services/collavre/ai_agent_service.rb +108 -20
- data/app/services/collavre/ai_client.rb +211 -21
- data/app/services/collavre/cli_proxy/client.rb +147 -0
- data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
- data/app/services/collavre/cli_proxy/identity.rb +47 -0
- data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
- data/app/services/collavre/command_menu_service.rb +0 -10
- data/app/services/collavre/comment_move_service.rb +96 -7
- data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
- data/app/services/collavre/comments/action_executor.rb +48 -20
- data/app/services/collavre/comments/command_processor.rb +0 -1
- data/app/services/collavre/comments/list_response.rb +133 -0
- data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
- data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
- data/app/services/collavre/comments/read_receipt_index.rb +152 -0
- data/app/services/collavre/comments/topic_command.rb +72 -1
- data/app/services/collavre/comments/topic_mutation.rb +20 -0
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
- data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
- data/app/services/collavre/creatives/reorderer.rb +15 -3
- data/app/services/collavre/creatives/tree_builder.rb +1 -1
- data/app/services/collavre/creatives/tree_formatter.rb +5 -6
- data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
- data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
- data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
- data/app/services/collavre/home_page_path.rb +55 -0
- data/app/services/collavre/html_text.rb +64 -0
- data/app/services/collavre/http_client.rb +11 -2
- data/app/services/collavre/inbox_reply_service.rb +1 -1
- data/app/services/collavre/markdown_converter.rb +13 -1
- data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
- data/app/services/collavre/orchestration/arbiter.rb +48 -5
- data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
- data/app/services/collavre/orchestration/delivery_record.rb +792 -0
- data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
- data/app/services/collavre/orchestration/matcher.rb +132 -17
- data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
- data/app/services/collavre/orchestration/scheduler.rb +10 -4
- data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
- data/app/services/collavre/orchestration/selection.rb +43 -0
- data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
- data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
- data/app/services/collavre/orchestration/topic_slot.rb +73 -0
- data/app/services/collavre/system_events/context_builder.rb +60 -11
- data/app/services/collavre/system_events/dispatcher.rb +58 -12
- data/app/services/collavre/system_events/envelope.rb +73 -0
- data/app/services/collavre/system_events/vocabulary.rb +45 -0
- data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
- data/app/services/collavre/tools/cron_create_service.rb +32 -18
- data/app/services/collavre/tools/id_list.rb +38 -0
- data/app/services/collavre/tools/topic_authorizer.rb +61 -7
- data/app/services/collavre/tools/topic_branch_service.rb +85 -0
- data/app/services/collavre/tools/topic_create_service.rb +117 -0
- data/app/services/collavre/tools/topic_list_service.rb +101 -0
- data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
- data/app/services/collavre/tools/topic_messages_service.rb +346 -0
- data/app/services/collavre/tools/topic_move_service.rb +108 -0
- data/app/services/collavre/tools/topic_selection.rb +59 -0
- data/app/services/collavre/tools/topic_update_service.rb +165 -0
- data/app/services/collavre/topic_branch_service.rb +39 -10
- data/app/services/collavre/topics/agent_resolver.rb +87 -0
- data/app/services/collavre/topics/char_budget.rb +70 -0
- data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
- data/app/services/collavre/topics/message_page.rb +380 -0
- data/app/services/collavre/topics/message_page_markdown.rb +102 -0
- data/app/services/collavre/topics/message_scope.rb +61 -0
- data/app/services/collavre/topics/message_stats.rb +92 -0
- data/app/services/collavre/topics/move_blocker.rb +47 -0
- data/app/services/collavre/topics/next_name.rb +35 -0
- data/app/services/collavre/topics/pin_rejection.rb +25 -0
- data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
- data/app/services/collavre/topics/reserved_name.rb +18 -0
- data/app/services/collavre/topics/serializer.rb +92 -0
- data/app/services/collavre/topics/topic_destroy.rb +30 -0
- data/app/services/collavre/topics/topic_move.rb +80 -0
- data/app/services/collavre/topics/topic_move_effects.rb +35 -0
- data/app/services/collavre/topics/topic_mutation.rb +27 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
- data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
- data/app/views/collavre/agent_connections/show.html.erb +77 -0
- data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
- data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
- data/app/views/collavre/agent_gateways/index.html.erb +54 -0
- data/app/views/collavre/agent_gateways/new.html.erb +2 -0
- data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
- data/app/views/collavre/comments/_empty_state.html.erb +33 -0
- data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
- data/app/views/collavre/comments/_feature_card.html.erb +35 -0
- data/app/views/collavre/comments/_list.html.erb +3 -2
- data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
- data/app/views/collavre/creatives/_add_button.html.erb +17 -19
- data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
- data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
- data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
- data/app/views/collavre/creatives/index.html.erb +79 -18
- data/app/views/collavre/desktop_setup/show.html.erb +114 -0
- data/app/views/collavre/features/index.html.erb +42 -0
- data/app/views/collavre/features/show.html.erb +52 -0
- data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
- data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
- data/app/views/collavre/invites/show.html.erb +14 -1
- data/app/views/collavre/landing/show.html.erb +5 -1
- data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
- data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
- data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
- data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
- data/app/views/collavre/user_themes/index.html.erb +1 -1
- data/app/views/collavre/users/_llm_fields.html.erb +65 -0
- data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
- data/app/views/collavre/users/edit_ai.html.erb +33 -31
- data/app/views/collavre/users/new.html.erb +4 -2
- data/app/views/collavre/users/new_ai.html.erb +14 -32
- data/app/views/collavre/users/show.html.erb +13 -1
- data/app/views/layouts/collavre/landing.html.erb +9 -4
- data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
- data/config/locales/admin.en.yml +3 -1
- data/config/locales/admin.ko.yml +3 -1
- data/config/locales/agent_gateway.en.yml +217 -0
- data/config/locales/agent_gateway.ko.yml +217 -0
- data/config/locales/ai_agent.en.yml +0 -1
- data/config/locales/ai_agent.ko.yml +0 -1
- data/config/locales/comments.en.yml +72 -30
- data/config/locales/comments.ko.yml +72 -30
- data/config/locales/creatives.en.yml +32 -1
- data/config/locales/creatives.ko.yml +32 -1
- data/config/locales/features.en.yml +140 -0
- data/config/locales/features.ko.yml +140 -0
- data/config/locales/invites.en.yml +3 -0
- data/config/locales/invites.ko.yml +3 -0
- data/config/locales/landing.en.yml +2 -1
- data/config/locales/landing.ko.yml +2 -1
- data/config/locales/tools.en.yml +20 -0
- data/config/locales/tools.ko.yml +20 -0
- data/config/locales/users.en.yml +15 -14
- data/config/locales/users.ko.yml +14 -14
- data/config/routes.rb +45 -19
- data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
- data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
- data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
- data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
- data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
- data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
- data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
- data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
- data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
- data/db/migrate/20260809000001_create_llm_models.rb +64 -0
- data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
- data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
- data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
- data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
- data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
- data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
- data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
- data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
- data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
- data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
- data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
- data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
- data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
- data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
- data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
- data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
- data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
- data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
- data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
- data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
- data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
- data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
- data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
- data/db/seeds.rb +1 -51
- data/lib/collavre/engine.rb +108 -1
- data/lib/collavre/feature_card_registry.rb +168 -0
- data/lib/collavre/hashed_access_token_lookup.rb +22 -0
- data/lib/collavre/sensitive_request_silencer.rb +23 -0
- data/lib/collavre/user_extensions.rb +0 -2
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +1 -0
- data/skills/collavre/SKILL.md +165 -0
- data/skills/collavre/package.json +8 -0
- data/skills/collavre/references/tool-reference.md +347 -0
- data/skills/collavre/scripts/collavre +634 -0
- data/skills/collavre/scripts/html_entities.js +24 -0
- metadata +228 -30
- data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
- data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
- data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
- data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
- data/app/controllers/collavre/inbox_items_controller.rb +0 -70
- data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
- data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
- data/app/javascript/lib/typo_correction.js +0 -146
- data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
- data/app/javascript/modules/creative_guide.js +0 -67
- data/app/javascript/modules/typo_corrector.js +0 -534
- data/app/jobs/collavre/inbox_summary_job.rb +0 -24
- data/app/mailers/collavre/inbox_mailer.rb +0 -19
- data/app/models/collavre/inbox_item.rb +0 -89
- data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
- data/app/services/collavre/comments/work_command.rb +0 -161
- data/app/services/collavre/comments/workflow_executor.rb +0 -276
- data/app/services/collavre/gemini_parent_recommender.rb +0 -77
- data/app/services/collavre/mobile/event_summarizer.rb +0 -40
- data/app/services/collavre/typo_corrector.rb +0 -188
- data/app/views/collavre/inbox_items/_item.html.erb +0 -18
- data/app/views/collavre/inbox_items/_items.html.erb +0 -3
- data/app/views/collavre/inbox_items/_list.html.erb +0 -7
- data/app/views/collavre/inbox_items/index.html.erb +0 -1
- data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
- data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
- data/app/views/collavre/users/typo_correction.html.erb +0 -50
- data/config/locales/mobile.en.yml +0 -16
- data/config/locales/mobile.ko.yml +0 -16
|
@@ -21,7 +21,7 @@ module Collavre
|
|
|
21
21
|
|
|
22
22
|
attr_reader :comment, :user, :creative, :url_helpers
|
|
23
23
|
|
|
24
|
-
# /topic "topic name" @agent_name
|
|
24
|
+
# /topic "topic name" @agent_name:
|
|
25
25
|
# /topic "topic name"
|
|
26
26
|
COMMAND_PATTERN = /\A\/topic\b/i.freeze
|
|
27
27
|
|
|
@@ -53,12 +53,61 @@ module Collavre
|
|
|
53
53
|
# Find existing topic or create new one
|
|
54
54
|
existing_topic = Topic.find_by(creative: creative, name: data[:name])
|
|
55
55
|
|
|
56
|
+
if existing_topic&.session_id.present?
|
|
57
|
+
# A Claude Channel session topic carries its agent as session identity,
|
|
58
|
+
# not as a routing pin (see TopicsController#set_primary_agent). Neither
|
|
59
|
+
# reassigning nor releasing it is safe from a chat command.
|
|
60
|
+
return I18n.t("collavre.comments.topic_command.session_topic_locked",
|
|
61
|
+
name: existing_topic.name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# User.mentionable_for resolves every searchable agent — and, on an
|
|
65
|
+
# inbox, every agent holding a share there, which includes the user's own
|
|
66
|
+
# Claude Channel session agent. Pinning an agent Matcher will not route
|
|
67
|
+
# to silences the topic outright: the pin is exclusive
|
|
68
|
+
# (Matcher#match_by_primary_agent suppresses the other agents' ambient
|
|
69
|
+
# routing) while the pinned agent itself is filtered out and cannot
|
|
70
|
+
# answer either. Resolved against the topic being targeted, so the check
|
|
71
|
+
# is the same mirror of Matcher the REST paths apply.
|
|
72
|
+
rejection = primary_agent &&
|
|
73
|
+
Topic.primary_agent_rejection(creative, primary_agent, topic: existing_topic)
|
|
74
|
+
if rejection
|
|
75
|
+
key = if rejection == :session_agent_outside_session_topic
|
|
76
|
+
"collavre.comments.topic_command.session_agent_not_assignable"
|
|
77
|
+
else
|
|
78
|
+
"collavre.comments.topic_command.agent_no_creative_access"
|
|
79
|
+
end
|
|
80
|
+
return I18n.t(key, agent: primary_agent.display_name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Assigning or releasing a primary agent rewrites the topic's routing:
|
|
84
|
+
# the pin decides who may speak here and silences every other agent. The
|
|
85
|
+
# REST equivalents (TopicsController#set_primary_agent and #create, which
|
|
86
|
+
# also accepts agent_id) require :write, but CommentsController#create
|
|
87
|
+
# only authorizes the comment at :feedback, so the command has to apply
|
|
88
|
+
# the same gate itself — otherwise commenting access would be enough to
|
|
89
|
+
# decide a topic's routing. Checked before the new/existing split because
|
|
90
|
+
# `/topic "new name" @agent:` persists an exclusive assignment just as
|
|
91
|
+
# much as moving one on a topic that already exists.
|
|
92
|
+
if assigns_primary_agent?(existing_topic, primary_agent) &&
|
|
93
|
+
!creative.has_permission?(user, :write)
|
|
94
|
+
return I18n.t("collavre.comments.topic_command.not_authorized")
|
|
95
|
+
end
|
|
96
|
+
|
|
56
97
|
if existing_topic
|
|
57
98
|
if primary_agent
|
|
58
99
|
set_primary_agent(existing_topic, primary_agent)
|
|
100
|
+
broadcast_topic_agent_updated(existing_topic, primary_agent)
|
|
59
101
|
I18n.t("collavre.comments.topic_command.updated_agent",
|
|
60
102
|
name: existing_topic.name,
|
|
61
103
|
agent: primary_agent.name)
|
|
104
|
+
elsif existing_topic.primary_agent_id
|
|
105
|
+
# /topic "name" with no @mention on an already-assigned topic releases
|
|
106
|
+
# the assignment — the keyboard counterpart of clicking the avatar off.
|
|
107
|
+
set_primary_agent(existing_topic, nil)
|
|
108
|
+
broadcast_topic_agent_updated(existing_topic, nil)
|
|
109
|
+
I18n.t("collavre.comments.topic_command.cleared_agent",
|
|
110
|
+
name: existing_topic.name)
|
|
62
111
|
else
|
|
63
112
|
I18n.t("collavre.comments.topic_command.already_exists",
|
|
64
113
|
name: existing_topic.name)
|
|
@@ -95,6 +144,17 @@ module Collavre
|
|
|
95
144
|
TopicsChannel.broadcast_to(creative, data)
|
|
96
145
|
end
|
|
97
146
|
|
|
147
|
+
# Push an assignment change on an existing topic to every connected client.
|
|
148
|
+
# :primary_agent is always present (nil when released) so the merge on the
|
|
149
|
+
# client can actually remove the avatar rather than keep a stale one.
|
|
150
|
+
def broadcast_topic_agent_updated(topic, agent)
|
|
151
|
+
data = { action: "updated", topic: topic.slice(:id, :name), user_id: user.id }
|
|
152
|
+
data[:topic][:primary_agent] = if agent
|
|
153
|
+
{ id: agent.id, name: agent.display_name, avatar_url: resolve_avatar_url(agent) }
|
|
154
|
+
end
|
|
155
|
+
TopicsChannel.broadcast_to(creative, data)
|
|
156
|
+
end
|
|
157
|
+
|
|
98
158
|
def resolve_avatar_url(agent)
|
|
99
159
|
if agent.avatar.attached?
|
|
100
160
|
Rails.application.routes.url_helpers.rails_blob_url(
|
|
@@ -110,6 +170,17 @@ module Collavre
|
|
|
110
170
|
def set_primary_agent(topic, agent)
|
|
111
171
|
topic.set_primary_agent!(agent)
|
|
112
172
|
end
|
|
173
|
+
|
|
174
|
+
# True when the command would write primary_agent_id: a mention assigns
|
|
175
|
+
# (or moves) the pin on either a new or an existing topic, and a bare
|
|
176
|
+
# /topic on an already assigned topic releases it. `/topic "name"` with no
|
|
177
|
+
# mention writes no assignment — on an unassigned existing topic it only
|
|
178
|
+
# reports that it already exists, and on a new one it creates a topic with
|
|
179
|
+
# ambient routing intact — so both stay open to commenters, matching the
|
|
180
|
+
# pre-existing rule that /topic can create topics at :feedback.
|
|
181
|
+
def assigns_primary_agent?(topic, primary_agent)
|
|
182
|
+
primary_agent.present? || topic&.primary_agent_id.present?
|
|
183
|
+
end
|
|
113
184
|
end
|
|
114
185
|
end
|
|
115
186
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Collavre
|
|
4
|
+
module Comments
|
|
5
|
+
module TopicMutation
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def call(topic_id, creative_id)
|
|
9
|
+
applied = false
|
|
10
|
+
ActiveRecord::Base.transaction do
|
|
11
|
+
next unless Orchestration::TopicSlot.lock_matches_context?(topic_id, creative_id)
|
|
12
|
+
|
|
13
|
+
yield
|
|
14
|
+
applied = true
|
|
15
|
+
end
|
|
16
|
+
applied
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -1,30 +1,66 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
module Creatives
|
|
3
|
-
#
|
|
4
|
-
# read-pointer lookup and an unread COUNT —
|
|
5
|
-
#
|
|
3
|
+
# Resolves the unread and visible comment counts used by every comment badge.
|
|
4
|
+
# Per node this was two queries — a read-pointer lookup and an unread COUNT —
|
|
5
|
+
# which is what made the browse-tree badge cost scale with the rendered tree.
|
|
6
6
|
#
|
|
7
7
|
# Keyed by *origin*: a linked shell shows its origin's comments.
|
|
8
|
+
#
|
|
9
|
+
# The counts it hands out are display-ready: they include only comments the
|
|
10
|
+
# user can see and apply presence suppression in one cache round trip.
|
|
8
11
|
class CommentBadgeIndex
|
|
12
|
+
# SQLite permits at most 1,000 expression nodes and commonly only 999 bound
|
|
13
|
+
# values. Keep the largest badge query well below both limits.
|
|
14
|
+
QUERY_BATCH_SIZE = 500
|
|
15
|
+
|
|
16
|
+
Badge = Struct.new(:unread_count, :visible_comments, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
# The comment-created fanout has one origin and many recipients. Resolve
|
|
19
|
+
# each topic watermark with database COUNTs instead of materialising the
|
|
20
|
+
# comment history in Ruby. Joining every recipient to public history made
|
|
21
|
+
# one broadcast materialise recipients × comments rows before grouping.
|
|
22
|
+
def self.for_users(origin:, users:)
|
|
23
|
+
recipients = users.compact.uniq(&:id)
|
|
24
|
+
return {} if recipients.empty?
|
|
25
|
+
|
|
26
|
+
counts_by_user_id = counts_for_users(origin, recipients.map(&:id))
|
|
27
|
+
present_user_ids = CommentPresenceStore.list(origin.id)
|
|
28
|
+
viewing_topics_by_user_id = CommentPresenceStore.viewing_topics_for(origin.id, present_user_ids)
|
|
29
|
+
recipients.to_h do |user|
|
|
30
|
+
counts_by_topic = counts_by_user_id.fetch(user.id, {})
|
|
31
|
+
viewing_topics = viewing_topics_by_user_id.fetch(user.id, [])
|
|
32
|
+
unread_count = counts_by_topic.sum do |topic_id, counts|
|
|
33
|
+
topic_is_viewed?(topic_id, viewing_topics) ? 0 : counts.fetch(:unread)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
[ user.id, Badge.new(
|
|
37
|
+
unread_count: unread_count,
|
|
38
|
+
visible_comments: counts_by_topic.values.sum { |counts| counts.fetch(:visible) }.positive?
|
|
39
|
+
) ]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
9
43
|
def initialize(user:)
|
|
10
44
|
@user = user
|
|
11
45
|
@unread_by_origin_id = {}
|
|
46
|
+
@visible_by_origin_id = {}
|
|
12
47
|
end
|
|
13
48
|
|
|
14
|
-
|
|
49
|
+
# Tree rendering only needs unread totals. Callers that render a standalone
|
|
50
|
+
# badge also need visible-comment state to decide whether a zero is shown.
|
|
51
|
+
def index(origins, include_visible_counts: true)
|
|
15
52
|
pending = origins.uniq(&:id).reject { |o| @unread_by_origin_id.key?(o.id) }
|
|
16
53
|
return if pending.empty?
|
|
17
54
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@unread_by_origin_id[origin.id] = origin.comments_count unless watermarks.key?(origin.id)
|
|
55
|
+
origin_ids = pending.map(&:id)
|
|
56
|
+
watermarks = read_watermarks(origin_ids)
|
|
57
|
+
unread_counts = unread_counts(origin_ids, watermarks)
|
|
58
|
+
origin_ids.each do |origin_id|
|
|
59
|
+
@unread_by_origin_id[origin_id] = unread_counts.fetch(origin_id, 0)
|
|
24
60
|
end
|
|
61
|
+
@visible_by_origin_id.merge!(visible_counts(origin_ids)) if include_visible_counts
|
|
25
62
|
|
|
26
|
-
|
|
27
|
-
watermarks.each_key { |origin_id| @unread_by_origin_id[origin_id] = counts.fetch(origin_id, 0) }
|
|
63
|
+
suppress_for_present_user(pending.map(&:id))
|
|
28
64
|
end
|
|
29
65
|
|
|
30
66
|
# nil when the origin was never indexed, which tells the caller to fall back
|
|
@@ -33,40 +69,276 @@ module Creatives
|
|
|
33
69
|
@unread_by_origin_id[origin.id]
|
|
34
70
|
end
|
|
35
71
|
|
|
72
|
+
def visible_comments?(origin)
|
|
73
|
+
@visible_by_origin_id[origin.id].to_i.positive?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The topic strip needs the same display-ready unread state as the creative
|
|
77
|
+
# badge, split by topic. A topic watermark wins; a legacy creative-wide
|
|
78
|
+
# watermark is only the fallback for a topic without its own row.
|
|
79
|
+
def unread_counts_by_topic(origin)
|
|
80
|
+
counts = unread_counts_by_topic_without_presence(origin)
|
|
81
|
+
suppress_viewing_topics!(counts, origin) if user
|
|
82
|
+
counts
|
|
83
|
+
end
|
|
84
|
+
|
|
36
85
|
private
|
|
37
86
|
|
|
38
87
|
attr_reader :user
|
|
39
88
|
|
|
89
|
+
# One read_multi for the whole level. An anonymous visitor is never present.
|
|
90
|
+
def suppress_for_present_user(origin_ids)
|
|
91
|
+
return unless user
|
|
92
|
+
|
|
93
|
+
present_origin_ids = CommentPresenceStore.list_many(origin_ids).filter_map do |origin_id, present_user_ids|
|
|
94
|
+
origin_id if present_user_ids.include?(user.id)
|
|
95
|
+
end
|
|
96
|
+
viewing_topics_by_origin_id = CommentPresenceStore.viewing_topics_for_creatives(user.id, present_origin_ids)
|
|
97
|
+
|
|
98
|
+
present_origin_ids.each do |origin_id|
|
|
99
|
+
origin = Creative.find_by(id: origin_id)
|
|
100
|
+
next unless origin
|
|
101
|
+
|
|
102
|
+
all_counts = unread_counts_by_topic_without_presence(origin)
|
|
103
|
+
remaining_counts = all_counts.dup
|
|
104
|
+
suppress_viewing_topics!(remaining_counts, origin, viewing_topics: viewing_topics_by_origin_id.fetch(origin_id, []))
|
|
105
|
+
@unread_by_origin_id[origin_id] -= all_counts.values.sum - remaining_counts.values.sum
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def suppress_viewing_topics!(counts, origin, viewing_topics: CommentPresenceStore.viewing_topics(origin.id, user.id))
|
|
110
|
+
if viewing_topics.include?(CommentPresenceStore::ALL_TOPICS)
|
|
111
|
+
# All Messages renders Main and the active-topic snapshot returned with
|
|
112
|
+
# its list. A topic restored after that response stays unread until the
|
|
113
|
+
# list is reloaded and reports it in a new snapshot.
|
|
114
|
+
counts.delete(nil) if viewing_topics.include?(CommentPresenceStore::LEGACY_TOPIC)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Another subscription can be viewing an archived topic while this one is
|
|
118
|
+
# on All Messages. Suppress every explicitly rendered topic as well.
|
|
119
|
+
viewing_topics.excluding(CommentPresenceStore::ALL_TOPICS).each { |topic_id| counts.delete(topic_id) }
|
|
120
|
+
end
|
|
121
|
+
|
|
40
122
|
# `user_id: nil` for an anonymous visitor is the lookup the un-batched path
|
|
41
123
|
# made too, so the two agree on which pointer (if any) applies.
|
|
42
124
|
def read_watermarks(origin_ids)
|
|
43
125
|
return {} if origin_ids.empty?
|
|
44
126
|
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
127
|
+
origin_ids.each_slice(QUERY_BATCH_SIZE).each_with_object({}) do |origin_id_batch, watermarks|
|
|
128
|
+
watermarks.merge!(CommentReadPointer
|
|
129
|
+
.where(user_id: user&.id, creative_id: origin_id_batch)
|
|
130
|
+
.pluck(:creative_id, :topic_id, :last_read_comment_id)
|
|
131
|
+
.each_with_object({}) do |(creative_id, topic_id, last_read_id), rows|
|
|
132
|
+
(rows[creative_id] ||= {})[topic_id] = last_read_id || 0
|
|
133
|
+
end)
|
|
134
|
+
end
|
|
50
135
|
end
|
|
51
136
|
|
|
52
137
|
# One grouped COUNT for the whole level. Each origin carries its own read
|
|
53
138
|
# watermark, so the thresholds are OR-ed together rather than shared. Built
|
|
54
139
|
# from relations rather than a SQL fragment: a hand-built string here would
|
|
55
140
|
# be safe (literal template, bound values) but unprovably so to a scanner.
|
|
56
|
-
def unread_counts(watermarks)
|
|
57
|
-
|
|
141
|
+
def unread_counts(origin_ids, watermarks)
|
|
142
|
+
origin_ids.each_slice(QUERY_BATCH_SIZE).each_with_object({}) do |origin_id_batch, counts|
|
|
143
|
+
counts.merge!(unread_counts_for_batch(origin_id_batch, watermarks))
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def unread_counts_for_batch(origin_ids, watermarks)
|
|
148
|
+
scopes = origin_ids.map { |origin_id| unread_scope(origin_id, watermarks.fetch(origin_id, {})) }
|
|
149
|
+
visible_comments.merge(scopes.reduce { |combined, scope| combined.or(scope) }).group(:creative_id).count
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def unread_scope(origin_id, watermarks)
|
|
153
|
+
legacy_watermark = watermarks.fetch(nil, 0)
|
|
154
|
+
topic_watermarks = watermarks.except(nil)
|
|
155
|
+
return Comment.where(creative_id: origin_id).where(Comment.arel_table[:id].gt(legacy_watermark)) if topic_watermarks.empty?
|
|
156
|
+
|
|
157
|
+
comment_table = Comment.arel_table
|
|
158
|
+
topic_ids = topic_watermarks.keys
|
|
159
|
+
fallback_scope = Comment.where(creative_id: origin_id).where(comment_table[:id].gt(legacy_watermark))
|
|
160
|
+
fallback_scope = fallback_scope.where(topic_id: nil).or(fallback_scope.where.not(topic_id: topic_ids))
|
|
161
|
+
topic_scopes = topic_watermarks.map do |topic_id, last_read_id|
|
|
162
|
+
Comment.where(creative_id: origin_id, topic_id: topic_id).where(comment_table[:id].gt(last_read_id))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
([ fallback_scope ] + topic_scopes).reduce { |combined, scope| combined.or(scope) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def unread_counts_by_topic_without_presence(origin)
|
|
169
|
+
watermarks = read_watermarks([ origin.id ]).fetch(origin.id, {})
|
|
170
|
+
visible_comments.merge(unread_scope(origin.id, watermarks)).group(:topic_id).count
|
|
171
|
+
end
|
|
58
172
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
.
|
|
173
|
+
def visible_counts(origin_ids)
|
|
174
|
+
origin_ids.each_slice(QUERY_BATCH_SIZE).each_with_object({}) do |origin_id_batch, counts|
|
|
175
|
+
counts.merge!(visible_comments.where(creative_id: origin_id_batch).group(:creative_id).count)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
62
178
|
|
|
63
|
-
|
|
179
|
+
def visible_comments
|
|
180
|
+
user ? Comment.visible_to(user) : Comment.public_only
|
|
64
181
|
end
|
|
65
182
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
183
|
+
class << self
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
# A comment broadcast can fan out to many people. Count only the suffix
|
|
187
|
+
# after each effective watermark in the database; loading all historical
|
|
188
|
+
# comment IDs for every broadcast made the cost grow with the full thread.
|
|
189
|
+
def counts_for_users(origin, user_ids)
|
|
190
|
+
watermarks_by_user_id = read_watermarks_for_users(origin, user_ids)
|
|
191
|
+
public_topic_ids = Comment.where(creative_id: origin.id, private: false).distinct.pluck(:topic_id)
|
|
192
|
+
private_topic_ids_by_user_id = private_topic_ids_for_users(origin, user_ids)
|
|
193
|
+
requests_by_user_id = requests_by_user(user_ids, public_topic_ids, private_topic_ids_by_user_id, watermarks_by_user_id)
|
|
194
|
+
public_unread_counts = public_unread_counts_for(origin, requests_by_user_id.values)
|
|
195
|
+
private_requests_by_user_id = private_requests_by_user(user_ids, private_topic_ids_by_user_id, watermarks_by_user_id)
|
|
196
|
+
private_counts = private_counts_for(origin, private_requests_by_user_id)
|
|
197
|
+
|
|
198
|
+
counts_by_user(user_ids, requests_by_user_id, public_unread_counts, private_counts, public_topic_ids.any?)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def requests_by_user(user_ids, public_topic_ids, private_topics_by_user, watermarks_by_user)
|
|
202
|
+
user_ids.to_h do |user_id|
|
|
203
|
+
topic_ids = public_topic_ids | private_topics_by_user.fetch(user_id, [])
|
|
204
|
+
[ user_id, requests_for_topics(topic_ids, watermarks_by_user[user_id]) ]
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def private_requests_by_user(user_ids, private_topics_by_user, watermarks_by_user)
|
|
209
|
+
user_ids.to_h do |user_id|
|
|
210
|
+
[ user_id, requests_for_topics(private_topics_by_user.fetch(user_id, []), watermarks_by_user[user_id]) ]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def requests_for_topics(topic_ids, watermarks)
|
|
215
|
+
topic_ids.to_h { |topic_id| [ topic_id, watermark_for(watermarks, topic_id) ] }
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def counts_by_user(user_ids, requests_by_user, public_counts, private_counts, public_comments_exist)
|
|
219
|
+
user_ids.to_h do |user_id|
|
|
220
|
+
[ user_id, counts_by_topic(user_id, requests_by_user.fetch(user_id), public_counts, private_counts, public_comments_exist) ]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def counts_by_topic(user_id, requests, public_counts, private_counts, public_comments_exist)
|
|
225
|
+
requests.to_h do |topic_id, watermark|
|
|
226
|
+
private_count = private_counts.fetch([ user_id, topic_id, watermark ], { unread: 0, visible: 0 })
|
|
227
|
+
[ topic_id, combined_counts(topic_id, watermark, public_counts, private_count, public_comments_exist) ]
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def combined_counts(topic_id, watermark, public_counts, private_count, public_comments_exist)
|
|
232
|
+
{
|
|
233
|
+
unread: public_counts.fetch([ topic_id, watermark ], 0) + private_count.fetch(:unread),
|
|
234
|
+
visible: (public_comments_exist || private_count.fetch(:visible).positive?) ? 1 : 0
|
|
235
|
+
}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def private_topic_ids_for_users(origin, user_ids)
|
|
239
|
+
topic_ids_by_user_id = Hash.new { |hash, user_id| hash[user_id] = [] }
|
|
240
|
+
[ :user_id, :approver_id ].each do |recipient_column|
|
|
241
|
+
Comment.where(creative_id: origin.id, private: true, recipient_column => user_ids).distinct.pluck(recipient_column, :topic_id).each do |user_id, topic_id|
|
|
242
|
+
topic_ids_by_user_id[user_id] << topic_id
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
topic_ids_by_user_id.transform_values!(&:uniq)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def public_unread_counts_for(origin, requests_by_user_id)
|
|
249
|
+
requests = requests_by_user_id.flat_map(&:to_a).uniq
|
|
250
|
+
return {} if requests.empty?
|
|
251
|
+
|
|
252
|
+
zero_watermark_topic_ids = requests.select { |_, watermark| watermark.zero? }.map(&:first)
|
|
253
|
+
counts = public_counts_after(origin, zero_watermark_topic_ids.uniq, 0)
|
|
254
|
+
positive_requests = requests.reject { |_, watermark| watermark.zero? }
|
|
255
|
+
positive_requests.each_slice(QUERY_BATCH_SIZE) do |request_batch|
|
|
256
|
+
counts.merge!(counts_from_rows(public_count_rows(origin, request_batch)) do |row|
|
|
257
|
+
[ row.fetch("topic_id")&.to_i, row.fetch("watermark").to_i ]
|
|
258
|
+
end)
|
|
259
|
+
end
|
|
260
|
+
counts
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def public_counts_after(origin, topic_ids, watermark)
|
|
264
|
+
return {} if topic_ids.empty?
|
|
265
|
+
|
|
266
|
+
Comment.where(creative_id: origin.id, private: false, topic_id: topic_ids)
|
|
267
|
+
.where(Comment.arel_table[:id].gt(watermark))
|
|
268
|
+
.group(:topic_id)
|
|
269
|
+
.count
|
|
270
|
+
.transform_keys { |topic_id| [ topic_id, watermark ] }
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def private_counts_for(origin, requests_by_user_id)
|
|
274
|
+
requests = requests_by_user_id.flat_map do |user_id, requests_by_topic|
|
|
275
|
+
requests_by_topic.map { |topic_id, watermark| [ user_id, topic_id, watermark ] }
|
|
276
|
+
end
|
|
277
|
+
requests.each_slice(QUERY_BATCH_SIZE).each_with_object({}) do |request_batch, counts|
|
|
278
|
+
counts.merge!(private_count_rows(origin, request_batch).to_h do |row|
|
|
279
|
+
[ [ row.fetch("user_id").to_i, row.fetch("topic_id")&.to_i, row.fetch("watermark").to_i ], {
|
|
280
|
+
unread: row.fetch("unread_count").to_i,
|
|
281
|
+
visible: row.fetch("visible_count").to_i
|
|
282
|
+
} ]
|
|
283
|
+
end)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def public_count_rows(origin, requests)
|
|
288
|
+
count_rows(requests) do |topic_id, watermark|
|
|
289
|
+
[
|
|
290
|
+
"SELECT ? AS topic_id, ? AS watermark, COUNT(*) AS unread_count FROM comments " \
|
|
291
|
+
"WHERE creative_id = ? AND private = ? AND #{topic_condition(topic_id)} AND id > ?",
|
|
292
|
+
topic_id, watermark, origin.id, false, *topic_binds(topic_id), watermark
|
|
293
|
+
]
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def private_count_rows(origin, requests)
|
|
298
|
+
count_rows(requests) do |user_id, topic_id, watermark|
|
|
299
|
+
[
|
|
300
|
+
"SELECT ? AS user_id, ? AS topic_id, ? AS watermark, " \
|
|
301
|
+
"SUM(CASE WHEN id > ? THEN 1 ELSE 0 END) AS unread_count, COUNT(*) AS visible_count FROM comments " \
|
|
302
|
+
"WHERE creative_id = ? AND private = ? AND #{topic_condition(topic_id)} AND (user_id = ? OR approver_id = ?)",
|
|
303
|
+
user_id, topic_id, watermark, watermark, origin.id, true, *topic_binds(topic_id), user_id, user_id
|
|
304
|
+
]
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def count_rows(requests)
|
|
309
|
+
sql = requests.map { |request| Comment.sanitize_sql_array(yield(*request)) }.join(" UNION ALL ")
|
|
310
|
+
Comment.connection.select_all(sql).to_a
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def counts_from_rows(rows)
|
|
314
|
+
rows.to_h { |row| [ yield(row), row.fetch("unread_count").to_i ] }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def topic_condition(topic_id)
|
|
318
|
+
topic_id.nil? ? "topic_id IS NULL" : "topic_id = ?"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def topic_binds(topic_id)
|
|
322
|
+
topic_id.nil? ? [] : [ topic_id ]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def read_watermarks_for_users(origin, user_ids)
|
|
326
|
+
CommentReadPointer.where(user_id: user_ids, creative_id: origin.id)
|
|
327
|
+
.pluck(:user_id, :topic_id, :last_read_comment_id)
|
|
328
|
+
.each_with_object(Hash.new { |hash, user_id| hash[user_id] = {} }) do |(user_id, topic_id, last_read_id), watermarks|
|
|
329
|
+
watermarks[user_id][topic_id] = last_read_id || 0
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def watermark_for(watermarks, topic_id)
|
|
334
|
+
watermarks.fetch(topic_id, watermarks.fetch(nil, 0))
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def topic_is_viewed?(topic_id, viewing_topics)
|
|
338
|
+
return viewing_topics.include?(topic_id) unless viewing_topics.include?(CommentPresenceStore::ALL_TOPICS)
|
|
339
|
+
|
|
340
|
+
topic_id.nil? ? viewing_topics.include?(CommentPresenceStore::LEGACY_TOPIC) : viewing_topics.include?(topic_id)
|
|
341
|
+
end
|
|
70
342
|
end
|
|
71
343
|
end
|
|
72
344
|
end
|
|
@@ -26,7 +26,7 @@ module Creatives
|
|
|
26
26
|
def reorder_multiple(dragged_ids:, target_id:, direction:)
|
|
27
27
|
ids = Array(dragged_ids).map(&:presence).compact
|
|
28
28
|
validate_direction!(direction)
|
|
29
|
-
raise Error, "Invalid creatives" if ids.empty?
|
|
29
|
+
raise Error, "Invalid creatives" if ids.empty? || ids.map(&:to_s).uniq.size != ids.size
|
|
30
30
|
|
|
31
31
|
target = Creative.find_by(id: target_id)
|
|
32
32
|
raise Error, "Invalid creatives" unless target
|
|
@@ -178,8 +178,20 @@ module Creatives
|
|
|
178
178
|
end
|
|
179
179
|
|
|
180
180
|
def resequence!(creatives)
|
|
181
|
-
creatives.
|
|
182
|
-
|
|
181
|
+
return if creatives.empty?
|
|
182
|
+
|
|
183
|
+
table = Creative.arel_table
|
|
184
|
+
cases = Arel::Nodes::Case.new(table[Creative.primary_key])
|
|
185
|
+
creatives.each_with_index do |creative, index|
|
|
186
|
+
cases.when(creative.id).then(index)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
Creative.where(id: creatives.map(&:id))
|
|
190
|
+
.update_all(sequence: cases)
|
|
191
|
+
|
|
192
|
+
creatives.each_with_index do |creative, index|
|
|
193
|
+
creative.write_attribute(:sequence, index)
|
|
194
|
+
creative.send(:clear_attribute_change, :sequence)
|
|
183
195
|
end
|
|
184
196
|
end
|
|
185
197
|
end
|
|
@@ -97,7 +97,7 @@ module Creatives
|
|
|
97
97
|
visible = creatives.reject(&:archived?).select { |creative| can_feedback?(creative) }
|
|
98
98
|
return if visible.empty?
|
|
99
99
|
|
|
100
|
-
comment_badge_index.index(visible.map(&:effective_origin))
|
|
100
|
+
comment_badge_index.index(visible.map(&:effective_origin), include_visible_counts: false)
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
def build_nodes(creatives, level:)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Collavre
|
|
2
2
|
module Creatives
|
|
3
3
|
# Compact markdown tree formatter for AI Agent consumption.
|
|
4
|
-
# Used by: creative_retrieval_service
|
|
4
|
+
# Used by: creative_retrieval_service and agent context injection.
|
|
5
5
|
#
|
|
6
6
|
# Output format (header declared once, rows are values only):
|
|
7
7
|
# <!-- format: [id] description (progress%) -->
|
|
@@ -34,10 +34,10 @@ module Collavre
|
|
|
34
34
|
lines.join("\n")
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# Extract plain text description from a creative (shared helper)
|
|
37
|
+
# Extract plain text description from a creative (shared helper).
|
|
38
|
+
# One creative must occupy exactly one row, so this collapses newlines.
|
|
38
39
|
def self.plain_description(creative)
|
|
39
|
-
|
|
40
|
-
ActionView::Base.full_sanitizer.sanitize(raw).strip
|
|
40
|
+
Collavre::HtmlText.label(creative.effective_description(nil, true))
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
private
|
|
@@ -53,7 +53,7 @@ module Collavre
|
|
|
53
53
|
|
|
54
54
|
if @include_comments
|
|
55
55
|
node.comments.order(created_at: :desc).limit(3).reverse_each do |comment|
|
|
56
|
-
comment_text =
|
|
56
|
+
comment_text = Collavre::HtmlText.truncated_label(comment.content, 100)
|
|
57
57
|
lines << "#{indent} > #{comment_text}"
|
|
58
58
|
end
|
|
59
59
|
end
|
|
@@ -67,7 +67,6 @@ module Collavre
|
|
|
67
67
|
if @use_permissions
|
|
68
68
|
node.linked_children
|
|
69
69
|
else
|
|
70
|
-
# When children are pre-set on association target (e.g. GeminiParentRecommender)
|
|
71
70
|
node.children
|
|
72
71
|
end
|
|
73
72
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Collavre
|
|
2
|
+
module Creatives
|
|
3
|
+
# Resolves the IDs that the persistent workspace tree can actually render for
|
|
4
|
+
# the current creative. Shared origin paths are re-rooted through the user's
|
|
5
|
+
# linked shell, and unreadable or archived ancestors are omitted.
|
|
6
|
+
class WorkspacePathResolver
|
|
7
|
+
def initialize(creative:, user:)
|
|
8
|
+
@creative = creative
|
|
9
|
+
@user = user
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
return [] unless creative
|
|
14
|
+
|
|
15
|
+
return renderable_ids(local_path_ids) if creative.origin_id?
|
|
16
|
+
|
|
17
|
+
origin_path = local_path_ids
|
|
18
|
+
reveal = RevealPathResolver.new([ creative.id ], user: user).call[creative.id]
|
|
19
|
+
return renderable_ids(origin_path) if reveal.blank?
|
|
20
|
+
|
|
21
|
+
reveal_paths = origin_path.filter_map { |id| [ id, reveal.fetch(id) ] if reveal.key?(id) }
|
|
22
|
+
renderable_reveal_ids = renderable_ids(reveal_paths.flat_map(&:last).uniq).to_set
|
|
23
|
+
anchor = reveal_paths.reverse.find do |_id, path|
|
|
24
|
+
path.all? { |id| renderable_reveal_ids.include?(id) }
|
|
25
|
+
end
|
|
26
|
+
return renderable_ids(origin_path) unless anchor
|
|
27
|
+
|
|
28
|
+
anchor_id, reveal_path = anchor
|
|
29
|
+
anchor_index = origin_path.index(anchor_id)
|
|
30
|
+
reveal_path + renderable_ids(origin_path.drop(anchor_index + 1))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
attr_reader :creative, :user
|
|
36
|
+
|
|
37
|
+
def local_path_ids
|
|
38
|
+
creative.ancestors.reverse.pluck(:id) + [ creative.id ]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def renderable_ids(ids)
|
|
42
|
+
return [] if ids.empty?
|
|
43
|
+
|
|
44
|
+
readable = PermissionFilter.new(user: user).readable_ids(ids).to_set
|
|
45
|
+
active = Creative.where(id: ids, archived_at: nil).pluck(:id).to_set
|
|
46
|
+
ids.select { |id| readable.include?(id) && active.include?(id) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|