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
|
@@ -64,19 +64,21 @@ describe('CommentsPresenceController', () => {
|
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
expect(setCommentPermission).toHaveBeenCalledWith(false)
|
|
67
|
-
expect(loadParticipantsSpy).toHaveBeenCalledWith(
|
|
67
|
+
expect(loadParticipantsSpy).toHaveBeenCalledWith()
|
|
68
68
|
expect(close).not.toHaveBeenCalled()
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
-
test('
|
|
71
|
+
test('clears docked chat and invalidates the workspace tree when access is revoked', async () => {
|
|
72
72
|
const close = jest.fn()
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
const resetDockedToEmpty = jest.fn()
|
|
74
|
+
const workspaceListener = jest.fn()
|
|
75
|
+
document.addEventListener('workspace-tree:invalidate', workspaceListener)
|
|
76
|
+
jest.spyOn(controller, 'popupController', 'get').mockReturnValue({
|
|
77
|
+
close,
|
|
78
|
+
isDocked: () => true,
|
|
79
|
+
resetDockedToEmpty,
|
|
79
80
|
})
|
|
81
|
+
jest.spyOn(controller, 'formController', 'get').mockReturnValue({ setCommentPermission: jest.fn() })
|
|
80
82
|
|
|
81
83
|
controller.handlePresenceMessage({
|
|
82
84
|
shares_changed: {
|
|
@@ -93,7 +95,37 @@ describe('CommentsPresenceController', () => {
|
|
|
93
95
|
// alertDialog renders an in-app modal (replacing native alert for the
|
|
94
96
|
// Tauri webview); assert the message surfaced there instead.
|
|
95
97
|
expect(document.querySelector('.confirm-dialog-message')?.textContent).toBe('No permission')
|
|
98
|
+
expect(resetDockedToEmpty).toHaveBeenCalled()
|
|
99
|
+
expect(close).not.toHaveBeenCalled()
|
|
100
|
+
expect(workspaceListener).toHaveBeenCalledTimes(1)
|
|
101
|
+
expect(workspaceListener).toHaveBeenCalledWith(expect.objectContaining({
|
|
102
|
+
detail: { creativeIds: ['123'] },
|
|
103
|
+
}))
|
|
104
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
105
|
+
document.removeEventListener('workspace-tree:invalidate', workspaceListener)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('closes a floating popup immediately when access is revoked', () => {
|
|
109
|
+
const close = jest.fn()
|
|
110
|
+
jest.spyOn(controller, 'popupController', 'get').mockReturnValue({
|
|
111
|
+
close,
|
|
112
|
+
isDocked: () => false,
|
|
113
|
+
resetDockedToEmpty: jest.fn(),
|
|
114
|
+
})
|
|
115
|
+
jest.spyOn(controller, 'formController', 'get').mockReturnValue({ setCommentPermission: jest.fn() })
|
|
116
|
+
|
|
117
|
+
controller.handlePresenceMessage({
|
|
118
|
+
shares_changed: {
|
|
119
|
+
user_id: 7,
|
|
120
|
+
has_access: false,
|
|
121
|
+
can_comment: false,
|
|
122
|
+
has_access_changed: true,
|
|
123
|
+
can_comment_changed: true,
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
|
|
96
127
|
expect(close).toHaveBeenCalled()
|
|
128
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
97
129
|
})
|
|
98
130
|
|
|
99
131
|
test('keeps popup open for unaffected users and only refreshes participants', () => {
|
|
@@ -121,6 +153,191 @@ describe('CommentsPresenceController', () => {
|
|
|
121
153
|
expect(loadInitialComments).not.toHaveBeenCalled()
|
|
122
154
|
expect(close).not.toHaveBeenCalled()
|
|
123
155
|
})
|
|
156
|
+
|
|
157
|
+
test('bootstraps the selected and main topics when the popup opens', () => {
|
|
158
|
+
const refreshChannelChips = jest.spyOn(controller, 'refreshChannelChips').mockImplementation(() => {})
|
|
159
|
+
jest.spyOn(application, 'getControllerForElementAndIdentifier').mockReturnValue({
|
|
160
|
+
currentTopicId: '45',
|
|
161
|
+
mainTopicId: '10',
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
controller.bootstrapChannelChips()
|
|
165
|
+
|
|
166
|
+
expect(controller.selectedTopicId).toBe('45')
|
|
167
|
+
expect(controller.mainTopicId).toBe('10')
|
|
168
|
+
expect(refreshChannelChips).toHaveBeenCalledWith('45')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('clears topic timers when the popup closes', () => {
|
|
172
|
+
const resetAgentActivity = jest.spyOn(controller, 'resetAgentActivity').mockImplementation(() => {})
|
|
173
|
+
|
|
174
|
+
controller.onPopupClosed()
|
|
175
|
+
|
|
176
|
+
expect(resetAgentActivity).toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('sends typing lifecycle with the selected topic', () => {
|
|
180
|
+
const perform = jest.fn()
|
|
181
|
+
controller.presenceSubscription = { perform }
|
|
182
|
+
controller.selectedTopicId = '45'
|
|
183
|
+
|
|
184
|
+
controller.typing()
|
|
185
|
+
controller.stoppedTyping()
|
|
186
|
+
|
|
187
|
+
expect(perform).toHaveBeenNthCalledWith(1, 'typing', { topic_id: '45' })
|
|
188
|
+
expect(perform).toHaveBeenNthCalledWith(2, 'stopped_typing', { topic_id: '45' })
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('renews the presence lease while the subscription is connected', () => {
|
|
192
|
+
jest.useFakeTimers()
|
|
193
|
+
const perform = jest.fn()
|
|
194
|
+
controller.presenceSubscription = { perform }
|
|
195
|
+
|
|
196
|
+
controller.startPresenceHeartbeat()
|
|
197
|
+
jest.advanceTimersByTime(30000)
|
|
198
|
+
controller.stopPresenceHeartbeat()
|
|
199
|
+
jest.useRealTimers()
|
|
200
|
+
|
|
201
|
+
expect(perform).toHaveBeenCalledWith('heartbeat')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('reports the rendered All Messages topic snapshot', () => {
|
|
205
|
+
const perform = jest.fn()
|
|
206
|
+
controller.presenceSubscription = { perform }
|
|
207
|
+
controller.selectedTopicId = null
|
|
208
|
+
|
|
209
|
+
controller.handleRenderedAllTopics({ detail: { creativeId: '123', topicIds: ['10', '11'] } })
|
|
210
|
+
|
|
211
|
+
expect(perform).toHaveBeenCalledWith('viewing_topic', {
|
|
212
|
+
topic_id: null,
|
|
213
|
+
rendered_topic_ids: ['10', '11'],
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('reports a rendered legacy lane with the All Messages snapshot', () => {
|
|
218
|
+
const perform = jest.fn()
|
|
219
|
+
controller.presenceSubscription = { perform }
|
|
220
|
+
controller.selectedTopicId = null
|
|
221
|
+
|
|
222
|
+
controller.handleRenderedAllTopics({ detail: { creativeId: '123', topicIds: ['10'], includesLegacy: true } })
|
|
223
|
+
|
|
224
|
+
expect(perform).toHaveBeenCalledWith('viewing_topic', {
|
|
225
|
+
topic_id: null,
|
|
226
|
+
rendered_topic_ids: ['10'],
|
|
227
|
+
rendered_legacy_topic: true,
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
test('does not report a rendered snapshot from another creative', () => {
|
|
232
|
+
const perform = jest.fn()
|
|
233
|
+
controller.presenceSubscription = { perform }
|
|
234
|
+
|
|
235
|
+
controller.handleRenderedAllTopics({ detail: { creativeId: '456', topicIds: ['10'] } })
|
|
236
|
+
|
|
237
|
+
expect(perform).not.toHaveBeenCalled()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('does not request running agents without a presence subscription', () => {
|
|
241
|
+
controller.selectedTopicId = '45'
|
|
242
|
+
|
|
243
|
+
controller.requestRunningAgents()
|
|
244
|
+
|
|
245
|
+
expect(controller.presenceSubscription).toBeNull()
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('sends All Messages input to Main but does not render topic indicators there', () => {
|
|
249
|
+
const perform = jest.fn()
|
|
250
|
+
controller.presenceSubscription = { perform }
|
|
251
|
+
controller.selectedTopicId = null
|
|
252
|
+
controller.mainTopicId = '10'
|
|
253
|
+
|
|
254
|
+
controller.typing()
|
|
255
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '10' } })
|
|
256
|
+
|
|
257
|
+
expect(perform).toHaveBeenCalledWith('typing', { topic_id: '10' })
|
|
258
|
+
expect(controller.typingUsers).toEqual({})
|
|
259
|
+
controller.stoppedTyping()
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('shows typing only for the selected topic', () => {
|
|
263
|
+
controller.selectedTopicId = '10'
|
|
264
|
+
|
|
265
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
|
|
266
|
+
expect(controller.typingUsers).toEqual({})
|
|
267
|
+
|
|
268
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '10' } })
|
|
269
|
+
expect(controller.typingUsers).toEqual({ 5: 'Alice' })
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('ignores stop events from another topic', () => {
|
|
273
|
+
controller.selectedTopicId = '10'
|
|
274
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '10' } })
|
|
275
|
+
|
|
276
|
+
controller.handlePresenceMessage({ stop_typing: { id: 5, topic_id: '11' } })
|
|
277
|
+
expect(controller.typingUsers).toEqual({ 5: 'Alice' })
|
|
278
|
+
|
|
279
|
+
controller.handlePresenceMessage({ stop_typing: { id: 5, topic_id: '10' } })
|
|
280
|
+
expect(controller.typingUsers).toEqual({})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('ignores agent idle events from another topic and mismatched legacy tasks', () => {
|
|
284
|
+
controller.selectedTopicId = '10'
|
|
285
|
+
const status = {
|
|
286
|
+
id: 9,
|
|
287
|
+
name: 'Agent',
|
|
288
|
+
status: 'thinking',
|
|
289
|
+
task_id: 99,
|
|
290
|
+
creative_id: '123',
|
|
291
|
+
topic_id: '10',
|
|
292
|
+
}
|
|
293
|
+
controller.handlePresenceMessage({ agent_status: status })
|
|
294
|
+
|
|
295
|
+
controller.handlePresenceMessage({
|
|
296
|
+
agent_status: { ...status, status: 'idle', topic_id: '11' },
|
|
297
|
+
})
|
|
298
|
+
expect(controller.typingUsers).toEqual({ 9: 'Agent' })
|
|
299
|
+
|
|
300
|
+
const { topic_id: _topicId, ...legacyStatus } = status
|
|
301
|
+
controller.handlePresenceMessage({
|
|
302
|
+
agent_status: { ...legacyStatus, status: 'idle', task_id: 100 },
|
|
303
|
+
})
|
|
304
|
+
expect(controller.typingUsers).toEqual({ 9: 'Agent' })
|
|
305
|
+
|
|
306
|
+
controller.handlePresenceMessage({
|
|
307
|
+
agent_status: { ...legacyStatus, status: 'idle' },
|
|
308
|
+
})
|
|
309
|
+
expect(controller.typingUsers).toEqual({})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('clears typing and agent state immediately when switching topics', () => {
|
|
313
|
+
const perform = jest.fn()
|
|
314
|
+
controller.presenceSubscription = { perform }
|
|
315
|
+
controller.selectedTopicId = '10'
|
|
316
|
+
controller.mainTopicId = '1'
|
|
317
|
+
controller.typingUsers = { 5: 'Alice', 9: 'Agent' }
|
|
318
|
+
controller.activeAgentTasks = { 9: 99 }
|
|
319
|
+
controller.typingTimers = { 5: setTimeout(() => {}, 1000) }
|
|
320
|
+
controller.streamingHeartbeatTimers = { 9: setTimeout(() => {}, 1000) }
|
|
321
|
+
controller.startAgentTaskPoll()
|
|
322
|
+
jest.spyOn(controller, 'refreshChannelChips').mockImplementation(() => {})
|
|
323
|
+
|
|
324
|
+
controller.handleTopicChange({ detail: { topicId: '11', mainTopicId: '1' } })
|
|
325
|
+
|
|
326
|
+
expect(perform).toHaveBeenCalledWith('stopped_typing', { topic_id: '10' })
|
|
327
|
+
expect(perform).toHaveBeenCalledWith('running_agents', { topic_id: '11' })
|
|
328
|
+
expect(controller.selectedTopicId).toBe('11')
|
|
329
|
+
expect(controller.typingUsers).toEqual({})
|
|
330
|
+
expect(controller.activeAgentTasks).toEqual({})
|
|
331
|
+
expect(controller.typingTimers).toEqual({})
|
|
332
|
+
expect(controller.streamingHeartbeatTimers).toEqual({})
|
|
333
|
+
// A poll left running would keep asking about the old topic's turns and
|
|
334
|
+
// re-render an indicator the user just navigated away from.
|
|
335
|
+
expect(controller.agentTaskPollHandle).toBeNull()
|
|
336
|
+
|
|
337
|
+
perform.mockClear()
|
|
338
|
+
controller.handleTopicChange({ detail: { topicId: '11', mainTopicId: '1' } })
|
|
339
|
+
expect(perform).not.toHaveBeenCalled()
|
|
340
|
+
})
|
|
124
341
|
})
|
|
125
342
|
|
|
126
343
|
describe('CommentsPresenceController typing-row horizontal scroll', () => {
|
|
@@ -169,6 +386,7 @@ describe('CommentsPresenceController typing-row horizontal scroll', () => {
|
|
|
169
386
|
const el = document.getElementById('comments-popup')
|
|
170
387
|
controller = application.getControllerForElementAndIdentifier(el, 'comments--presence')
|
|
171
388
|
controller.creativeId = '123'
|
|
389
|
+
controller.selectedTopicId = '11'
|
|
172
390
|
row = el.querySelector('#typing-scroll-viewport')
|
|
173
391
|
})
|
|
174
392
|
|
|
@@ -182,7 +400,7 @@ describe('CommentsPresenceController typing-row horizontal scroll', () => {
|
|
|
182
400
|
test('auto-scrolls a new typer into view when parked at the right edge', () => {
|
|
183
401
|
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 200 }) // at end
|
|
184
402
|
|
|
185
|
-
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
|
|
403
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
|
|
186
404
|
|
|
187
405
|
expect(scrollLeftValue).toBe(300)
|
|
188
406
|
})
|
|
@@ -190,20 +408,20 @@ describe('CommentsPresenceController typing-row horizontal scroll', () => {
|
|
|
190
408
|
test('does NOT scroll when the user has scrolled back to look at earlier items', () => {
|
|
191
409
|
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 }) // scrolled back
|
|
192
410
|
|
|
193
|
-
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
|
|
411
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
|
|
194
412
|
|
|
195
413
|
expect(scrollLeftValue).toBe(0)
|
|
196
414
|
})
|
|
197
415
|
|
|
198
416
|
test('does not re-scroll on repeat typing pings from the same user (not a new item)', () => {
|
|
199
417
|
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 200 })
|
|
200
|
-
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
|
|
418
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
|
|
201
419
|
expect(scrollLeftValue).toBe(300)
|
|
202
420
|
|
|
203
421
|
// User scrolls back; a heartbeat ping for the same (already-shown) typer
|
|
204
422
|
// must not yank them to the end again.
|
|
205
423
|
scrollLeftValue = 0
|
|
206
|
-
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice' } })
|
|
424
|
+
controller.handlePresenceMessage({ typing: { id: 5, name: 'Alice', topic_id: '11' } })
|
|
207
425
|
expect(scrollLeftValue).toBe(0)
|
|
208
426
|
})
|
|
209
427
|
|
|
@@ -213,20 +431,504 @@ describe('CommentsPresenceController typing-row horizontal scroll', () => {
|
|
|
213
431
|
// always want to see their own indicator, so stick-to-end must not suppress it.
|
|
214
432
|
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 })
|
|
215
433
|
|
|
216
|
-
controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
|
|
434
|
+
controller.handlePresenceMessage({ typing: { id: 7, name: 'Me', topic_id: '11' } })
|
|
217
435
|
|
|
218
436
|
expect(scrollLeftValue).toBe(300)
|
|
219
437
|
})
|
|
220
438
|
|
|
221
439
|
test('does not re-scroll on repeat self typing pings (only the first appearance forces scroll)', () => {
|
|
222
440
|
setGeometry({ clientWidth: 100, scrollWidth: 300, scrollLeft: 0 })
|
|
223
|
-
controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
|
|
441
|
+
controller.handlePresenceMessage({ typing: { id: 7, name: 'Me', topic_id: '11' } })
|
|
224
442
|
expect(scrollLeftValue).toBe(300)
|
|
225
443
|
|
|
226
444
|
// A heartbeat ping for the same (already-shown) self typer while scrolled
|
|
227
445
|
// back must not yank to the end again — only the first appearance forces it.
|
|
228
446
|
scrollLeftValue = 0
|
|
229
|
-
controller.handlePresenceMessage({ typing: { id: 7, name: 'Me' } })
|
|
447
|
+
controller.handlePresenceMessage({ typing: { id: 7, name: 'Me', topic_id: '11' } })
|
|
230
448
|
expect(scrollLeftValue).toBe(0)
|
|
231
449
|
})
|
|
450
|
+
|
|
451
|
+
describe('detaching a channel', () => {
|
|
452
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
453
|
+
|
|
454
|
+
const appendChannelChip = () => {
|
|
455
|
+
controller.channelChipsTarget.innerHTML = `
|
|
456
|
+
<span class="channel-chip" data-channel-id="42">
|
|
457
|
+
Pull request
|
|
458
|
+
<button data-channel-id="42">Detach</button>
|
|
459
|
+
</span>
|
|
460
|
+
`
|
|
461
|
+
return controller.channelChipsTarget.querySelector('button')
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
test('removes the channel chip immediately after a successful request', async () => {
|
|
465
|
+
const button = appendChannelChip()
|
|
466
|
+
global.fetch.mockResolvedValueOnce({ status: 204, redirected: false, headers: new Headers() })
|
|
467
|
+
|
|
468
|
+
controller.detachChannel({ currentTarget: button })
|
|
469
|
+
await flush()
|
|
470
|
+
|
|
471
|
+
expect(global.fetch).toHaveBeenCalledWith('/channels/42', expect.objectContaining({
|
|
472
|
+
method: 'DELETE',
|
|
473
|
+
}))
|
|
474
|
+
expect(controller.channelChipsTarget.querySelector('.channel-chip')).toBeNull()
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
test('keeps the channel chip when the request fails', async () => {
|
|
478
|
+
const button = appendChannelChip()
|
|
479
|
+
global.fetch.mockResolvedValueOnce({ status: 500, redirected: false, headers: new Headers() })
|
|
480
|
+
|
|
481
|
+
controller.detachChannel({ currentTarget: button })
|
|
482
|
+
await flush()
|
|
483
|
+
|
|
484
|
+
expect(controller.channelChipsTarget.querySelector('.channel-chip')).not.toBeNull()
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
test('keeps the channel chip when the request is redirected', async () => {
|
|
488
|
+
const button = appendChannelChip()
|
|
489
|
+
global.fetch.mockResolvedValueOnce({ status: 200, redirected: true, headers: new Headers() })
|
|
490
|
+
|
|
491
|
+
controller.detachChannel({ currentTarget: button })
|
|
492
|
+
await flush()
|
|
493
|
+
|
|
494
|
+
expect(controller.channelChipsTarget.querySelector('.channel-chip')).not.toBeNull()
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
test('keeps the channel chip for an unexpected non-redirect response', async () => {
|
|
498
|
+
const button = appendChannelChip()
|
|
499
|
+
global.fetch.mockResolvedValueOnce({ status: 200, redirected: false, headers: new Headers() })
|
|
500
|
+
|
|
501
|
+
controller.detachChannel({ currentTarget: button })
|
|
502
|
+
await flush()
|
|
503
|
+
|
|
504
|
+
expect(controller.channelChipsTarget.querySelector('.channel-chip')).not.toBeNull()
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test('keeps the channel chip when the request is interrupted', async () => {
|
|
508
|
+
const button = appendChannelChip()
|
|
509
|
+
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
510
|
+
global.fetch.mockRejectedValueOnce(new Error('offline'))
|
|
511
|
+
|
|
512
|
+
controller.detachChannel({ currentTarget: button })
|
|
513
|
+
await flush()
|
|
514
|
+
|
|
515
|
+
expect(controller.channelChipsTarget.querySelector('.channel-chip')).not.toBeNull()
|
|
516
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
517
|
+
'[presence] detach channel failed:',
|
|
518
|
+
expect.any(Error),
|
|
519
|
+
)
|
|
520
|
+
})
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
describe('agent task status poll', () => {
|
|
524
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
525
|
+
|
|
526
|
+
const respondWith = (tasks) => {
|
|
527
|
+
global.fetch.mockResolvedValueOnce({
|
|
528
|
+
ok: true,
|
|
529
|
+
headers: new Headers(),
|
|
530
|
+
json: async () => ({ tasks }),
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const armAgent = (agentId, taskIds) => {
|
|
535
|
+
controller.activeAgentTasks[agentId] = [...taskIds]
|
|
536
|
+
controller.typingUsers[agentId] = 'Agent'
|
|
537
|
+
controller.agentStates[agentId] = 'thinking'
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
test('drops a task the poll reports as finished', async () => {
|
|
541
|
+
armAgent('9', [55])
|
|
542
|
+
respondWith([{ id: 55, active: false }])
|
|
543
|
+
|
|
544
|
+
controller.pollAgentTaskStatuses()
|
|
545
|
+
await flush()
|
|
546
|
+
|
|
547
|
+
expect(controller.activeAgentTasks['9']).toBeUndefined()
|
|
548
|
+
expect(controller.typingUsers['9']).toBeUndefined()
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
// Which statuses count as active is the server's call (Task#active?), covered
|
|
552
|
+
// in tasks_controller_test.rb — here it only has to be believed.
|
|
553
|
+
test('keeps a task the poll still reports as active', async () => {
|
|
554
|
+
armAgent('9', [55])
|
|
555
|
+
respondWith([{ id: 55, active: true }])
|
|
556
|
+
|
|
557
|
+
controller.pollAgentTaskStatuses()
|
|
558
|
+
await flush()
|
|
559
|
+
|
|
560
|
+
expect(controller.activeAgentTasks['9']).toEqual([55])
|
|
561
|
+
expect(controller.typingUsers['9']).toBe('Agent')
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
test('keeps a task that started after the poll request was sent', async () => {
|
|
565
|
+
armAgent('9', [55])
|
|
566
|
+
|
|
567
|
+
let settle
|
|
568
|
+
global.fetch.mockReturnValueOnce(
|
|
569
|
+
new Promise((resolve) => {
|
|
570
|
+
settle = resolve
|
|
571
|
+
}),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
controller.pollAgentTaskStatuses()
|
|
575
|
+
|
|
576
|
+
// A second turn starts while the request is in flight. The server was
|
|
577
|
+
// never asked about 77, so its absence from the response says nothing.
|
|
578
|
+
controller.activeAgentTasks['9'].push(77)
|
|
579
|
+
|
|
580
|
+
settle({
|
|
581
|
+
ok: true,
|
|
582
|
+
headers: new Headers(),
|
|
583
|
+
json: async () => ({ tasks: [{ id: 55, active: true }] }),
|
|
584
|
+
})
|
|
585
|
+
await flush()
|
|
586
|
+
|
|
587
|
+
expect(controller.activeAgentTasks['9']).toEqual([55, 77])
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
// A blip must not do what the old 10s timeout did: take the indicator, and
|
|
591
|
+
// with it the only Stop button, off screen while the turn is still running.
|
|
592
|
+
test('a failed poll leaves the turn alone', async () => {
|
|
593
|
+
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
594
|
+
armAgent('9', [55])
|
|
595
|
+
global.fetch.mockRejectedValueOnce(new Error('offline'))
|
|
596
|
+
|
|
597
|
+
controller.pollAgentTaskStatuses()
|
|
598
|
+
await flush()
|
|
599
|
+
|
|
600
|
+
expect(controller.activeAgentTasks['9']).toEqual([55])
|
|
601
|
+
expect(controller.typingUsers['9']).toBe('Agent')
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
test('a poll with nothing left to ask about stops itself', () => {
|
|
605
|
+
controller.startAgentTaskPoll()
|
|
606
|
+
|
|
607
|
+
controller.pollAgentTaskStatuses()
|
|
608
|
+
|
|
609
|
+
expect(controller.agentTaskPollHandle).toBeNull()
|
|
610
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
test('still drops a requested task even when a newer one arrived mid-flight', async () => {
|
|
614
|
+
armAgent('9', [55])
|
|
615
|
+
|
|
616
|
+
let settle
|
|
617
|
+
global.fetch.mockReturnValueOnce(
|
|
618
|
+
new Promise((resolve) => {
|
|
619
|
+
settle = resolve
|
|
620
|
+
}),
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
controller.pollAgentTaskStatuses()
|
|
624
|
+
controller.activeAgentTasks['9'].push(77)
|
|
625
|
+
|
|
626
|
+
settle({
|
|
627
|
+
ok: true,
|
|
628
|
+
headers: new Headers(),
|
|
629
|
+
json: async () => ({ tasks: [{ id: 55, active: false }] }),
|
|
630
|
+
})
|
|
631
|
+
await flush()
|
|
632
|
+
|
|
633
|
+
expect(controller.activeAgentTasks['9']).toEqual([77])
|
|
634
|
+
})
|
|
635
|
+
})
|
|
636
|
+
|
|
637
|
+
describe('agent_status for a task paused on approval', () => {
|
|
638
|
+
const agentStatus = (status) => ({
|
|
639
|
+
agent_status: { id: 9, name: 'Agent', status, task_id: 55, creative_id: '123', topic_id: '11' },
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
beforeEach(() => {
|
|
643
|
+
controller.handlePresenceMessage(agentStatus('streaming'))
|
|
644
|
+
expect(controller.activeAgentTasks['9']).toEqual([55])
|
|
645
|
+
expect(controller.agentTaskPollHandle).not.toBeNull()
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
// The server pauses the task on pending_approval while it still holds the
|
|
649
|
+
// topic slot and still accepts cancel. Dropping it here would also stop the
|
|
650
|
+
// poll, so nothing would ever observe that status.
|
|
651
|
+
test('keeps the task and the poll, and still offers Stop', () => {
|
|
652
|
+
controller.handlePresenceMessage(agentStatus('pending_approval'))
|
|
653
|
+
|
|
654
|
+
expect(controller.activeAgentTasks['9']).toEqual([55])
|
|
655
|
+
expect(controller.typingUsers['9']).toBe('Agent')
|
|
656
|
+
expect(controller.agentTaskPollHandle).not.toBeNull()
|
|
657
|
+
expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
// The other direction: a genuinely finished turn must still clear at once,
|
|
661
|
+
// rather than lingering until the next poll.
|
|
662
|
+
test('idle still drops the task and stops the poll', () => {
|
|
663
|
+
controller.handlePresenceMessage(agentStatus('idle'))
|
|
664
|
+
|
|
665
|
+
expect(controller.activeAgentTasks['9']).toBeUndefined()
|
|
666
|
+
expect(controller.typingUsers['9']).toBeUndefined()
|
|
667
|
+
expect(controller.agentTaskPollHandle).toBeNull()
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
// idle is broadcast more than once per turn (finalize, then teardown), and a
|
|
671
|
+
// reconnecting client can also replay one for a turn it never registered. The
|
|
672
|
+
// second one has no task list to walk and must still be a clean no-op.
|
|
673
|
+
test('a repeat idle for an agent with no task left is harmless', () => {
|
|
674
|
+
controller.handlePresenceMessage(agentStatus('idle'))
|
|
675
|
+
controller.typingUsers['9'] = 'Agent'
|
|
676
|
+
controller.agentStates['9'] = 'thinking'
|
|
677
|
+
|
|
678
|
+
controller.handlePresenceMessage(agentStatus('idle'))
|
|
679
|
+
|
|
680
|
+
expect(controller.typingUsers['9']).toBeUndefined()
|
|
681
|
+
expect(controller.agentStates['9']).toBeUndefined()
|
|
682
|
+
})
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
describe('indicator label per agent state', () => {
|
|
686
|
+
const agentStatus = (status) => ({
|
|
687
|
+
agent_status: { id: 9, name: 'Agent', status, task_id: 55, creative_id: '123', topic_id: '11' },
|
|
688
|
+
})
|
|
689
|
+
const label = () => controller.typingIndicatorTarget.lastChild.textContent
|
|
690
|
+
|
|
691
|
+
test('streaming reads as typing', () => {
|
|
692
|
+
controller.handlePresenceMessage(agentStatus('streaming'))
|
|
693
|
+
|
|
694
|
+
expect(label()).toBe('Agent ...')
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
test('thinking reads as waiting', () => {
|
|
698
|
+
controller.handlePresenceMessage(agentStatus('thinking'))
|
|
699
|
+
|
|
700
|
+
expect(label()).toBe('Agent ⏳')
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
// The reported bug: a long turn used to make the whole indicator (and with
|
|
704
|
+
// it the only Stop button) disappear. It must degrade to waiting instead.
|
|
705
|
+
test('a stalled stream degrades to waiting and keeps Stop reachable', () => {
|
|
706
|
+
jest.useFakeTimers()
|
|
707
|
+
try {
|
|
708
|
+
controller.handlePresenceMessage(agentStatus('streaming'))
|
|
709
|
+
expect(label()).toBe('Agent ...')
|
|
710
|
+
|
|
711
|
+
jest.advanceTimersByTime(5000)
|
|
712
|
+
|
|
713
|
+
expect(label()).toBe('Agent ⏳')
|
|
714
|
+
expect(controller.activeAgentTasks['9']).toEqual([55])
|
|
715
|
+
expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
|
|
716
|
+
} finally {
|
|
717
|
+
jest.useRealTimers()
|
|
718
|
+
}
|
|
719
|
+
})
|
|
720
|
+
|
|
721
|
+
// Each heartbeat re-arms the timer, so a stream that keeps reporting never
|
|
722
|
+
// flips to waiting.
|
|
723
|
+
test('a live heartbeat keeps it typing', () => {
|
|
724
|
+
jest.useFakeTimers()
|
|
725
|
+
try {
|
|
726
|
+
controller.handlePresenceMessage(agentStatus('streaming'))
|
|
727
|
+
jest.advanceTimersByTime(3000)
|
|
728
|
+
controller.handlePresenceMessage(agentStatus('streaming'))
|
|
729
|
+
jest.advanceTimersByTime(3000)
|
|
730
|
+
|
|
731
|
+
expect(label()).toBe('Agent ...')
|
|
732
|
+
} finally {
|
|
733
|
+
jest.useRealTimers()
|
|
734
|
+
}
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
// The scheduler runs more than one turn per agent (topic concurrency > 1),
|
|
738
|
+
// and the heartbeat timer is per agent, not per turn. One turn going idle
|
|
739
|
+
// must not disarm the timer the surviving turn still depends on, or that
|
|
740
|
+
// turn stays labelled as typing forever once its stream goes quiet.
|
|
741
|
+
test('one turn ending leaves the other turn able to degrade to waiting', () => {
|
|
742
|
+
const statusFor = (status, taskId) => ({
|
|
743
|
+
agent_status: { id: 9, name: 'Agent', status, task_id: taskId, creative_id: '123', topic_id: '11' },
|
|
744
|
+
})
|
|
745
|
+
jest.useFakeTimers()
|
|
746
|
+
try {
|
|
747
|
+
controller.handlePresenceMessage(statusFor('streaming', 55))
|
|
748
|
+
controller.handlePresenceMessage(statusFor('streaming', 66))
|
|
749
|
+
|
|
750
|
+
controller.handlePresenceMessage(statusFor('idle', 55))
|
|
751
|
+
expect(controller.activeAgentTasks['9']).toEqual([66])
|
|
752
|
+
expect(label()).toBe('Agent ...')
|
|
753
|
+
|
|
754
|
+
jest.advanceTimersByTime(5000)
|
|
755
|
+
|
|
756
|
+
expect(label()).toBe('Agent ⏳')
|
|
757
|
+
expect(controller.activeAgentTasks['9']).toEqual([66])
|
|
758
|
+
expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
|
|
759
|
+
} finally {
|
|
760
|
+
jest.useRealTimers()
|
|
761
|
+
}
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
test('a human typer is never labelled as waiting', () => {
|
|
765
|
+
controller.typingUsers['7'] = 'Human'
|
|
766
|
+
controller.renderTypingIndicator()
|
|
767
|
+
|
|
768
|
+
expect(label()).toBe('Human ...')
|
|
769
|
+
})
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
describe('navigating the open popup to another creative', () => {
|
|
773
|
+
beforeEach(() => {
|
|
774
|
+
// onPopupOpened's other work needs a cable and a server; neither is the
|
|
775
|
+
// subject here.
|
|
776
|
+
jest.spyOn(controller, 'loadParticipants').mockImplementation(() => {})
|
|
777
|
+
jest.spyOn(controller, 'subscribe').mockImplementation(() => {})
|
|
778
|
+
jest.spyOn(controller, 'bootstrapChannelChips').mockImplementation(() => {})
|
|
779
|
+
|
|
780
|
+
controller.handlePresenceMessage({
|
|
781
|
+
agent_status: { id: 9, name: 'Agent', status: 'streaming', task_id: 55, creative_id: '123', topic_id: '11' },
|
|
782
|
+
})
|
|
783
|
+
expect(controller.activeAgentTasks['9']).toEqual([55])
|
|
784
|
+
expect(controller.agentTaskPollHandle).not.toBeNull()
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
// The popup is never closed on navigation — PopupController#_navigateToEntry
|
|
788
|
+
// reuses open()/openForCreative() — so onPopupClosed() does not run and this
|
|
789
|
+
// is the only place that can drop the previous chat's state. Left behind, the
|
|
790
|
+
// poll keeps asking about a task in another creative, active_statuses answers
|
|
791
|
+
// for it (it only checks readability), and the indicator plus a Stop button
|
|
792
|
+
// for that foreign turn stay on screen.
|
|
793
|
+
test('drops the previous chat’s agent state and stops the poll', () => {
|
|
794
|
+
controller.onPopupOpened({ creativeId: '456' })
|
|
795
|
+
|
|
796
|
+
expect(controller.activeAgentTasks).toEqual({})
|
|
797
|
+
expect(controller.typingUsers).toEqual({})
|
|
798
|
+
expect(controller.agentStates).toEqual({})
|
|
799
|
+
expect(controller.agentTaskPollHandle).toBeNull()
|
|
800
|
+
expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).toBeNull()
|
|
801
|
+
})
|
|
802
|
+
|
|
803
|
+
// Scoped to an actual change of creative: re-opening the same chat must not
|
|
804
|
+
// blank a turn that is streaming in it right now, which would leave it
|
|
805
|
+
// invisible (and uncancellable) until the next 3s heartbeat.
|
|
806
|
+
test('keeps it when the same creative is re-opened', () => {
|
|
807
|
+
controller.onPopupOpened({ creativeId: 123 })
|
|
808
|
+
|
|
809
|
+
expect(controller.activeAgentTasks['9']).toEqual([55])
|
|
810
|
+
expect(controller.typingUsers['9']).toBe('Agent')
|
|
811
|
+
expect(controller.agentTaskPollHandle).not.toBeNull()
|
|
812
|
+
})
|
|
813
|
+
})
|
|
814
|
+
|
|
815
|
+
describe('stopping a turn', () => {
|
|
816
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
817
|
+
const cancelOk = () => global.fetch.mockResolvedValueOnce({ ok: true, headers: new Headers() })
|
|
818
|
+
const cancelledUrls = () => global.fetch.mock.calls.map(([url]) => url)
|
|
819
|
+
|
|
820
|
+
const arrive = (status, { agentId = 9, taskId = 55, name = 'Agent' } = {}) =>
|
|
821
|
+
controller.handlePresenceMessage({
|
|
822
|
+
agent_status: { id: agentId, name, status, task_id: taskId, creative_id: '123', topic_id: '11' },
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
test('the Stop button cancels the turn and takes the indicator down with it', async () => {
|
|
826
|
+
arrive('streaming')
|
|
827
|
+
cancelOk()
|
|
828
|
+
|
|
829
|
+
controller.typingIndicatorTarget.querySelector('.agent-stop-btn').click()
|
|
830
|
+
await flush()
|
|
831
|
+
|
|
832
|
+
expect(cancelledUrls()).toEqual(['/tasks/55/cancel'])
|
|
833
|
+
expect(controller.activeAgentTasks['9']).toBeUndefined()
|
|
834
|
+
expect(controller.typingUsers['9']).toBeUndefined()
|
|
835
|
+
expect(controller.agentStates['9']).toBeUndefined()
|
|
836
|
+
expect(controller.agentTaskPollHandle).toBeNull()
|
|
837
|
+
expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).toBeNull()
|
|
838
|
+
})
|
|
839
|
+
|
|
840
|
+
// One Stop button stands for one agent, so it has to mean the turn the user
|
|
841
|
+
// is watching — the newest — not whichever turn happened to start first.
|
|
842
|
+
test('Stop ends the newest turn when an agent has two in flight', async () => {
|
|
843
|
+
arrive('streaming', { taskId: 55 })
|
|
844
|
+
arrive('streaming', { taskId: 77 })
|
|
845
|
+
cancelOk()
|
|
846
|
+
|
|
847
|
+
controller.typingIndicatorTarget.querySelector('.agent-stop-btn').click()
|
|
848
|
+
await flush()
|
|
849
|
+
|
|
850
|
+
expect(cancelledUrls()).toEqual(['/tasks/77/cancel'])
|
|
851
|
+
// The other turn is still running, so the agent — and its Stop button — stay.
|
|
852
|
+
expect(controller.activeAgentTasks['9']).toEqual([55])
|
|
853
|
+
expect(controller.typingUsers['9']).toBe('Agent')
|
|
854
|
+
expect(controller.agentTaskPollHandle).not.toBeNull()
|
|
855
|
+
expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
// A cancel that lands after the same task was already dropped (an idle
|
|
859
|
+
// broadcast, or a poll that saw it finish) must still clear the name.
|
|
860
|
+
test('cancelling a turn already dropped elsewhere still clears the agent', async () => {
|
|
861
|
+
arrive('streaming')
|
|
862
|
+
delete controller.activeAgentTasks['9']
|
|
863
|
+
cancelOk()
|
|
864
|
+
|
|
865
|
+
controller.cancelAgentTask(55, '9')
|
|
866
|
+
await flush()
|
|
867
|
+
|
|
868
|
+
expect(controller.typingUsers['9']).toBeUndefined()
|
|
869
|
+
expect(controller.agentStates['9']).toBeUndefined()
|
|
870
|
+
expect(controller.streamingHeartbeatTimers['9']).toBeUndefined()
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
test('a rejected cancel leaves the turn on screen', async () => {
|
|
874
|
+
arrive('streaming')
|
|
875
|
+
global.fetch.mockResolvedValueOnce({ ok: false, headers: new Headers() })
|
|
876
|
+
|
|
877
|
+
controller.cancelAgentTask(55, '9')
|
|
878
|
+
await flush()
|
|
879
|
+
|
|
880
|
+
expect(controller.activeAgentTasks['9']).toEqual([55])
|
|
881
|
+
expect(controller.typingIndicatorTarget.querySelector('.agent-stop-btn')).not.toBeNull()
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
// Escape in the composer routes here (form_controller keydown). Returning
|
|
885
|
+
// false is what lets that handler fall through to its other behaviour.
|
|
886
|
+
test('Escape reports false when there is nothing to stop', () => {
|
|
887
|
+
expect(controller.cancelAllAgentTasks()).toBe(false)
|
|
888
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
test('Escape stops the newest turn of every agent', async () => {
|
|
892
|
+
arrive('streaming', { agentId: 9, taskId: 55 })
|
|
893
|
+
arrive('streaming', { agentId: 9, taskId: 77 })
|
|
894
|
+
arrive('streaming', { agentId: 10, taskId: 88, name: 'Other' })
|
|
895
|
+
cancelOk()
|
|
896
|
+
cancelOk()
|
|
897
|
+
|
|
898
|
+
expect(controller.cancelAllAgentTasks()).toBe(true)
|
|
899
|
+
await flush()
|
|
900
|
+
|
|
901
|
+
expect(cancelledUrls()).toEqual(['/tasks/77/cancel', '/tasks/88/cancel'])
|
|
902
|
+
})
|
|
903
|
+
})
|
|
904
|
+
|
|
905
|
+
// The poll interval and the heartbeat timeouts write back into state the
|
|
906
|
+
// indicator renders from. Leaving them armed for a chat nobody is looking at
|
|
907
|
+
// keeps fetching /tasks/active_statuses forever and can repaint a torn-down row.
|
|
908
|
+
describe('leaving the chat', () => {
|
|
909
|
+
beforeEach(() => {
|
|
910
|
+
jest.spyOn(controller, 'unsubscribe').mockImplementation(() => {})
|
|
911
|
+
controller.handlePresenceMessage({
|
|
912
|
+
agent_status: { id: 9, name: 'Agent', status: 'streaming', task_id: 55, creative_id: '123', topic_id: '11' },
|
|
913
|
+
})
|
|
914
|
+
expect(controller.agentTaskPollHandle).not.toBeNull()
|
|
915
|
+
expect(controller.streamingHeartbeatTimers['9']).toBeDefined()
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
test('closing the popup disarms the poll and the heartbeats', () => {
|
|
919
|
+
controller.onPopupClosed()
|
|
920
|
+
|
|
921
|
+
expect(controller.agentTaskPollHandle).toBeNull()
|
|
922
|
+
expect(controller.streamingHeartbeatTimers).toEqual({})
|
|
923
|
+
expect(controller.activeAgentTasks).toEqual({})
|
|
924
|
+
expect(controller.typingUsers).toEqual({})
|
|
925
|
+
})
|
|
926
|
+
|
|
927
|
+
test('disconnecting the controller disarms the poll and the heartbeats', () => {
|
|
928
|
+
controller.disconnect()
|
|
929
|
+
|
|
930
|
+
expect(controller.agentTaskPollHandle).toBeNull()
|
|
931
|
+
expect(controller.streamingHeartbeatTimers).toEqual({})
|
|
932
|
+
})
|
|
933
|
+
})
|
|
232
934
|
})
|