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
|
@@ -41,9 +41,11 @@ describe('CommonPopup bounds clamping', () => {
|
|
|
41
41
|
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
42
42
|
popup._boundsElement = boundsElement
|
|
43
43
|
popup.updatePosition({ left: 200, bottom: 200 })
|
|
44
|
-
// inner box =
|
|
44
|
+
// inner box = width - 2*pad
|
|
45
45
|
expect(element.style.maxWidth).toBe('384px')
|
|
46
|
-
|
|
46
|
+
// Height is capped to the space under the anchor rather than the whole
|
|
47
|
+
// box (700 - 8 - 204), so content arriving later cannot spill past it.
|
|
48
|
+
expect(element.style.maxHeight).toBe('488px')
|
|
47
49
|
})
|
|
48
50
|
|
|
49
51
|
test('falls back to viewport clamping when no bounds element is set', () => {
|
|
@@ -52,7 +54,10 @@ describe('CommonPopup bounds clamping', () => {
|
|
|
52
54
|
// jsdom viewport defaults 1024x768; anchor at 0 → left clamps to padding 8, parent 0
|
|
53
55
|
Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
|
|
54
56
|
popup.updatePosition({ left: 0, bottom: 0 })
|
|
55
|
-
|
|
57
|
+
// Space below the anchor inside the viewport: 768 - 8 - 4.
|
|
58
|
+
expect(element.style.maxHeight).toBe('756px')
|
|
56
59
|
expect(element.style.left).toBe('8px')
|
|
60
|
+
// maxWidth stays a stylesheet concern when unbounded.
|
|
61
|
+
expect(element.style.maxWidth).toBe('')
|
|
57
62
|
})
|
|
58
63
|
})
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import CommonPopup, { elementAnchor } from '../common_popup'
|
|
6
|
+
|
|
7
|
+
// Viewport-fit placement: a popup anchored to a caret sitting just above the
|
|
8
|
+
// chat composer has almost no room below it. updatePosition must flip above the
|
|
9
|
+
// anchor, cap the popup to the space it actually has, and re-run once async
|
|
10
|
+
// content (the creative mini-tree) has replaced the "Loading…" placeholder.
|
|
11
|
+
describe('CommonPopup viewport-fit placement', () => {
|
|
12
|
+
const VIEWPORT_WIDTH = 1024
|
|
13
|
+
|
|
14
|
+
let element
|
|
15
|
+
|
|
16
|
+
// offsetHeight is mutable so a test can simulate the popup growing after its
|
|
17
|
+
// async content lands, which is the whole point of reposition().
|
|
18
|
+
const build = ({ popupWidth = 320, popupHeight = 400 } = {}) => {
|
|
19
|
+
element = document.createElement('div')
|
|
20
|
+
Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
|
|
21
|
+
Object.defineProperty(element, 'offsetWidth', { get: () => popupWidth, configurable: true })
|
|
22
|
+
Object.defineProperty(element, 'offsetHeight', {
|
|
23
|
+
get: () => element._testHeight,
|
|
24
|
+
configurable: true,
|
|
25
|
+
})
|
|
26
|
+
element._testHeight = popupHeight
|
|
27
|
+
document.body.appendChild(element)
|
|
28
|
+
return new CommonPopup(element, { listElement: document.createElement('ul') })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const stubVisualViewport = (height) => {
|
|
32
|
+
const listeners = {}
|
|
33
|
+
window.visualViewport = {
|
|
34
|
+
offsetLeft: 0,
|
|
35
|
+
offsetTop: 0,
|
|
36
|
+
width: VIEWPORT_WIDTH,
|
|
37
|
+
height,
|
|
38
|
+
addEventListener: (type, fn) => { listeners[type] = fn },
|
|
39
|
+
removeEventListener: (type) => { delete listeners[type] },
|
|
40
|
+
}
|
|
41
|
+
return listeners
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
document.body.innerHTML = ''
|
|
46
|
+
delete window.visualViewport
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('flips above the anchor when the popup does not fit below it', () => {
|
|
50
|
+
const popup = build({ popupHeight: 400 })
|
|
51
|
+
// Caret on the last line of a composer pinned to the bottom: only 48px
|
|
52
|
+
// of viewport under it, far less than the popup's 400px.
|
|
53
|
+
popup.updatePosition({ left: 200, top: 700, bottom: 720 })
|
|
54
|
+
|
|
55
|
+
// top = anchorTop - gap - height = 700 - 4 - 400
|
|
56
|
+
expect(element.style.top).toBe('296px')
|
|
57
|
+
// Space above = 700 - 4 - 8 = 688, and the popup fits in it untouched.
|
|
58
|
+
expect(element.style.maxHeight).toBe('688px')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('stays below the anchor when there is room for the popup', () => {
|
|
62
|
+
const popup = build({ popupHeight: 400 })
|
|
63
|
+
popup.updatePosition({ left: 200, top: 80, bottom: 100 })
|
|
64
|
+
|
|
65
|
+
expect(element.style.top).toBe('104px')
|
|
66
|
+
// Capped to the space below (768 - 8 - 104) so later growth still fits.
|
|
67
|
+
expect(element.style.maxHeight).toBe('656px')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('caps the popup to the larger side when it fits on neither', () => {
|
|
71
|
+
const popup = build({ popupHeight: 400 })
|
|
72
|
+
// spaceBelow = 768 - 8 - 564 = 196; spaceAbove = 360 - 4 - 8 = 348.
|
|
73
|
+
popup.updatePosition({ left: 200, top: 360, bottom: 560 })
|
|
74
|
+
|
|
75
|
+
expect(element.style.maxHeight).toBe('348px')
|
|
76
|
+
// Rendered height is now the cap, so the top edge sits at the padding.
|
|
77
|
+
expect(element.style.top).toBe('8px')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('never shrinks the popup below a usable minimum', () => {
|
|
81
|
+
const popup = build({ popupHeight: 400 })
|
|
82
|
+
// Anchor pinned to the very bottom: nothing below, 8px above.
|
|
83
|
+
popup.updatePosition({ left: 200, top: 20, bottom: 760 })
|
|
84
|
+
|
|
85
|
+
expect(element.style.maxHeight).toBe('160px')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('measures the popup unconstrained so a stale cap cannot stick', () => {
|
|
89
|
+
const popup = build({ popupHeight: 400 })
|
|
90
|
+
// A previous placement left a tiny cap behind.
|
|
91
|
+
element.style.maxHeight = '130px'
|
|
92
|
+
popup.updatePosition({ left: 200, top: 700, bottom: 720 })
|
|
93
|
+
|
|
94
|
+
// Had the 130px cap been read back as the popup's height, it would have
|
|
95
|
+
// "fit" below the caret and stayed pinned there.
|
|
96
|
+
expect(element.style.top).toBe('296px')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('measures the visual viewport, not the layout viewport', () => {
|
|
100
|
+
// The on-screen keyboard covers the bottom 300px of a 768px window.
|
|
101
|
+
stubVisualViewport(468)
|
|
102
|
+
const popup = build({ popupHeight: 400 })
|
|
103
|
+
popup.updatePosition({ left: 200, top: 400, bottom: 420 })
|
|
104
|
+
|
|
105
|
+
// spaceBelow against the keyboard top = 468 - 8 - 424 = 36, so it flips;
|
|
106
|
+
// spaceAbove = 400 - 4 - 8 = 388, which becomes the cap.
|
|
107
|
+
expect(element.style.maxHeight).toBe('388px')
|
|
108
|
+
expect(element.style.top).toBe('8px')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('reposition after async content', () => {
|
|
112
|
+
let realRaf
|
|
113
|
+
let realCancelRaf
|
|
114
|
+
|
|
115
|
+
beforeEach(() => {
|
|
116
|
+
jest.useFakeTimers()
|
|
117
|
+
realRaf = window.requestAnimationFrame
|
|
118
|
+
realCancelRaf = window.cancelAnimationFrame
|
|
119
|
+
// Stubbed as a pair: the timer id doubles as the frame handle, so
|
|
120
|
+
// cancelAnimationFrame actually drops the pending callback the way
|
|
121
|
+
// the browser would. Stubbing only requestAnimationFrame would make
|
|
122
|
+
// cancellation silently no-op and hide regressions.
|
|
123
|
+
window.requestAnimationFrame = (cb) => setTimeout(cb, 0)
|
|
124
|
+
window.cancelAnimationFrame = (handle) => clearTimeout(handle)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
afterEach(() => {
|
|
128
|
+
window.requestAnimationFrame = realRaf
|
|
129
|
+
window.cancelAnimationFrame = realCancelRaf
|
|
130
|
+
jest.useRealTimers()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const openAndSettle = (popup, anchorRect) => {
|
|
134
|
+
popup.showAt(anchorRect)
|
|
135
|
+
// showAt defers positioning by one animation frame.
|
|
136
|
+
jest.advanceTimersByTime(20)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
test('re-places the popup once its content has grown', () => {
|
|
140
|
+
const popup = build({ popupHeight: 400 })
|
|
141
|
+
// Opens while still showing "Loading…": header + input only.
|
|
142
|
+
element._testHeight = 130
|
|
143
|
+
openAndSettle(popup, { left: 200, top: 700, bottom: 720 })
|
|
144
|
+
expect(element.style.top).toBe('566px')
|
|
145
|
+
|
|
146
|
+
// The mini-tree lands and the popup grows to its full height.
|
|
147
|
+
element._testHeight = 400
|
|
148
|
+
popup.reposition()
|
|
149
|
+
|
|
150
|
+
expect(element.style.top).toBe('296px')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('keeps the popup above the anchor once flipped, even if it later shrinks', () => {
|
|
154
|
+
const popup = build({ popupHeight: 400 })
|
|
155
|
+
openAndSettle(popup, { left: 200, top: 700, bottom: 720 })
|
|
156
|
+
expect(element.style.top).toBe('296px')
|
|
157
|
+
|
|
158
|
+
// Collapsing every tree node shrinks the popup back to a size that
|
|
159
|
+
// would fit below — hopping across the caret would be jarring.
|
|
160
|
+
element._testHeight = 120
|
|
161
|
+
popup.reposition()
|
|
162
|
+
|
|
163
|
+
expect(element.style.top).toBe('576px')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('does nothing when the popup is closed', () => {
|
|
167
|
+
const popup = build({ popupHeight: 400 })
|
|
168
|
+
popup.reposition()
|
|
169
|
+
expect(element.style.top).toBe('')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('re-places on visual viewport resize while open, and unbinds on hide', () => {
|
|
173
|
+
const listeners = stubVisualViewport(768)
|
|
174
|
+
const popup = build({ popupHeight: 400 })
|
|
175
|
+
openAndSettle(popup, { left: 200, top: 300, bottom: 320 })
|
|
176
|
+
// spaceBelow = 768 - 8 - 324 = 436, enough for 400px.
|
|
177
|
+
expect(element.style.top).toBe('324px')
|
|
178
|
+
expect(element.style.maxHeight).toBe('436px')
|
|
179
|
+
expect(typeof listeners.resize).toBe('function')
|
|
180
|
+
|
|
181
|
+
// The on-screen keyboard opens and eats the bottom of the viewport.
|
|
182
|
+
window.visualViewport.height = 400
|
|
183
|
+
listeners.resize()
|
|
184
|
+
|
|
185
|
+
// spaceBelow = 400 - 8 - 324 = 68; spaceAbove = 300 - 4 - 8 = 288.
|
|
186
|
+
expect(element.style.maxHeight).toBe('288px')
|
|
187
|
+
expect(element.style.top).toBe('8px')
|
|
188
|
+
|
|
189
|
+
popup.hide()
|
|
190
|
+
expect(listeners.resize).toBeUndefined()
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// The placement frame runs a tick after showAt, so a close that lands in
|
|
194
|
+
// between must not leave document/visualViewport holding this popup:
|
|
195
|
+
// hide()'s removeEventListener calls run before the additions would.
|
|
196
|
+
test('registers nothing when the popup is closed before its placement frame runs', () => {
|
|
197
|
+
const listeners = stubVisualViewport(768)
|
|
198
|
+
const addSpy = jest.spyOn(document, 'addEventListener')
|
|
199
|
+
const popup = build({ popupHeight: 400 })
|
|
200
|
+
|
|
201
|
+
popup.showAt({ left: 200, top: 300, bottom: 320 })
|
|
202
|
+
popup.hide()
|
|
203
|
+
jest.advanceTimersByTime(20)
|
|
204
|
+
|
|
205
|
+
expect(listeners.resize).toBeUndefined()
|
|
206
|
+
expect(addSpy.mock.calls.some(([type]) => type === 'mousedown' || type === 'touchstart')).toBe(false)
|
|
207
|
+
// The cancelled frame must not re-show what hide() closed.
|
|
208
|
+
expect(element.style.display).toBe('none')
|
|
209
|
+
addSpy.mockRestore()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Turbo swaps the page without closing the popup: the element is gone
|
|
213
|
+
// from the document but still reads display:block.
|
|
214
|
+
test('registers nothing when the element is detached before its placement frame runs', () => {
|
|
215
|
+
const listeners = stubVisualViewport(768)
|
|
216
|
+
const addSpy = jest.spyOn(document, 'addEventListener')
|
|
217
|
+
const popup = build({ popupHeight: 400 })
|
|
218
|
+
|
|
219
|
+
popup.showAt({ left: 200, top: 300, bottom: 320 })
|
|
220
|
+
element.remove()
|
|
221
|
+
jest.advanceTimersByTime(20)
|
|
222
|
+
|
|
223
|
+
expect(listeners.resize).toBeUndefined()
|
|
224
|
+
expect(addSpy.mock.calls.some(([type]) => type === 'mousedown' || type === 'touchstart')).toBe(false)
|
|
225
|
+
addSpy.mockRestore()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Reopening before the first frame fires must drop the superseded one,
|
|
229
|
+
// so the popup is never placed against the anchor it has abandoned.
|
|
230
|
+
test('supersedes a pending placement frame when reopened', () => {
|
|
231
|
+
const popup = build({ popupHeight: 400 })
|
|
232
|
+
const placeSpy = jest.spyOn(popup, 'updatePosition')
|
|
233
|
+
|
|
234
|
+
popup.showAt({ left: 200, top: 300, bottom: 320 })
|
|
235
|
+
popup.showAt({ left: 200, top: 700, bottom: 720 })
|
|
236
|
+
jest.advanceTimersByTime(20)
|
|
237
|
+
|
|
238
|
+
expect(placeSpy).toHaveBeenCalledTimes(1)
|
|
239
|
+
expect(placeSpy).toHaveBeenCalledWith({ left: 200, top: 700, bottom: 720 })
|
|
240
|
+
expect(element.style.top).toBe('296px')
|
|
241
|
+
placeSpy.mockRestore()
|
|
242
|
+
})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// A rect captured at open time stops describing the anchor as soon as the
|
|
246
|
+
// anchor moves, and the one event that reliably moves it is the same one
|
|
247
|
+
// that triggers a re-place: the on-screen keyboard shrinks the viewport and
|
|
248
|
+
// comments--presence pushes the composer up off the keyboard on that very
|
|
249
|
+
// resize. Callers that can re-measure pass a provider instead of a rect.
|
|
250
|
+
describe('live anchor providers', () => {
|
|
251
|
+
let realRaf
|
|
252
|
+
let realCancelRaf
|
|
253
|
+
|
|
254
|
+
beforeEach(() => {
|
|
255
|
+
jest.useFakeTimers()
|
|
256
|
+
realRaf = window.requestAnimationFrame
|
|
257
|
+
realCancelRaf = window.cancelAnimationFrame
|
|
258
|
+
window.requestAnimationFrame = (cb) => setTimeout(cb, 0)
|
|
259
|
+
window.cancelAnimationFrame = (handle) => clearTimeout(handle)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
afterEach(() => {
|
|
263
|
+
window.requestAnimationFrame = realRaf
|
|
264
|
+
window.cancelAnimationFrame = realCancelRaf
|
|
265
|
+
jest.useRealTimers()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const openAndSettle = (popup, anchor) => {
|
|
269
|
+
popup.showAt(anchor)
|
|
270
|
+
jest.advanceTimersByTime(20)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
test('re-measures the anchor on every reposition', () => {
|
|
274
|
+
const popup = build({ popupHeight: 300 })
|
|
275
|
+
let caret = { left: 200, top: 300, bottom: 320 }
|
|
276
|
+
openAndSettle(popup, () => caret)
|
|
277
|
+
expect(element.style.top).toBe('324px')
|
|
278
|
+
|
|
279
|
+
// The composer scrolled: the caret is 120px higher than at open.
|
|
280
|
+
caret = { left: 200, top: 180, bottom: 200 }
|
|
281
|
+
popup.reposition()
|
|
282
|
+
|
|
283
|
+
expect(element.style.top).toBe('204px')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('follows the anchor the keyboard pushed up, not its opening rect', () => {
|
|
287
|
+
const listeners = stubVisualViewport(768)
|
|
288
|
+
const popup = build({ popupHeight: 300 })
|
|
289
|
+
// Caret on a composer pinned to the bottom of a full-height viewport.
|
|
290
|
+
let caret = { left: 200, top: 700, bottom: 720 }
|
|
291
|
+
openAndSettle(popup, () => caret)
|
|
292
|
+
expect(element.style.top).toBe('396px')
|
|
293
|
+
|
|
294
|
+
// The keyboard takes the bottom 368px. comments--presence lifts the
|
|
295
|
+
// composer clear of it on the same resize, so the caret rides up
|
|
296
|
+
// with it — its opening rect now sits *under* the keyboard.
|
|
297
|
+
window.visualViewport.height = 400
|
|
298
|
+
caret = { left: 200, top: 332, bottom: 352 }
|
|
299
|
+
listeners.resize()
|
|
300
|
+
|
|
301
|
+
// Flipped above the lifted caret: 332 - 4 - 300.
|
|
302
|
+
expect(element.style.top).toBe('28px')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('keeps the last measured rect when the anchor disappears', () => {
|
|
306
|
+
const popup = build({ popupHeight: 300 })
|
|
307
|
+
let caret = { left: 200, top: 300, bottom: 320 }
|
|
308
|
+
openAndSettle(popup, () => caret)
|
|
309
|
+
expect(element.style.top).toBe('324px')
|
|
310
|
+
|
|
311
|
+
// Turbo tore the textarea out: nothing left to measure. Reading a
|
|
312
|
+
// detached element's rect would report all zeros and fling the
|
|
313
|
+
// popup into the top-left corner.
|
|
314
|
+
caret = null
|
|
315
|
+
popup.reposition()
|
|
316
|
+
|
|
317
|
+
expect(element.style.top).toBe('324px')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('keeps the last measured rect when the provider throws', () => {
|
|
321
|
+
const popup = build({ popupHeight: 300 })
|
|
322
|
+
let broken = false
|
|
323
|
+
openAndSettle(popup, () => {
|
|
324
|
+
if (broken) throw new Error('anchor gone')
|
|
325
|
+
return { left: 200, top: 300, bottom: 320 }
|
|
326
|
+
})
|
|
327
|
+
expect(element.style.top).toBe('324px')
|
|
328
|
+
|
|
329
|
+
broken = true
|
|
330
|
+
expect(() => popup.reposition()).not.toThrow()
|
|
331
|
+
expect(element.style.top).toBe('324px')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('drops the provider on hide so the next open cannot reuse it', () => {
|
|
335
|
+
const popup = build({ popupHeight: 300 })
|
|
336
|
+
const provider = jest.fn(() => ({ left: 200, top: 300, bottom: 320 }))
|
|
337
|
+
openAndSettle(popup, provider)
|
|
338
|
+
popup.hide()
|
|
339
|
+
|
|
340
|
+
const callsWhileOpen = provider.mock.calls.length
|
|
341
|
+
openAndSettle(popup, { left: 200, top: 100, bottom: 120 })
|
|
342
|
+
popup.reposition()
|
|
343
|
+
|
|
344
|
+
expect(provider).toHaveBeenCalledTimes(callsWhileOpen)
|
|
345
|
+
expect(element.style.top).toBe('124px')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('elementAnchor', () => {
|
|
349
|
+
test('measures the element live', () => {
|
|
350
|
+
const anchor = document.createElement('button')
|
|
351
|
+
document.body.appendChild(anchor)
|
|
352
|
+
anchor.getBoundingClientRect = () => ({ left: 10, top: 500, bottom: 540 })
|
|
353
|
+
const provider = elementAnchor(anchor)
|
|
354
|
+
|
|
355
|
+
expect(provider().top).toBe(500)
|
|
356
|
+
anchor.getBoundingClientRect = () => ({ left: 10, top: 200, bottom: 240 })
|
|
357
|
+
expect(provider().top).toBe(200)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('returns nothing once the element has left the document', () => {
|
|
361
|
+
const anchor = document.createElement('button')
|
|
362
|
+
document.body.appendChild(anchor)
|
|
363
|
+
const provider = elementAnchor(anchor)
|
|
364
|
+
anchor.remove()
|
|
365
|
+
|
|
366
|
+
expect(provider()).toBeNull()
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
test('returns nothing when there is no element at all', () => {
|
|
370
|
+
expect(elementAnchor(null)()).toBeNull()
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
})
|
|
374
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import CommonPopup from '../common_popup'
|
|
6
|
+
|
|
7
|
+
// Typeahead menus (command menu, mention menu) call showAt on every keystroke to
|
|
8
|
+
// re-anchor a popup that is already on screen. showAt used to blank the element
|
|
9
|
+
// to visibility:hidden and wait a frame before revealing it again, so the menu
|
|
10
|
+
// blinked off and back on once per character typed.
|
|
11
|
+
describe('CommonPopup re-show while already open', () => {
|
|
12
|
+
let element
|
|
13
|
+
let frames
|
|
14
|
+
|
|
15
|
+
const build = ({ popupHeight = 100 } = {}) => {
|
|
16
|
+
element = document.createElement('div')
|
|
17
|
+
Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
|
|
18
|
+
Object.defineProperty(element, 'offsetWidth', { get: () => 320, configurable: true })
|
|
19
|
+
Object.defineProperty(element, 'offsetHeight', { get: () => popupHeight, configurable: true })
|
|
20
|
+
document.body.appendChild(element)
|
|
21
|
+
return new CommonPopup(element, { listElement: document.createElement('ul') })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const runFrames = () => {
|
|
25
|
+
const pending = frames.splice(0)
|
|
26
|
+
pending.forEach((fn) => fn())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
frames = []
|
|
31
|
+
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((fn) => {
|
|
32
|
+
frames.push(fn)
|
|
33
|
+
return frames.length
|
|
34
|
+
})
|
|
35
|
+
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => { })
|
|
36
|
+
global.requestAnimationFrame = window.requestAnimationFrame
|
|
37
|
+
global.cancelAnimationFrame = window.cancelAnimationFrame
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
jest.restoreAllMocks()
|
|
42
|
+
document.body.innerHTML = ''
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('the first open hides the popup for a frame while it is measured', () => {
|
|
46
|
+
const popup = build()
|
|
47
|
+
popup.showAt({ left: 10, top: 100, bottom: 120 })
|
|
48
|
+
|
|
49
|
+
expect(element.style.display).toBe('block')
|
|
50
|
+
expect(element.style.visibility).toBe('hidden')
|
|
51
|
+
|
|
52
|
+
runFrames()
|
|
53
|
+
expect(element.style.visibility).toBe('visible')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('re-anchoring an open popup never blanks it', () => {
|
|
57
|
+
const popup = build()
|
|
58
|
+
popup.showAt({ left: 10, top: 100, bottom: 120 })
|
|
59
|
+
runFrames()
|
|
60
|
+
|
|
61
|
+
const seen = []
|
|
62
|
+
for (const left of [20, 30, 40, 50]) {
|
|
63
|
+
popup.showAt({ left, top: 100, bottom: 120 })
|
|
64
|
+
seen.push(element.style.visibility)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(seen).toEqual(['visible', 'visible', 'visible', 'visible'])
|
|
68
|
+
expect(element.style.display).toBe('block')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('re-anchoring places the popup immediately instead of a frame late', () => {
|
|
72
|
+
const popup = build()
|
|
73
|
+
popup.showAt({ left: 10, top: 100, bottom: 120 })
|
|
74
|
+
runFrames()
|
|
75
|
+
expect(element.style.left).toBe('10px')
|
|
76
|
+
|
|
77
|
+
// No frame is run: the new position must already be applied.
|
|
78
|
+
popup.showAt({ left: 240, top: 100, bottom: 120 })
|
|
79
|
+
expect(element.style.left).toBe('240px')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('a popup flipped above the caret stays above when re-anchored', () => {
|
|
83
|
+
// 400px popup with the caret pinned near the bottom of a 768px viewport:
|
|
84
|
+
// it cannot fit below, so the first placement flips it above.
|
|
85
|
+
const popup = build({ popupHeight: 400 })
|
|
86
|
+
window.visualViewport = {
|
|
87
|
+
offsetLeft: 0, offsetTop: 0, width: 1024, height: 768,
|
|
88
|
+
addEventListener: () => { }, removeEventListener: () => { },
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
popup.showAt({ left: 200, top: 700, bottom: 720 })
|
|
92
|
+
runFrames()
|
|
93
|
+
expect(popup._placedAbove).toBe(true)
|
|
94
|
+
const flippedTop = element.style.top
|
|
95
|
+
|
|
96
|
+
popup.showAt({ left: 210, top: 700, bottom: 720 })
|
|
97
|
+
expect(popup._placedAbove).toBe(true)
|
|
98
|
+
expect(element.style.top).toBe(flippedTop)
|
|
99
|
+
|
|
100
|
+
delete window.visualViewport
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('re-registers the outside-click listeners so a re-open cannot self-close', () => {
|
|
104
|
+
const addSpy = jest.spyOn(document, 'addEventListener')
|
|
105
|
+
const removeSpy = jest.spyOn(document, 'removeEventListener')
|
|
106
|
+
const popup = build()
|
|
107
|
+
|
|
108
|
+
popup.showAt({ left: 10, top: 100, bottom: 120 })
|
|
109
|
+
runFrames()
|
|
110
|
+
popup.showAt({ left: 20, top: 100, bottom: 120 })
|
|
111
|
+
|
|
112
|
+
expect(removeSpy).toHaveBeenCalledWith('mousedown', popup.handleOutsideClick)
|
|
113
|
+
runFrames()
|
|
114
|
+
expect(addSpy).toHaveBeenCalledWith('mousedown', popup.handleOutsideClick)
|
|
115
|
+
expect(popup.isOpen()).toBe(true)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('hide() after a re-anchor still closes the popup', () => {
|
|
119
|
+
const popup = build()
|
|
120
|
+
popup.showAt({ left: 10, top: 100, bottom: 120 })
|
|
121
|
+
runFrames()
|
|
122
|
+
popup.showAt({ left: 20, top: 100, bottom: 120 })
|
|
123
|
+
popup.hide()
|
|
124
|
+
|
|
125
|
+
expect(element.style.display).toBe('none')
|
|
126
|
+
expect(popup.isOpen()).toBe(false)
|
|
127
|
+
// _placedAbove is recomputed by the next genuine open.
|
|
128
|
+
popup.showAt({ left: 10, top: 100, bottom: 120 })
|
|
129
|
+
expect(element.style.visibility).toBe('hidden')
|
|
130
|
+
})
|
|
131
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { isCreativeVisitNavigation } from "../creative_link_prefetch"
|
|
5
|
+
|
|
6
|
+
function prefetch(link) {
|
|
7
|
+
const event = new Event("turbo:before-prefetch", { bubbles: true, cancelable: true })
|
|
8
|
+
link.dispatchEvent(event)
|
|
9
|
+
return event
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = ""
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("prevents prefetching any same-origin Creative navigation link", () => {
|
|
17
|
+
const links = [ "/creatives/1", "/collavre/creatives/2", "/workspace/creatives?id=3" ].map((href) => {
|
|
18
|
+
const link = document.createElement("a")
|
|
19
|
+
link.href = href
|
|
20
|
+
document.body.appendChild(link)
|
|
21
|
+
return link
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
links.forEach((link) => expect(prefetch(link).defaultPrevented).toBe(true))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("does not prevent prefetching unrelated or external links", () => {
|
|
28
|
+
const unrelated = document.createElement("a")
|
|
29
|
+
unrelated.href = "/users/1"
|
|
30
|
+
const external = document.createElement("a")
|
|
31
|
+
external.href = "https://example.com/creatives/1"
|
|
32
|
+
document.body.append(unrelated, external)
|
|
33
|
+
|
|
34
|
+
expect(prefetch(unrelated).defaultPrevented).toBe(false)
|
|
35
|
+
expect(prefetch(external).defaultPrevented).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test("identifies only Creative index and show routes as visit navigations", () => {
|
|
39
|
+
[ "/creatives", "/creatives.html", "/collavre/creatives/2" ].forEach((url) => {
|
|
40
|
+
expect(isCreativeVisitNavigation(url)).toBe(true)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
;[ "/creatives/2/comments/3/activity_log", "/creatives/next_last_visited_sequence" ].forEach((url) => {
|
|
44
|
+
expect(isCreativeVisitNavigation(url)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
const csrfFetch = jest.fn()
|
|
8
|
+
const refreshCsrfToken = jest.fn()
|
|
9
|
+
|
|
10
|
+
jest.unstable_mockModule('../api/csrf_fetch', () => ({
|
|
11
|
+
default: csrfFetch,
|
|
12
|
+
refreshCsrfToken,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
prepareLastVisitedCreativeNavigation,
|
|
17
|
+
rememberLastVisitedCreative,
|
|
18
|
+
} = await import('../last_visited_creative')
|
|
19
|
+
|
|
20
|
+
describe('rememberLastVisitedCreative', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
csrfFetch.mockReset()
|
|
23
|
+
refreshCsrfToken.mockReset()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('cancels the CSRF refresh when a newer visit supersedes a restored visit', async () => {
|
|
27
|
+
csrfFetch
|
|
28
|
+
.mockResolvedValue({ ok: true, json: async () => ({ sequence: 3 }) })
|
|
29
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ sequence: 2 }) })
|
|
30
|
+
.mockResolvedValueOnce({ ok: false, status: 422 })
|
|
31
|
+
refreshCsrfToken.mockImplementationOnce(({ signal }) => new Promise((resolve) => {
|
|
32
|
+
signal.addEventListener('abort', resolve, { once: true })
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
rememberLastVisitedCreative('/creatives', 1, 'restored-token')
|
|
36
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
37
|
+
rememberLastVisitedCreative('/creatives', 2, 'newer-token')
|
|
38
|
+
|
|
39
|
+
expect(refreshCsrfToken).toHaveBeenCalledWith(expect.objectContaining({ signal: expect.any(AbortSignal) }))
|
|
40
|
+
expect(csrfFetch).toHaveBeenCalledWith('/creatives/next_last_visited_sequence', expect.objectContaining({ method: 'PATCH' }))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('refreshes CSRF and retries sequence reservation before remembering a restored Creative', async () => {
|
|
44
|
+
csrfFetch
|
|
45
|
+
.mockResolvedValueOnce({ ok: false, status: 422 })
|
|
46
|
+
.mockResolvedValueOnce({ ok: true, json: async () => ({ sequence: 2 }) })
|
|
47
|
+
.mockResolvedValueOnce({ ok: true })
|
|
48
|
+
|
|
49
|
+
rememberLastVisitedCreative('/creatives', 1, 'restored-token')
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
51
|
+
|
|
52
|
+
expect(refreshCsrfToken).toHaveBeenCalledWith(expect.objectContaining({ signal: expect.any(AbortSignal) }))
|
|
53
|
+
expect(csrfFetch).toHaveBeenNthCalledWith(1, '/creatives/next_last_visited_sequence', expect.objectContaining({ method: 'PATCH' }))
|
|
54
|
+
expect(csrfFetch).toHaveBeenNthCalledWith(2, '/creatives/next_last_visited_sequence', expect.objectContaining({ method: 'PATCH' }))
|
|
55
|
+
expect(csrfFetch).toHaveBeenNthCalledWith(3, '/creatives/1/remember_last_visited?visit_token=restored-token', expect.objectContaining({ method: 'PATCH' }))
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('does not reserve a sequence for an unrelated Turbo navigation', async () => {
|
|
59
|
+
const fetchOptions = { method: 'GET', headers: new Headers() }
|
|
60
|
+
const resume = jest.fn()
|
|
61
|
+
const event = new CustomEvent('turbo:before-fetch-request', {
|
|
62
|
+
cancelable: true,
|
|
63
|
+
detail: { fetchOptions, resume, url: '/users' },
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await prepareLastVisitedCreativeNavigation(event, '/creatives', 'server-token')
|
|
67
|
+
|
|
68
|
+
expect(event.defaultPrevented).toBe(false)
|
|
69
|
+
expect(resume).not.toHaveBeenCalled()
|
|
70
|
+
expect(csrfFetch).not.toHaveBeenCalled()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('does not reserve a sequence for a nested Creative Turbo frame request', async () => {
|
|
74
|
+
const fetchOptions = { method: 'GET', headers: new Headers() }
|
|
75
|
+
const resume = jest.fn()
|
|
76
|
+
const event = new CustomEvent('turbo:before-fetch-request', {
|
|
77
|
+
cancelable: true,
|
|
78
|
+
detail: { fetchOptions, resume, url: '/creatives/1/comments/2/activity_log' },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await prepareLastVisitedCreativeNavigation(event, '/creatives', 'server-token')
|
|
82
|
+
|
|
83
|
+
expect(event.defaultPrevented).toBe(false)
|
|
84
|
+
expect(resume).not.toHaveBeenCalled()
|
|
85
|
+
expect(csrfFetch).not.toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
})
|