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
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
// Gap between the anchor (caret, button) and the popup edge.
|
|
2
|
+
const ANCHOR_GAP = 4
|
|
3
|
+
// Breathing room kept between the popup and the edge of its region.
|
|
4
|
+
const BOUNDS_PADDING = 8
|
|
5
|
+
// Floor for the space-derived max-height. Header + search input already eat
|
|
6
|
+
// ~90px, so anything under this leaves no list at all; overflowing the region
|
|
7
|
+
// slightly beats rendering a sliver.
|
|
8
|
+
const MIN_POPUP_HEIGHT = 160
|
|
9
|
+
|
|
10
|
+
// Anchor provider for a popup hung off a live element. Pass this to showAt
|
|
11
|
+
// instead of the element's rect so placement can re-measure it later; returns
|
|
12
|
+
// null once the element leaves the document, where its rect would read as all
|
|
13
|
+
// zeros and drag the popup into the top-left corner.
|
|
14
|
+
export function elementAnchor(element) {
|
|
15
|
+
return () => (element?.isConnected ? element.getBoundingClientRect() : null)
|
|
16
|
+
}
|
|
17
|
+
|
|
1
18
|
export default class CommonPopup {
|
|
2
19
|
constructor(element, { listElement, onSelect, renderItem, onClose, closeOnOutsideClick = true } = {}) {
|
|
3
20
|
this.element = element
|
|
@@ -8,16 +25,49 @@ export default class CommonPopup {
|
|
|
8
25
|
this.closeOnOutsideClick = closeOnOutsideClick
|
|
9
26
|
this.items = []
|
|
10
27
|
this.activeIndex = -1
|
|
28
|
+
// Pending showAt placement frame, so hide() can cancel it.
|
|
29
|
+
this._openFrame = null
|
|
30
|
+
// Set by showAt when the caller can re-measure its anchor — see anchorRect.
|
|
31
|
+
this._anchorProvider = null
|
|
32
|
+
this._anchorRect = null
|
|
33
|
+
// True once the placement frame has actually revealed the popup. isOpen() is
|
|
34
|
+
// already true during the frame between display:block and that reveal, so it
|
|
35
|
+
// cannot tell "on screen" apart from "measured but still blank" — and only
|
|
36
|
+
// the first of those may skip the blanking step. See showAt.
|
|
37
|
+
this._revealed = false
|
|
11
38
|
|
|
12
39
|
this.handleOutsideClick = this.handleOutsideClick.bind(this)
|
|
40
|
+
this.handleViewportResize = this.handleViewportResize.bind(this)
|
|
13
41
|
}
|
|
14
42
|
|
|
15
|
-
|
|
43
|
+
// `anchor` is either a rect or a function returning one. Prefer the function
|
|
44
|
+
// (see elementAnchor / caretAnchor): a rect is a snapshot, and the anchor can
|
|
45
|
+
// move while the popup is open — the on-screen keyboard shrinks the viewport
|
|
46
|
+
// and comments--presence lifts the composer clear of it on that same resize,
|
|
47
|
+
// so by the time we re-place, the opening rect points under the keyboard.
|
|
48
|
+
// Rects stay supported for anchors with nothing to re-measure, such as one
|
|
49
|
+
// derived from a text selection that has since been cleared.
|
|
50
|
+
showAt(anchor, boundsElement = null) {
|
|
16
51
|
if (!this.element) return
|
|
17
52
|
|
|
53
|
+
// Typeahead popups call showAt on every keystroke to re-anchor a menu that
|
|
54
|
+
// is already up (see modules/command_menu.js). Blanking it to
|
|
55
|
+
// visibility:hidden and waiting a frame would then blink the menu off and
|
|
56
|
+
// back on once per character, so a popup already on screen is only re-placed.
|
|
57
|
+
const wasRevealed = this._revealed && this.element.isConnected
|
|
58
|
+
|
|
18
59
|
// When set, the popup is caged inside this element's rect (e.g. the chat
|
|
19
60
|
// box) instead of the viewport — see updatePosition.
|
|
20
61
|
this._boundsElement = boundsElement
|
|
62
|
+
this._anchorProvider = typeof anchor === 'function' ? anchor : null
|
|
63
|
+
// Last known anchor geometry, so reposition() can re-place after the
|
|
64
|
+
// popup's content has changed size, and so a provider that stops resolving
|
|
65
|
+
// has something to fall back on.
|
|
66
|
+
this._anchorRect = this._anchorProvider ? null : anchor
|
|
67
|
+
// Keep the flip decision for the rest of this open: re-anchoring a menu
|
|
68
|
+
// that is already above the caret must not drop it back under the caret
|
|
69
|
+
// for a frame before it flips up again.
|
|
70
|
+
if (!wasRevealed) this._placedAbove = false
|
|
21
71
|
|
|
22
72
|
// Re-opening while already open (e.g. clicking the same typo mark twice):
|
|
23
73
|
// a listener from the previous open is still live, so the opening mousedown
|
|
@@ -28,11 +78,28 @@ export default class CommonPopup {
|
|
|
28
78
|
document.removeEventListener('touchstart', this.handleOutsideClick)
|
|
29
79
|
|
|
30
80
|
this.element.style.display = 'block'
|
|
31
|
-
|
|
81
|
+
if (wasRevealed) {
|
|
82
|
+
// Already painted, so place it now rather than a frame late — otherwise
|
|
83
|
+
// the menu sits at the previous caret's position for one frame.
|
|
84
|
+
this.updatePosition(this.anchorRect())
|
|
85
|
+
} else {
|
|
86
|
+
this.element.style.visibility = 'hidden'
|
|
87
|
+
}
|
|
32
88
|
|
|
33
|
-
|
|
34
|
-
|
|
89
|
+
cancelAnimationFrame(this._openFrame)
|
|
90
|
+
this._openFrame = requestAnimationFrame(() => {
|
|
91
|
+
this._openFrame = null
|
|
92
|
+
// The popup can be gone before this frame runs: hide() on a rapid
|
|
93
|
+
// open/close, or a Turbo navigation that tears the element out of the
|
|
94
|
+
// document without closing the popup. Registering here regardless would
|
|
95
|
+
// leave document/visualViewport holding this popup and its detached
|
|
96
|
+
// element forever, and hide()'s removals already ran against listeners
|
|
97
|
+
// that did not exist yet.
|
|
98
|
+
if (!this.isOpen() || !this.element.isConnected) return
|
|
99
|
+
|
|
100
|
+
this.updatePosition(this.anchorRect())
|
|
35
101
|
this.element.style.visibility = 'visible'
|
|
102
|
+
this._revealed = true
|
|
36
103
|
// Register the outside-click listeners only after the opening event has
|
|
37
104
|
// finished propagating. When a popup is opened from a mousedown handler
|
|
38
105
|
// (e.g. clicking a typo highlight), registering synchronously here would
|
|
@@ -43,42 +110,106 @@ export default class CommonPopup {
|
|
|
43
110
|
document.addEventListener('mousedown', this.handleOutsideClick)
|
|
44
111
|
document.addEventListener('touchstart', this.handleOutsideClick)
|
|
45
112
|
}
|
|
113
|
+
// The on-screen keyboard shrinks the visual viewport without firing a
|
|
114
|
+
// window resize, and it opens *after* showAt (the consumer focuses its
|
|
115
|
+
// input a frame later), so the placement made here is already stale.
|
|
116
|
+
window.visualViewport?.addEventListener('resize', this.handleViewportResize)
|
|
46
117
|
})
|
|
47
118
|
}
|
|
48
119
|
|
|
120
|
+
// Re-run placement against the anchor showAt was given. Call this whenever the
|
|
121
|
+
// popup's content changes size: showAt measures one frame after opening, so a
|
|
122
|
+
// popup whose body arrives asynchronously (a fetched tree, search results) was
|
|
123
|
+
// sized while it still read "Loading…" and would otherwise keep the placement
|
|
124
|
+
// that placeholder height implied.
|
|
125
|
+
reposition() {
|
|
126
|
+
if (!this.isOpen()) return
|
|
127
|
+
this.updatePosition(this.anchorRect())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The anchor's geometry now, not as it was when showAt ran.
|
|
131
|
+
anchorRect() {
|
|
132
|
+
if (!this._anchorProvider) return this._anchorRect
|
|
133
|
+
|
|
134
|
+
let rect = null
|
|
135
|
+
try {
|
|
136
|
+
rect = this._anchorProvider()
|
|
137
|
+
} catch {
|
|
138
|
+
rect = null
|
|
139
|
+
}
|
|
140
|
+
// The anchor can go away mid-open (Turbo swap, a textarea replaced by a
|
|
141
|
+
// stream update). Keep the last place we saw it rather than collapsing to
|
|
142
|
+
// the origin.
|
|
143
|
+
if (rect) this._anchorRect = rect
|
|
144
|
+
return this._anchorRect
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
handleViewportResize() {
|
|
148
|
+
this.reposition()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The region the popup must stay inside: an explicit bounds element (the chat
|
|
152
|
+
// box) when caged, otherwise the visual viewport. window.innerHeight is the
|
|
153
|
+
// wrong number on mobile — it still counts the strip the keyboard covers.
|
|
154
|
+
regionRect() {
|
|
155
|
+
const bounds = this._boundsElement?.getBoundingClientRect?.()
|
|
156
|
+
if (bounds) return bounds
|
|
157
|
+
|
|
158
|
+
const viewport = window.visualViewport
|
|
159
|
+
const left = viewport?.offsetLeft || 0
|
|
160
|
+
const top = viewport?.offsetTop || 0
|
|
161
|
+
const width = viewport?.width || window.innerWidth
|
|
162
|
+
const height = viewport?.height || window.innerHeight
|
|
163
|
+
return { left, top, right: left + width, bottom: top + height, width, height }
|
|
164
|
+
}
|
|
165
|
+
|
|
49
166
|
updatePosition(anchorRect) {
|
|
50
167
|
if (!this.element) return
|
|
51
168
|
|
|
52
|
-
const scrollX = window.scrollX || window.pageXOffset || 0
|
|
53
|
-
const scrollY = window.scrollY || window.pageYOffset || 0
|
|
54
169
|
const offsetParent = this.element.offsetParent
|
|
55
170
|
const parentRect = offsetParent?.getBoundingClientRect?.() || { left: 0, top: 0 }
|
|
56
171
|
const parentScrollX = offsetParent?.scrollLeft || 0
|
|
57
172
|
const parentScrollY = offsetParent?.scrollTop || 0
|
|
58
|
-
const boundsPadding = 8
|
|
59
173
|
const rect = anchorRect || this.element.getBoundingClientRect()
|
|
174
|
+
const anchorTop = rect?.top || 0
|
|
175
|
+
const anchorBottom = rect?.bottom || 0
|
|
60
176
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
177
|
+
// Measure unconstrained. A max-height left behind by an earlier placement
|
|
178
|
+
// would make offsetHeight report that cap instead of the content height, so
|
|
179
|
+
// every later call would agree the popup "fits" wherever it was first put.
|
|
180
|
+
this.element.style.maxHeight = ''
|
|
64
181
|
const { offsetWidth: width, offsetHeight: height } = this.element
|
|
65
182
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
183
|
+
const region = this.regionRect()
|
|
184
|
+
const spaceBelow = region.bottom - BOUNDS_PADDING - (anchorBottom + ANCHOR_GAP)
|
|
185
|
+
const spaceAbove = (anchorTop - ANCHOR_GAP) - (region.top + BOUNDS_PADDING)
|
|
186
|
+
|
|
187
|
+
// Flip above the anchor when the popup genuinely does not fit below it and
|
|
188
|
+
// there is more room up there — a chat composer pinned to the bottom of the
|
|
189
|
+
// screen leaves almost nothing under the caret. Once flipped, stay flipped
|
|
190
|
+
// for the rest of this open: content that shrinks again (collapsing tree
|
|
191
|
+
// nodes) must not make the popup hop back across the anchor.
|
|
192
|
+
const placeAbove = this._placedAbove || (height > spaceBelow && spaceAbove > spaceBelow)
|
|
193
|
+
this._placedAbove = placeAbove
|
|
194
|
+
|
|
195
|
+
// Cap to the space actually available so the list scrolls inside the popup
|
|
196
|
+
// instead of running off-screen, both now and if the content grows later.
|
|
197
|
+
const available = Math.max(placeAbove ? spaceAbove : spaceBelow, MIN_POPUP_HEIGHT)
|
|
198
|
+
this.element.style.maxHeight = `${available}px`
|
|
199
|
+
if (this._boundsElement) {
|
|
200
|
+
this.element.style.maxWidth = `${region.width - BOUNDS_PADDING * 2}px`
|
|
81
201
|
}
|
|
202
|
+
const renderedHeight = Math.min(height, available)
|
|
203
|
+
|
|
204
|
+
let viewportLeft = rect?.left || 0
|
|
205
|
+
let viewportTop = placeAbove
|
|
206
|
+
? anchorTop - ANCHOR_GAP - renderedHeight
|
|
207
|
+
: anchorBottom + ANCHOR_GAP
|
|
208
|
+
|
|
209
|
+
const minLeft = region.left + BOUNDS_PADDING
|
|
210
|
+
const maxLeft = region.right - width - BOUNDS_PADDING
|
|
211
|
+
const minTop = region.top + BOUNDS_PADDING
|
|
212
|
+
const maxTop = region.bottom - renderedHeight - BOUNDS_PADDING
|
|
82
213
|
|
|
83
214
|
viewportLeft = Math.max(minLeft, Math.min(viewportLeft, maxLeft))
|
|
84
215
|
viewportTop = Math.max(minTop, Math.min(viewportTop, maxTop))
|
|
@@ -200,14 +331,22 @@ export default class CommonPopup {
|
|
|
200
331
|
|
|
201
332
|
hide(reason = 'manual') {
|
|
202
333
|
if (!this.element || !this.isOpen()) return
|
|
334
|
+
// Drop a placement frame that has not run yet, so it cannot re-show and
|
|
335
|
+
// re-register listeners after this close.
|
|
336
|
+
cancelAnimationFrame(this._openFrame)
|
|
337
|
+
this._openFrame = null
|
|
203
338
|
this.element.style.display = 'none'
|
|
204
339
|
this.element.style.visibility = ''
|
|
340
|
+
this._revealed = false
|
|
205
341
|
this.items = []
|
|
206
342
|
this.activeIndex = -1
|
|
207
343
|
this.updateActiveItem()
|
|
208
344
|
|
|
209
345
|
document.removeEventListener('mousedown', this.handleOutsideClick)
|
|
210
346
|
document.removeEventListener('touchstart', this.handleOutsideClick)
|
|
347
|
+
window.visualViewport?.removeEventListener('resize', this.handleViewportResize)
|
|
348
|
+
this._anchorRect = null
|
|
349
|
+
this._anchorProvider = null
|
|
211
350
|
|
|
212
351
|
if (typeof this.onClose === 'function') {
|
|
213
352
|
this.onClose(reason)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function isCreativeNavigation(url) {
|
|
2
|
+
url = new URL(url, window.location.origin)
|
|
3
|
+
|
|
4
|
+
return url.origin === window.location.origin && /\/creatives(?:\/|$)/.test(url.pathname)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Only these HTML routes reach CreativesController#index visit persistence:
|
|
8
|
+
// the Creative index and a single Creative show route (which redirects there).
|
|
9
|
+
// Nested Creative routes load supporting UI and must not reserve a visit
|
|
10
|
+
// sequence merely because a last-visited controller is connected.
|
|
11
|
+
export function isCreativeVisitNavigation(url) {
|
|
12
|
+
url = new URL(url, window.location.origin)
|
|
13
|
+
|
|
14
|
+
return url.origin === window.location.origin && /\/creatives(?:\.html|\/\d+)?\/?$/.test(url.pathname)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function preventCreativeLinkPrefetch(event) {
|
|
18
|
+
const link = event.target
|
|
19
|
+
if (!(link instanceof HTMLAnchorElement) || !isCreativeNavigation(link.href)) return
|
|
20
|
+
|
|
21
|
+
event.preventDefault()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A prefetched Creative response can be promoted by Turbo without issuing a
|
|
25
|
+
// second request. Prevent it globally so confirmed navigation remains the only
|
|
26
|
+
// way to update the user's last visited Creative, regardless of link source.
|
|
27
|
+
document.addEventListener("turbo:before-prefetch", preventCreativeLinkPrefetch)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import csrfFetch, { refreshCsrfToken } from './api/csrf_fetch'
|
|
2
|
+
import { isCreativeVisitNavigation } from './creative_link_prefetch'
|
|
3
|
+
|
|
4
|
+
let pendingRequestController = null
|
|
5
|
+
const SEQUENCE_HEADER = 'X-Collavre-Last-Visited-Creative-Sequence'
|
|
6
|
+
const TOKEN_HEADER = 'X-Collavre-Last-Visited-Creative-Token'
|
|
7
|
+
|
|
8
|
+
function sequenceUrl(baseUrl) {
|
|
9
|
+
const url = new URL(baseUrl, window.location.origin)
|
|
10
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/next_last_visited_sequence`
|
|
11
|
+
return `${url.pathname}${url.search}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function nextLastVisitedCreativeSequence(baseUrl, signal, retryCsrf = true) {
|
|
15
|
+
const response = await csrfFetch(sequenceUrl(baseUrl), {
|
|
16
|
+
method: 'PATCH',
|
|
17
|
+
headers: { Accept: 'application/json' },
|
|
18
|
+
signal,
|
|
19
|
+
})
|
|
20
|
+
if (response.status === 422 && retryCsrf && !signal?.aborted) {
|
|
21
|
+
await refreshCsrfToken({ signal })
|
|
22
|
+
if (signal?.aborted) return null
|
|
23
|
+
return nextLastVisitedCreativeSequence(baseUrl, signal, false)
|
|
24
|
+
}
|
|
25
|
+
if (!response.ok) return null
|
|
26
|
+
|
|
27
|
+
const sequence = Number((await response.json()).sequence)
|
|
28
|
+
return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function prepareLastVisitedCreativeNavigation(event, baseUrl, visitToken) {
|
|
32
|
+
const fetchOptions = event.detail?.fetchOptions
|
|
33
|
+
if (
|
|
34
|
+
!baseUrl ||
|
|
35
|
+
!visitToken ||
|
|
36
|
+
!fetchOptions ||
|
|
37
|
+
!isCreativeVisitNavigation(event.detail?.url) ||
|
|
38
|
+
String(fetchOptions.method || 'GET').toUpperCase() !== 'GET'
|
|
39
|
+
) return
|
|
40
|
+
|
|
41
|
+
event.preventDefault()
|
|
42
|
+
try {
|
|
43
|
+
const sequence = await nextLastVisitedCreativeSequence(baseUrl)
|
|
44
|
+
if (!sequence) return
|
|
45
|
+
|
|
46
|
+
const headers = new Headers(fetchOptions.headers)
|
|
47
|
+
headers.set(TOKEN_HEADER, visitToken)
|
|
48
|
+
headers.set(SEQUENCE_HEADER, String(sequence))
|
|
49
|
+
fetchOptions.headers = headers
|
|
50
|
+
} finally {
|
|
51
|
+
event.detail.resume?.()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function cancelPendingLastVisitedCreative() {
|
|
56
|
+
pendingRequestController?.abort()
|
|
57
|
+
pendingRequestController = null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function rememberLastVisitedCreative(baseUrl, creativeId, visitToken) {
|
|
61
|
+
if (!baseUrl || !creativeId || !visitToken) return
|
|
62
|
+
|
|
63
|
+
cancelPendingLastVisitedCreative()
|
|
64
|
+
const requestController = new AbortController()
|
|
65
|
+
pendingRequestController = requestController
|
|
66
|
+
const url = new URL(baseUrl, window.location.origin)
|
|
67
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/${encodeURIComponent(creativeId)}/remember_last_visited`
|
|
68
|
+
url.searchParams.set('visit_token', visitToken)
|
|
69
|
+
const request = (sequence) => csrfFetch(`${url.pathname}${url.search}`, {
|
|
70
|
+
method: 'PATCH',
|
|
71
|
+
headers: {
|
|
72
|
+
Accept: 'application/json',
|
|
73
|
+
[SEQUENCE_HEADER]: String(sequence),
|
|
74
|
+
},
|
|
75
|
+
signal: requestController.signal,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
nextLastVisitedCreativeSequence(baseUrl, requestController.signal)
|
|
79
|
+
.then(async (sequence) => {
|
|
80
|
+
if (!sequence || requestController.signal.aborted) return null
|
|
81
|
+
return request(sequence)
|
|
82
|
+
})
|
|
83
|
+
.then(async (response) => {
|
|
84
|
+
if (!response) return
|
|
85
|
+
if (requestController.signal.aborted || response.ok || response.status !== 422) return
|
|
86
|
+
|
|
87
|
+
await refreshCsrfToken({ signal: requestController.signal })
|
|
88
|
+
if (requestController.signal.aborted) return
|
|
89
|
+
const sequence = await nextLastVisitedCreativeSequence(baseUrl, requestController.signal)
|
|
90
|
+
if (!sequence || requestController.signal.aborted) return
|
|
91
|
+
await request(sequence)
|
|
92
|
+
})
|
|
93
|
+
.catch(() => {})
|
|
94
|
+
.finally(() => {
|
|
95
|
+
if (pendingRequestController === requestController) pendingRequestController = null
|
|
96
|
+
})
|
|
97
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
$createParagraphNode,
|
|
7
|
+
$createTextNode,
|
|
8
|
+
$getRoot,
|
|
9
|
+
createEditor,
|
|
10
|
+
} from 'lexical'
|
|
11
|
+
import { LinkNode } from '@lexical/link'
|
|
12
|
+
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'
|
|
13
|
+
import {
|
|
14
|
+
$createCreativeLinkNode,
|
|
15
|
+
$isCreativeLinkNode,
|
|
16
|
+
CreativeLinkNode,
|
|
17
|
+
creativeIdFromUrl,
|
|
18
|
+
} from '../creative_link_node'
|
|
19
|
+
import { lexicalToMarkdown } from '../markdown_serialize'
|
|
20
|
+
|
|
21
|
+
function createTestEditor() {
|
|
22
|
+
return createEditor({
|
|
23
|
+
namespace: 'creative-link-test',
|
|
24
|
+
nodes: [LinkNode, CreativeLinkNode],
|
|
25
|
+
onError(error) {
|
|
26
|
+
throw error
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('CreativeLinkNode', () => {
|
|
32
|
+
test('extracts creative ids only from canonical internal paths', () => {
|
|
33
|
+
expect(creativeIdFromUrl('/creatives/42')).toBe(42)
|
|
34
|
+
expect(creativeIdFromUrl('/creatives/42?topic_id=3')).toBe(42)
|
|
35
|
+
expect(creativeIdFromUrl('https://example.com/creatives/42')).toBeNull()
|
|
36
|
+
expect(creativeIdFromUrl('/creatives/not-a-number')).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('exports data-creative-id while keeping canonical Markdown', () => {
|
|
40
|
+
const editor = createTestEditor()
|
|
41
|
+
editor.update(() => {
|
|
42
|
+
const paragraph = $createParagraphNode()
|
|
43
|
+
const link = $createCreativeLinkNode('/creatives/42', 42)
|
|
44
|
+
link.append($createTextNode('Internal page'))
|
|
45
|
+
paragraph.append(link)
|
|
46
|
+
$getRoot().append(paragraph)
|
|
47
|
+
}, { discrete: true })
|
|
48
|
+
|
|
49
|
+
let html = ''
|
|
50
|
+
editor.getEditorState().read(() => {
|
|
51
|
+
html = $generateHtmlFromNodes(editor)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(html).toContain('data-creative-id="42"')
|
|
55
|
+
expect(lexicalToMarkdown(editor)).toBe('[Internal page](/creatives/42)')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('reconstructs the dedicated node from a canonical internal href', () => {
|
|
59
|
+
const editor = createTestEditor()
|
|
60
|
+
let imported = null
|
|
61
|
+
|
|
62
|
+
editor.update(() => {
|
|
63
|
+
const doc = new DOMParser().parseFromString(
|
|
64
|
+
'<p><a href="/creatives/77">Existing</a></p>',
|
|
65
|
+
'text/html',
|
|
66
|
+
)
|
|
67
|
+
const nodes = $generateNodesFromDOM(editor, doc.body)
|
|
68
|
+
nodes.forEach((node) => $getRoot().append(node))
|
|
69
|
+
imported = $getRoot().getFirstChild().getFirstChild()
|
|
70
|
+
}, { discrete: true })
|
|
71
|
+
|
|
72
|
+
let importedType = null
|
|
73
|
+
let importedCreativeId = null
|
|
74
|
+
editor.getEditorState().read(() => {
|
|
75
|
+
importedType = imported.getType()
|
|
76
|
+
importedCreativeId = imported.getCreativeId()
|
|
77
|
+
})
|
|
78
|
+
expect(importedType).toBe('creative-link')
|
|
79
|
+
expect(importedCreativeId).toBe(77)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('imports serialized nodes and preserves link attributes when cloned', () => {
|
|
83
|
+
const editor = createTestEditor()
|
|
84
|
+
let imported = null
|
|
85
|
+
let cloned = null
|
|
86
|
+
|
|
87
|
+
editor.update(() => {
|
|
88
|
+
imported = CreativeLinkNode.importJSON({
|
|
89
|
+
type: 'creative-link',
|
|
90
|
+
version: 1,
|
|
91
|
+
url: '/creatives/51',
|
|
92
|
+
creativeId: 51,
|
|
93
|
+
rel: 'nofollow',
|
|
94
|
+
target: '_blank',
|
|
95
|
+
title: 'Imported page',
|
|
96
|
+
})
|
|
97
|
+
imported.append($createTextNode('Imported'))
|
|
98
|
+
const paragraph = $createParagraphNode()
|
|
99
|
+
paragraph.append(imported)
|
|
100
|
+
$getRoot().append(paragraph)
|
|
101
|
+
cloned = CreativeLinkNode.clone(imported)
|
|
102
|
+
}, { discrete: true })
|
|
103
|
+
|
|
104
|
+
editor.getEditorState().read(() => {
|
|
105
|
+
expect(imported.exportJSON()).toMatchObject({
|
|
106
|
+
type: 'creative-link',
|
|
107
|
+
creativeId: 51,
|
|
108
|
+
url: '/creatives/51',
|
|
109
|
+
rel: 'nofollow',
|
|
110
|
+
target: '_blank',
|
|
111
|
+
title: 'Imported page',
|
|
112
|
+
})
|
|
113
|
+
expect($isCreativeLinkNode(imported)).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
expect(cloned.__url).toBe('/creatives/51')
|
|
116
|
+
expect(cloned.__creativeId).toBe(51)
|
|
117
|
+
expect(cloned.__rel).toBe('nofollow')
|
|
118
|
+
expect($isCreativeLinkNode({})).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('updates and removes the DOM creative id with the node state', () => {
|
|
122
|
+
const editor = createTestEditor()
|
|
123
|
+
|
|
124
|
+
editor.update(() => {
|
|
125
|
+
const paragraph = $createParagraphNode()
|
|
126
|
+
const linked = $createCreativeLinkNode('/creatives/61', 61)
|
|
127
|
+
const unlinked = $createCreativeLinkNode('/other', null)
|
|
128
|
+
paragraph.append(linked, unlinked)
|
|
129
|
+
$getRoot().append(paragraph)
|
|
130
|
+
|
|
131
|
+
const element = document.createElement('a')
|
|
132
|
+
linked.updateDOM(linked, element, {})
|
|
133
|
+
expect(element.dataset.creativeId).toBe('61')
|
|
134
|
+
|
|
135
|
+
unlinked.updateDOM(linked, element, {})
|
|
136
|
+
expect(element.hasAttribute('data-creative-id')).toBe(false)
|
|
137
|
+
}, { discrete: true })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('imports data-creative-id without an href and keeps link attributes', () => {
|
|
141
|
+
const editor = createTestEditor()
|
|
142
|
+
let imported = null
|
|
143
|
+
|
|
144
|
+
editor.update(() => {
|
|
145
|
+
const doc = new DOMParser().parseFromString(
|
|
146
|
+
'<p><a data-creative-id="88" rel="nofollow" target="_blank" title="Page">Existing</a></p>',
|
|
147
|
+
'text/html',
|
|
148
|
+
)
|
|
149
|
+
const nodes = $generateNodesFromDOM(editor, doc.body)
|
|
150
|
+
nodes.forEach((node) => $getRoot().append(node))
|
|
151
|
+
imported = $getRoot().getFirstChild().getFirstChild()
|
|
152
|
+
}, { discrete: true })
|
|
153
|
+
|
|
154
|
+
editor.getEditorState().read(() => {
|
|
155
|
+
expect(imported.getURL()).toBe('/creatives/88')
|
|
156
|
+
expect(imported.getCreativeId()).toBe(88)
|
|
157
|
+
expect(imported.getRel()).toBe('nofollow')
|
|
158
|
+
expect(imported.getTarget()).toBe('_blank')
|
|
159
|
+
expect(imported.getTitle()).toBe('Page')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
import {
|
|
7
|
+
$createParagraphNode,
|
|
8
|
+
$createTextNode,
|
|
9
|
+
$getRoot,
|
|
10
|
+
createEditor,
|
|
11
|
+
} from 'lexical'
|
|
12
|
+
import { LinkNode } from '@lexical/link'
|
|
13
|
+
import { CreativeLinkNode } from '../creative_link_node'
|
|
14
|
+
import { registerCreativeLinkTrigger } from '../creative_link_trigger'
|
|
15
|
+
import { lexicalToMarkdown } from '../markdown_serialize'
|
|
16
|
+
|
|
17
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
18
|
+
|
|
19
|
+
function createTestEditor() {
|
|
20
|
+
const editor = createEditor({
|
|
21
|
+
namespace: 'creative-link-trigger-test',
|
|
22
|
+
nodes: [LinkNode, CreativeLinkNode],
|
|
23
|
+
onError(error) {
|
|
24
|
+
throw error
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
const root = document.createElement('div')
|
|
28
|
+
root.contentEditable = 'true'
|
|
29
|
+
document.body.appendChild(root)
|
|
30
|
+
editor.setRootElement(root)
|
|
31
|
+
return editor
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('creative link [[ trigger', () => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
document.body.innerHTML = ''
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('opens once and replaces the trigger with a canonical creative link', async () => {
|
|
40
|
+
const editor = createTestEditor()
|
|
41
|
+
const openPicker = jest.fn(() => true)
|
|
42
|
+
const unregister = registerCreativeLinkTrigger(editor, openPicker)
|
|
43
|
+
|
|
44
|
+
editor.update(() => {
|
|
45
|
+
const paragraph = $createParagraphNode()
|
|
46
|
+
const text = $createTextNode('Before [[')
|
|
47
|
+
paragraph.append(text)
|
|
48
|
+
$getRoot().append(paragraph)
|
|
49
|
+
text.selectEnd()
|
|
50
|
+
}, { discrete: true })
|
|
51
|
+
await flush()
|
|
52
|
+
|
|
53
|
+
expect(openPicker).toHaveBeenCalledTimes(1)
|
|
54
|
+
openPicker.mock.calls[0][0].onSelect({ id: 99, label: 'Target page' })
|
|
55
|
+
|
|
56
|
+
expect(lexicalToMarkdown(editor)).toBe('Before [Target page](/creatives/99)')
|
|
57
|
+
expect(openPicker).toHaveBeenCalledTimes(1)
|
|
58
|
+
unregister()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('keeps the trigger when the picker cannot open', async () => {
|
|
62
|
+
const editor = createTestEditor()
|
|
63
|
+
const openPicker = jest.fn(() => false)
|
|
64
|
+
const unregister = registerCreativeLinkTrigger(editor, openPicker)
|
|
65
|
+
|
|
66
|
+
editor.update(() => {
|
|
67
|
+
const paragraph = $createParagraphNode()
|
|
68
|
+
const text = $createTextNode('[[')
|
|
69
|
+
paragraph.append(text)
|
|
70
|
+
$getRoot().append(paragraph)
|
|
71
|
+
text.selectEnd()
|
|
72
|
+
}, { discrete: true })
|
|
73
|
+
await flush()
|
|
74
|
+
|
|
75
|
+
expect(openPicker).toHaveBeenCalledTimes(1)
|
|
76
|
+
expect(lexicalToMarkdown(editor)).toBe('[[')
|
|
77
|
+
unregister()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('does not immediately reopen a dismissed trigger', async () => {
|
|
81
|
+
const editor = createTestEditor()
|
|
82
|
+
const openPicker = jest.fn(() => true)
|
|
83
|
+
const unregister = registerCreativeLinkTrigger(editor, openPicker)
|
|
84
|
+
|
|
85
|
+
let textNode = null
|
|
86
|
+
editor.update(() => {
|
|
87
|
+
const paragraph = $createParagraphNode()
|
|
88
|
+
textNode = $createTextNode('[[')
|
|
89
|
+
paragraph.append(textNode)
|
|
90
|
+
$getRoot().append(paragraph)
|
|
91
|
+
textNode.selectEnd()
|
|
92
|
+
}, { discrete: true })
|
|
93
|
+
await flush()
|
|
94
|
+
|
|
95
|
+
openPicker.mock.calls[0][0].onClose()
|
|
96
|
+
editor.update(() => textNode.getLatest().selectEnd(), { discrete: true })
|
|
97
|
+
await flush()
|
|
98
|
+
|
|
99
|
+
expect(openPicker).toHaveBeenCalledTimes(1)
|
|
100
|
+
unregister()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('clears a dismissed trigger after editing away and opens for a new trigger', async () => {
|
|
104
|
+
const editor = createTestEditor()
|
|
105
|
+
const openPicker = jest.fn(() => true)
|
|
106
|
+
const unregister = registerCreativeLinkTrigger(editor, openPicker)
|
|
107
|
+
|
|
108
|
+
let textNode = null
|
|
109
|
+
editor.update(() => {
|
|
110
|
+
const paragraph = $createParagraphNode()
|
|
111
|
+
textNode = $createTextNode('[[')
|
|
112
|
+
paragraph.append(textNode)
|
|
113
|
+
$getRoot().append(paragraph)
|
|
114
|
+
textNode.selectEnd()
|
|
115
|
+
}, { discrete: true })
|
|
116
|
+
await flush()
|
|
117
|
+
openPicker.mock.calls[0][0].onClose()
|
|
118
|
+
|
|
119
|
+
editor.update(() => {
|
|
120
|
+
textNode.getLatest().setTextContent('plain')
|
|
121
|
+
textNode.getLatest().selectEnd()
|
|
122
|
+
}, { discrete: true })
|
|
123
|
+
await flush()
|
|
124
|
+
editor.update(() => {
|
|
125
|
+
textNode.getLatest().setTextContent('plain [[')
|
|
126
|
+
textNode.getLatest().selectEnd()
|
|
127
|
+
}, { discrete: true })
|
|
128
|
+
await flush()
|
|
129
|
+
|
|
130
|
+
expect(openPicker).toHaveBeenCalledTimes(2)
|
|
131
|
+
unregister()
|
|
132
|
+
})
|
|
133
|
+
})
|