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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { decodeHtmlEntities } from "../../../../skills/collavre/scripts/html_entities.js";
|
|
2
|
+
|
|
3
|
+
describe("decodeHtmlEntities", () => {
|
|
4
|
+
test("decodes supported named and numeric entities", () => {
|
|
5
|
+
expect(decodeHtmlEntities("A & B <x> "q" 'a' AB"))
|
|
6
|
+
.toBe("A & B <x> \"q\" 'a' AB");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("decodes each entity exactly once", () => {
|
|
10
|
+
expect(decodeHtmlEntities("&lt; &#60; &gt; &quot;"))
|
|
11
|
+
.toBe("< < > "");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("preserves unsupported and out-of-range entities", () => {
|
|
15
|
+
expect(decodeHtmlEntities("© �"))
|
|
16
|
+
.toBe("© �");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("preserves empty values", () => {
|
|
20
|
+
expect(decodeHtmlEntities("")).toBe("");
|
|
21
|
+
expect(decodeHtmlEntities(null)).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
|
|
6
|
+
const fakeTurbo = { StreamActions: {} }
|
|
7
|
+
|
|
8
|
+
jest.unstable_mockModule('@hotwired/turbo-rails', () => ({ Turbo: fakeTurbo }))
|
|
9
|
+
jest.unstable_mockModule('../../creatives/tree_renderer', () => ({
|
|
10
|
+
createRow: jest.fn(),
|
|
11
|
+
applyRowProperties: jest.fn(),
|
|
12
|
+
replaceProgressControl: jest.fn((_html, controlHtml) => controlHtml),
|
|
13
|
+
updateProgressHtml: jest.fn((html, progress) => progress === 1
|
|
14
|
+
? html.replace('class="progress-toggle-checkbox"', 'class="progress-toggle-checkbox" checked="checked"')
|
|
15
|
+
.replace('data-current-progress="0"', 'data-current-progress="1"')
|
|
16
|
+
.replace('data-new-progress="1"', 'data-new-progress="0"')
|
|
17
|
+
.replace('title="Mark complete"', 'title="Mark incomplete"')
|
|
18
|
+
: html),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
await import('../turbo_stream_actions')
|
|
22
|
+
const { createRow } = await import('../../creatives/tree_renderer')
|
|
23
|
+
const { updateProgressForRow } = await import('../turbo_stream_actions')
|
|
24
|
+
|
|
25
|
+
const EMPTY_HTML = '<div data-creatives-empty-state=""><p>No sub-creatives found.</p></div>'
|
|
26
|
+
|
|
27
|
+
// Mirrors creatives/index.html.erb: title row above the tree container with the
|
|
28
|
+
// server-rendered placeholder inside it.
|
|
29
|
+
function renderEmptyTreeForParent(parentId) {
|
|
30
|
+
document.body.innerHTML = `
|
|
31
|
+
<creative-tree-row is-title creative-id="${parentId}"></creative-tree-row>
|
|
32
|
+
<div id="creatives">${EMPTY_HTML}</div>
|
|
33
|
+
`
|
|
34
|
+
createRow.mockImplementation((creative) => {
|
|
35
|
+
const row = document.createElement('creative-tree-row')
|
|
36
|
+
row.setAttribute('creative-id', String(creative.id))
|
|
37
|
+
row.setAttribute('parent-id', String(creative.parent_id))
|
|
38
|
+
return row
|
|
39
|
+
})
|
|
40
|
+
return document.getElementById('creatives')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function dispatchCreativeTreeStream(payload) {
|
|
44
|
+
fakeTurbo.StreamActions.refresh_creative_tree.call({
|
|
45
|
+
getAttribute: (name) => name === 'data' ? JSON.stringify(payload) : null,
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
document.body.innerHTML = ''
|
|
51
|
+
jest.restoreAllMocks()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('remote destroyed streams notify chat even when no tree row is visible', () => {
|
|
55
|
+
const destroyedEvents = []
|
|
56
|
+
const invalidatedEvents = []
|
|
57
|
+
document.addEventListener('creative-destroyed', (event) => destroyedEvents.push(event.detail), { once: true })
|
|
58
|
+
document.addEventListener('workspace-tree:invalidate', (event) => invalidatedEvents.push(event), { once: true })
|
|
59
|
+
|
|
60
|
+
dispatchCreativeTreeStream({
|
|
61
|
+
action: 'destroyed',
|
|
62
|
+
creative: { id: 123, linked_id: 456, parent_id: 42 },
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(destroyedEvents).toEqual([{ creativeIds: ['456', '123'] }])
|
|
66
|
+
expect(invalidatedEvents).toHaveLength(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('remote destroyed streams deduplicate identical effective and origin IDs', () => {
|
|
70
|
+
const listener = jest.fn()
|
|
71
|
+
document.addEventListener('creative-destroyed', listener, { once: true })
|
|
72
|
+
|
|
73
|
+
dispatchCreativeTreeStream({
|
|
74
|
+
action: 'destroyed',
|
|
75
|
+
creative: { id: 123 },
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(listener).toHaveBeenCalledWith(expect.objectContaining({
|
|
79
|
+
detail: { creativeIds: ['123'] },
|
|
80
|
+
}))
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('remote created streams clear the empty-state placeholder', () => {
|
|
84
|
+
const container = renderEmptyTreeForParent(42)
|
|
85
|
+
|
|
86
|
+
dispatchCreativeTreeStream({
|
|
87
|
+
action: 'created',
|
|
88
|
+
creative: { id: 7, parent_id: 42, level: 2, sequence: 0 },
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(container.querySelector('creative-tree-row[creative-id="7"]')).not.toBeNull()
|
|
92
|
+
expect(container.querySelector('[data-creatives-empty-state]').hidden).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('remote destroyed streams restore the empty-state placeholder for the last row', () => {
|
|
96
|
+
const container = renderEmptyTreeForParent(42)
|
|
97
|
+
dispatchCreativeTreeStream({
|
|
98
|
+
action: 'created',
|
|
99
|
+
creative: { id: 7, parent_id: 42, level: 2, sequence: 0 },
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
dispatchCreativeTreeStream({
|
|
103
|
+
action: 'destroyed',
|
|
104
|
+
creative: { id: 7, parent_id: 42 },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(container.querySelector('creative-tree-row')).toBeNull()
|
|
108
|
+
expect(container.querySelector('[data-creatives-empty-state]').hidden).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('remote destroyed streams keep the placeholder hidden while rows remain', () => {
|
|
112
|
+
const container = renderEmptyTreeForParent(42)
|
|
113
|
+
dispatchCreativeTreeStream({
|
|
114
|
+
action: 'created',
|
|
115
|
+
creative: { id: 7, parent_id: 42, level: 2, sequence: 0 },
|
|
116
|
+
})
|
|
117
|
+
dispatchCreativeTreeStream({
|
|
118
|
+
action: 'created',
|
|
119
|
+
creative: { id: 8, parent_id: 42, level: 2, sequence: 1 },
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
dispatchCreativeTreeStream({
|
|
123
|
+
action: 'destroyed',
|
|
124
|
+
creative: { id: 7, parent_id: 42 },
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
expect(container.querySelector('creative-tree-row[creative-id="8"]')).not.toBeNull()
|
|
128
|
+
expect(container.querySelector('[data-creatives-empty-state]').hidden).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('remote progress updates keep checkbox controls actionable', () => {
|
|
132
|
+
const row = {
|
|
133
|
+
dataset: {},
|
|
134
|
+
progressHtml: '<span class="progress-toggle-wrap" data-progress-toggle="true" data-current-progress="0" data-new-progress="1" data-mark-complete="Mark complete" data-mark-incomplete="Mark incomplete" title="Mark complete"><input type="checkbox" class="progress-toggle-checkbox"></span>',
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
updateProgressForRow(row, 1, '100%')
|
|
138
|
+
|
|
139
|
+
expect(row.progressHtml).toContain('checked="checked"')
|
|
140
|
+
expect(row.progressHtml).toContain('data-current-progress="1"')
|
|
141
|
+
expect(row.progressHtml).toContain('data-new-progress="0"')
|
|
142
|
+
expect(row.progressHtml).toContain('title="Mark incomplete"')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('remote child creation changes the parent checkbox into its rollup control', () => {
|
|
146
|
+
document.body.innerHTML = '<div id="creatives"><creative-tree-row creative-id="42"></creative-tree-row></div>'
|
|
147
|
+
const parent = document.querySelector('creative-tree-row[creative-id="42"]')
|
|
148
|
+
parent.progressHtml = '<span data-progress-toggle="true"><input class="progress-toggle-checkbox"></span>'
|
|
149
|
+
parent.dataset.progressHtml = parent.progressHtml
|
|
150
|
+
|
|
151
|
+
dispatchCreativeTreeStream({
|
|
152
|
+
action: 'created',
|
|
153
|
+
creative: {
|
|
154
|
+
id: 7,
|
|
155
|
+
parent_id: 42,
|
|
156
|
+
level: 2,
|
|
157
|
+
sequence: 0,
|
|
158
|
+
ancestors: [{ id: 42, progress: 0.5, progress_html: '<span class="creative-progress-incomplete">50%</span>' }],
|
|
159
|
+
},
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
expect(parent.hasChildren).toBe(true)
|
|
163
|
+
expect(parent.progressHtml).toBe('<span class="creative-progress-incomplete">50%</span>')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('remote child deletion restores the parent leaf checkbox control', () => {
|
|
167
|
+
document.body.innerHTML = `
|
|
168
|
+
<div id="creatives">
|
|
169
|
+
<creative-tree-row creative-id="42" has-children></creative-tree-row>
|
|
170
|
+
<div id="creative-children-42"><creative-tree-row creative-id="7"></creative-tree-row></div>
|
|
171
|
+
</div>
|
|
172
|
+
`
|
|
173
|
+
const parent = document.querySelector('creative-tree-row[creative-id="42"]')
|
|
174
|
+
parent.hasChildren = true
|
|
175
|
+
parent.progressHtml = '<span class="creative-progress-incomplete">0%</span>'
|
|
176
|
+
parent.dataset.progressHtml = parent.progressHtml
|
|
177
|
+
const checkbox = '<span data-progress-toggle="true"><input class="progress-toggle-checkbox"></span>'
|
|
178
|
+
|
|
179
|
+
dispatchCreativeTreeStream({
|
|
180
|
+
action: 'destroyed',
|
|
181
|
+
creative: {
|
|
182
|
+
id: 7,
|
|
183
|
+
parent_id: 42,
|
|
184
|
+
ancestors: [{ id: 42, progress: 0, progress_html: checkbox }],
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(parent.hasChildren).toBe(false)
|
|
189
|
+
expect(parent.progressHtml).toBe(checkbox)
|
|
190
|
+
})
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
const csrfFetch = jest.fn()
|
|
8
|
+
|
|
9
|
+
jest.unstable_mockModule('../csrf_fetch', () => ({ default: csrfFetch }))
|
|
10
|
+
|
|
11
|
+
const creativesApi = await import('../creatives')
|
|
12
|
+
|
|
13
|
+
describe('creatives API workspace invalidation', () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
csrfFetch.mockReset()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('invalidates the workspace tree after a successful mutation', async () => {
|
|
19
|
+
const listener = jest.fn()
|
|
20
|
+
document.addEventListener('workspace-tree:invalidate', listener)
|
|
21
|
+
csrfFetch.mockResolvedValue({ ok: true })
|
|
22
|
+
|
|
23
|
+
await creativesApi.destroy(42)
|
|
24
|
+
|
|
25
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
26
|
+
document.removeEventListener('workspace-tree:invalidate', listener)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('does not invalidate the workspace tree after a failed mutation', async () => {
|
|
30
|
+
const listener = jest.fn()
|
|
31
|
+
document.addEventListener('workspace-tree:invalidate', listener)
|
|
32
|
+
csrfFetch.mockResolvedValue({ ok: false })
|
|
33
|
+
|
|
34
|
+
await creativesApi.archive(42)
|
|
35
|
+
|
|
36
|
+
expect(listener).not.toHaveBeenCalled()
|
|
37
|
+
document.removeEventListener('workspace-tree:invalidate', listener)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('creates a rich-authored Markdown creative from a picker title', async () => {
|
|
41
|
+
const listener = jest.fn()
|
|
42
|
+
document.addEventListener('workspace-tree:invalidate', listener)
|
|
43
|
+
csrfFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 17 }) })
|
|
44
|
+
|
|
45
|
+
await expect(creativesApi.createFromTitle('New page')).resolves.toEqual({ id: 17 })
|
|
46
|
+
|
|
47
|
+
const [, options] = csrfFetch.mock.calls[0]
|
|
48
|
+
expect(csrfFetch.mock.calls[0][0]).toBe('/creatives')
|
|
49
|
+
expect(options.method).toBe('POST')
|
|
50
|
+
expect(options.body.get('creative[markdown_source]')).toBe('New page')
|
|
51
|
+
expect(options.body.get('creative[content_type_input]')).toBe('markdown')
|
|
52
|
+
expect(options.body.get('creative[markdown_editor]')).toBe('rich')
|
|
53
|
+
expect(listener).toHaveBeenCalledTimes(1)
|
|
54
|
+
document.removeEventListener('workspace-tree:invalidate', listener)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('rejects a failed picker creation without invalidating the workspace tree', async () => {
|
|
58
|
+
const listener = jest.fn()
|
|
59
|
+
document.addEventListener('workspace-tree:invalidate', listener)
|
|
60
|
+
csrfFetch.mockResolvedValue({ ok: false, status: 422 })
|
|
61
|
+
|
|
62
|
+
await expect(creativesApi.createFromTitle('Invalid')).rejects.toThrow(
|
|
63
|
+
'Failed to create creative: 422',
|
|
64
|
+
)
|
|
65
|
+
expect(listener).not.toHaveBeenCalled()
|
|
66
|
+
document.removeEventListener('workspace-tree:invalidate', listener)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
import { refreshCsrfToken } from '../csrf_fetch'
|
|
7
|
+
|
|
8
|
+
describe('refreshCsrfToken', () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
delete global.fetch
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('marks the refresh as a prefetch and passes through cancellation', async () => {
|
|
14
|
+
const signal = new AbortController().signal
|
|
15
|
+
const fetchMock = jest.fn().mockResolvedValue({ headers: new Headers() })
|
|
16
|
+
global.fetch = fetchMock
|
|
17
|
+
|
|
18
|
+
await refreshCsrfToken({ signal })
|
|
19
|
+
|
|
20
|
+
expect(fetchMock).toHaveBeenCalledWith(window.location.href, {
|
|
21
|
+
method: 'HEAD',
|
|
22
|
+
credentials: 'same-origin',
|
|
23
|
+
headers: { 'X-Sec-Purpose': 'prefetch' },
|
|
24
|
+
signal,
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
import { saveLastTopic } from '../topics'
|
|
7
|
+
|
|
8
|
+
// update_last_topic broadcasts to every session of the saving user, and the
|
|
9
|
+
// broadcast echoes back whatever client_id the save carried. That id is the
|
|
10
|
+
// only thing that tells the session which made the save apart from a sibling
|
|
11
|
+
// session that happened to pick the same topic, so it has to reach the server.
|
|
12
|
+
describe('saveLastTopic', () => {
|
|
13
|
+
const bodyOfLastCall = () => JSON.parse(global.fetch.mock.calls.at(-1)[1].body)
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
document.head.innerHTML = '<meta name="csrf-token" content="test-csrf">'
|
|
17
|
+
global.fetch = jest.fn().mockImplementation((url, options) => {
|
|
18
|
+
if (options?.method === 'POST') {
|
|
19
|
+
return Promise.resolve({ ok: true, json: async () => ({ last_topic_save_fence: 1 }) })
|
|
20
|
+
}
|
|
21
|
+
return Promise.resolve({ ok: true, json: async () => ({}) })
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
document.head.innerHTML = ''
|
|
27
|
+
delete global.fetch
|
|
28
|
+
jest.clearAllMocks()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('sends the client id alongside the topic', async () => {
|
|
32
|
+
await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toEqual({ success: true, lastTopicRevision: undefined })
|
|
33
|
+
|
|
34
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
35
|
+
'/creatives/42/user_creative_preferences/update_last_topic',
|
|
36
|
+
expect.objectContaining({ method: 'PATCH' }),
|
|
37
|
+
)
|
|
38
|
+
expect(bodyOfLastCall()).toEqual({ last_topic_id: '3', client_id: 'save-abc', last_topic_save_fence: 1 })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('forwards an abort signal to the request', async () => {
|
|
42
|
+
const abortController = new AbortController()
|
|
43
|
+
|
|
44
|
+
await saveLastTopic('42', '3', 'save-abc', abortController.signal)
|
|
45
|
+
|
|
46
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
47
|
+
'/creatives/42/user_creative_preferences/update_last_topic',
|
|
48
|
+
expect.objectContaining({ signal: abortController.signal }),
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('sends null for both when clearing the selection anonymously', async () => {
|
|
53
|
+
await saveLastTopic('42', null)
|
|
54
|
+
|
|
55
|
+
expect(bodyOfLastCall()).toEqual({ last_topic_id: null, client_id: null, last_topic_save_fence: 1 })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('reports a rejected save rather than throwing', async () => {
|
|
59
|
+
global.fetch.mockResolvedValue({ ok: false })
|
|
60
|
+
|
|
61
|
+
await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('reports a stale ordered save as definitively rejected', async () => {
|
|
65
|
+
global.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ last_topic_save_fence: 1 }) })
|
|
66
|
+
global.fetch.mockResolvedValueOnce({
|
|
67
|
+
ok: true,
|
|
68
|
+
json: async () => ({ success: false, stale_last_topic_save: true, last_topic_revision: [5, 2] }),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toEqual({
|
|
72
|
+
success: false,
|
|
73
|
+
staleLastTopicSave: true,
|
|
74
|
+
lastTopicRevision: [5, 2],
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('does not PATCH when issuing the save fence is ambiguous', async () => {
|
|
79
|
+
global.fetch.mockResolvedValue({ ok: false, status: 502 })
|
|
80
|
+
|
|
81
|
+
await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toBeNull()
|
|
82
|
+
expect(global.fetch).toHaveBeenCalledTimes(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('falls back to the legacy PATCH when an older server lacks the fence endpoint', async () => {
|
|
86
|
+
global.fetch.mockResolvedValueOnce({ ok: false, status: 404 })
|
|
87
|
+
global.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
|
|
88
|
+
|
|
89
|
+
await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toEqual({ success: true, lastTopicRevision: undefined })
|
|
90
|
+
|
|
91
|
+
expect(global.fetch).toHaveBeenCalledTimes(2)
|
|
92
|
+
// No fence key at all: an older bundle cannot send one either, and the
|
|
93
|
+
// PATCH handler retires outstanding fences for every unfenced save.
|
|
94
|
+
expect(bodyOfLastCall()).toEqual({
|
|
95
|
+
last_topic_id: '3',
|
|
96
|
+
client_id: 'save-abc',
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('reports an ambiguous network failure separately from an HTTP rejection', async () => {
|
|
101
|
+
jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
102
|
+
global.fetch.mockRejectedValue(new Error('offline'))
|
|
103
|
+
|
|
104
|
+
await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toBeNull()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test.each([ 502, 504 ])('treats gateway status %i as an ambiguous delivery', async (status) => {
|
|
108
|
+
global.fetch.mockResolvedValue({ ok: false, status })
|
|
109
|
+
|
|
110
|
+
await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toBeNull()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -2,14 +2,15 @@ import csrfFetch from './csrf_fetch'
|
|
|
2
2
|
|
|
3
3
|
const JSON_HEADERS = { Accept: 'application/json' }
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
5
|
+
function invalidateWorkspaceTreeOnSuccess(response) {
|
|
6
|
+
if (response.ok && typeof document !== 'undefined') {
|
|
7
|
+
document.dispatchEvent(new CustomEvent('workspace-tree:invalidate'))
|
|
8
|
+
}
|
|
9
|
+
return response
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export function
|
|
12
|
-
return csrfFetch(`/creatives/${id}
|
|
12
|
+
export function get(id) {
|
|
13
|
+
return csrfFetch(`/creatives/${id}.json`, {
|
|
13
14
|
headers: JSON_HEADERS,
|
|
14
15
|
}).then((response) => response.json())
|
|
15
16
|
}
|
|
@@ -43,12 +44,30 @@ export function search(query, { simple = false } = {}) {
|
|
|
43
44
|
}).then((response) => response.json())
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export function createFromTitle(title) {
|
|
48
|
+
const body = new FormData()
|
|
49
|
+
body.append('creative[description]', title)
|
|
50
|
+
body.append('creative[content_type_input]', 'markdown')
|
|
51
|
+
body.append('creative[markdown_source]', title)
|
|
52
|
+
body.append('creative[markdown_editor]', 'rich')
|
|
53
|
+
|
|
54
|
+
return csrfFetch('/creatives', {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: JSON_HEADERS,
|
|
57
|
+
body,
|
|
58
|
+
}).then((response) => {
|
|
59
|
+
if (!response.ok) throw new Error(`Failed to create creative: ${response.status}`)
|
|
60
|
+
invalidateWorkspaceTreeOnSuccess(response)
|
|
61
|
+
return response.json()
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
export function save(action, method, form) {
|
|
47
66
|
return csrfFetch(action, {
|
|
48
67
|
method,
|
|
49
68
|
headers: JSON_HEADERS,
|
|
50
69
|
body: new FormData(form),
|
|
51
|
-
})
|
|
70
|
+
}).then(invalidateWorkspaceTreeOnSuccess)
|
|
52
71
|
}
|
|
53
72
|
|
|
54
73
|
export function linkExisting(parentId, originId) {
|
|
@@ -60,33 +79,33 @@ export function linkExisting(parentId, originId) {
|
|
|
60
79
|
method: 'POST',
|
|
61
80
|
headers: JSON_HEADERS,
|
|
62
81
|
body,
|
|
63
|
-
})
|
|
82
|
+
}).then(invalidateWorkspaceTreeOnSuccess)
|
|
64
83
|
}
|
|
65
84
|
|
|
66
85
|
export function destroy(id, withChildren = false) {
|
|
67
86
|
const query = withChildren ? '?delete_with_children=true' : ''
|
|
68
87
|
return csrfFetch(`/creatives/${id}${query}`, {
|
|
69
88
|
method: 'DELETE',
|
|
70
|
-
})
|
|
89
|
+
}).then(invalidateWorkspaceTreeOnSuccess)
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
export function archive(id) {
|
|
74
93
|
return csrfFetch(`/creatives/${id}/archive`, {
|
|
75
94
|
method: 'PATCH',
|
|
76
|
-
})
|
|
95
|
+
}).then(invalidateWorkspaceTreeOnSuccess)
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
export function unarchive(id) {
|
|
80
99
|
return csrfFetch(`/creatives/${id}/unarchive`, {
|
|
81
100
|
method: 'PATCH',
|
|
82
|
-
})
|
|
101
|
+
}).then(invalidateWorkspaceTreeOnSuccess)
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
export function unconvert(id) {
|
|
86
105
|
return csrfFetch(`/creatives/${id}/unconvert`, {
|
|
87
106
|
method: 'POST',
|
|
88
107
|
headers: JSON_HEADERS,
|
|
89
|
-
})
|
|
108
|
+
}).then(invalidateWorkspaceTreeOnSuccess)
|
|
90
109
|
}
|
|
91
110
|
|
|
92
111
|
export function updateMetadata(id, data) {
|
|
@@ -102,10 +121,10 @@ export function updateMetadata(id, data) {
|
|
|
102
121
|
|
|
103
122
|
const creativesApi = {
|
|
104
123
|
get,
|
|
105
|
-
parentSuggestions,
|
|
106
124
|
loadChildren,
|
|
107
125
|
browse,
|
|
108
126
|
search,
|
|
127
|
+
createFromTitle,
|
|
109
128
|
save,
|
|
110
129
|
linkExisting,
|
|
111
130
|
destroy,
|
|
@@ -20,15 +20,17 @@ export function updateCsrfTokenFromResponse(response) {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
* Fetch a fresh CSRF token from the server.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Fetch a fresh CSRF token from the server. Uses a lightweight HEAD request
|
|
24
|
+
* marked as a prefetch so endpoints that record ordinary visits do not treat
|
|
25
|
+
* the refresh as navigation.
|
|
26
26
|
*/
|
|
27
|
-
export async function refreshCsrfToken() {
|
|
27
|
+
export async function refreshCsrfToken({ signal } = {}) {
|
|
28
28
|
try {
|
|
29
29
|
const response = await fetch(window.location.href, {
|
|
30
30
|
method: 'HEAD',
|
|
31
31
|
credentials: 'same-origin',
|
|
32
|
+
headers: { 'X-Sec-Purpose': 'prefetch' },
|
|
33
|
+
signal,
|
|
32
34
|
})
|
|
33
35
|
updateCsrfTokenFromResponse(response)
|
|
34
36
|
return readCsrfToken()
|
|
@@ -30,22 +30,80 @@ export async function fetchNextTopicName(creativeId) {
|
|
|
30
30
|
* Save the last selected topic for a creative (server-side persistence).
|
|
31
31
|
* @param {string|number} creativeId
|
|
32
32
|
* @param {string|number|null} topicId
|
|
33
|
-
* @
|
|
33
|
+
* @param {string} [clientId] - echoed back on the broadcast, so the session
|
|
34
|
+
* that made this save can recognise it and not treat it as someone else's
|
|
35
|
+
* @param {AbortSignal} [signal] - cancels a request that can no longer keep
|
|
36
|
+
* later selections waiting
|
|
37
|
+
* @returns {Promise<{success: true, lastTopicRevision: unknown}|boolean|null>} `null` means the request outcome is unknown
|
|
38
|
+
* (for example, a network failure after the server received the request).
|
|
34
39
|
*/
|
|
35
|
-
export async function saveLastTopic(creativeId, topicId) {
|
|
40
|
+
export async function saveLastTopic(creativeId, topicId, clientId, signal) {
|
|
36
41
|
try {
|
|
42
|
+
const saveFence = await issueLastTopicSaveFence(creativeId, signal)
|
|
43
|
+
if (saveFence !== undefined && !Number.isSafeInteger(saveFence)) return saveFence
|
|
44
|
+
|
|
45
|
+
const body = { last_topic_id: topicId || null, client_id: clientId || null }
|
|
46
|
+
// An undefined fence means this client fell back through an older
|
|
47
|
+
// instance. The PATCH handler retires outstanding fences for every
|
|
48
|
+
// unfenced save, so the fallback needs no marker of its own — an older
|
|
49
|
+
// bundle cannot send one either, and it is the case that has to be
|
|
50
|
+
// ordered correctly.
|
|
51
|
+
if (saveFence !== undefined) body.last_topic_save_fence = saveFence
|
|
52
|
+
|
|
37
53
|
const response = await fetch(`/creatives/${creativeId}/user_creative_preferences/update_last_topic`, {
|
|
38
54
|
method: 'PATCH',
|
|
39
55
|
headers: {
|
|
40
56
|
'Content-Type': 'application/json',
|
|
41
57
|
'X-CSRF-Token': csrfToken()
|
|
42
58
|
},
|
|
43
|
-
body: JSON.stringify(
|
|
59
|
+
body: JSON.stringify(body),
|
|
60
|
+
signal
|
|
44
61
|
})
|
|
45
|
-
|
|
62
|
+
// A 5xx response can have been generated by a gateway after Rails
|
|
63
|
+
// committed and broadcast the update, so it cannot prove the save did
|
|
64
|
+
// not land. Only an application-level rejection is definitive.
|
|
65
|
+
if (response.ok) {
|
|
66
|
+
let data = {}
|
|
67
|
+
try {
|
|
68
|
+
data = await response.json()
|
|
69
|
+
} catch (_) {
|
|
70
|
+
// Older servers returned an empty success response. Keep accepting it
|
|
71
|
+
// while newer servers provide the revision that orders delayed echoes.
|
|
72
|
+
}
|
|
73
|
+
if (data.success === false) {
|
|
74
|
+
return data.stale_last_topic_save === true
|
|
75
|
+
? { success: false, staleLastTopicSave: true, lastTopicRevision: data.last_topic_revision }
|
|
76
|
+
: false
|
|
77
|
+
}
|
|
78
|
+
return { success: true, lastTopicRevision: data.last_topic_revision }
|
|
79
|
+
}
|
|
80
|
+
return response.status >= 500 ? null : false
|
|
46
81
|
} catch (e) {
|
|
47
82
|
console.error('Error saving last topic', e)
|
|
48
|
-
return
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function issueLastTopicSaveFence(creativeId, signal) {
|
|
88
|
+
try {
|
|
89
|
+
const response = await fetch(`/creatives/${creativeId}/user_creative_preferences/update_last_topic`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
'X-CSRF-Token': csrfToken()
|
|
94
|
+
},
|
|
95
|
+
signal
|
|
96
|
+
})
|
|
97
|
+
// During a rolling deploy, an older instance does not have the POST
|
|
98
|
+
// fence endpoint yet. Its PATCH endpoint remains compatible with the
|
|
99
|
+
// pre-fence request body, so save through that endpoint instead.
|
|
100
|
+
if (response.status === 404) return undefined
|
|
101
|
+
if (!response.ok) return response.status >= 500 ? null : false
|
|
102
|
+
const data = await response.json()
|
|
103
|
+
return Number.isSafeInteger(data.last_topic_save_fence) ? data.last_topic_save_fence : null
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.error('Error issuing last topic save fence', e)
|
|
106
|
+
return null
|
|
49
107
|
}
|
|
50
108
|
}
|
|
51
109
|
|