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,146 +0,0 @@
|
|
|
1
|
-
// Pure logic for client-side typo correction (Phase 1: chat input).
|
|
2
|
-
//
|
|
3
|
-
// This module holds the framework-free core so it can be unit-tested without a
|
|
4
|
-
// DOM: device classification, the 2D gating decision (mirrors the server's
|
|
5
|
-
// `Collavre::User#typo_correction_active_for?`), candidate-list construction for
|
|
6
|
-
// the selection popup, and edit anchoring/application with re-anchoring of the
|
|
7
|
-
// remaining edits after one is applied.
|
|
8
|
-
//
|
|
9
|
-
// The overlay highlight is a *volatile* presentation layer — the textarea value
|
|
10
|
-
// is the only canonical text. Nothing here ever serializes a highlight.
|
|
11
|
-
|
|
12
|
-
export const DEVICE_VOICE = 'voice'
|
|
13
|
-
export const DEVICE_SOFT_KEYBOARD = 'soft_keyboard'
|
|
14
|
-
export const DEVICE_PHYSICAL_KEYBOARD = 'physical_keyboard'
|
|
15
|
-
|
|
16
|
-
// Classify the active typing device from the signals we can observe.
|
|
17
|
-
//
|
|
18
|
-
// - Voice dictation is explicit (the composer exposes its recognition state).
|
|
19
|
-
// - A physical keyboard fires a `keydown` for a printable key immediately
|
|
20
|
-
// before the resulting `input` event. Soft keyboards and IME composition
|
|
21
|
-
// insert text without that printable keydown (key is 'Unidentified'/'Process'
|
|
22
|
-
// or no keydown at all), which is the standard heuristic we rely on.
|
|
23
|
-
export function detectDevice({ voiceActive = false, lastPrintableKeydownAt = null, inputAt = null } = {}) {
|
|
24
|
-
if (voiceActive) return DEVICE_VOICE
|
|
25
|
-
if (lastPrintableKeydownAt == null || inputAt == null) return DEVICE_SOFT_KEYBOARD
|
|
26
|
-
// A printable keydown that landed within the same input tick means a physical
|
|
27
|
-
// key produced this character.
|
|
28
|
-
const delta = inputAt - lastPrintableKeydownAt
|
|
29
|
-
if (delta >= 0 && delta <= 50) return DEVICE_PHYSICAL_KEYBOARD
|
|
30
|
-
return DEVICE_SOFT_KEYBOARD
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Mirror of the server-side 2D gate so the client avoids a pointless round-trip
|
|
34
|
-
// when the feature is off for this device/location. The server re-checks; this
|
|
35
|
-
// is an optimization, not the source of truth.
|
|
36
|
-
export function shouldRun(settings, { device, location }) {
|
|
37
|
-
if (!settings || settings.enabled !== true) return false
|
|
38
|
-
|
|
39
|
-
const deviceOn =
|
|
40
|
-
device === DEVICE_VOICE ? settings.onVoice
|
|
41
|
-
: device === DEVICE_SOFT_KEYBOARD ? settings.onSoftKeyboard
|
|
42
|
-
: device === DEVICE_PHYSICAL_KEYBOARD ? settings.onPhysicalKeyboard
|
|
43
|
-
: false
|
|
44
|
-
if (deviceOn !== true) return false
|
|
45
|
-
|
|
46
|
-
const locationOn =
|
|
47
|
-
location === 'chat' ? settings.inChat
|
|
48
|
-
: location === 'editor' ? settings.inEditor
|
|
49
|
-
: false
|
|
50
|
-
return locationOn === true
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Build the popup's option list for one edit: the word currently sitting in the
|
|
54
|
-
// document first (so Enter = keep), then the suggestions in confidence order,
|
|
55
|
-
// de-duplicated. `currentValue` is whatever text is actually in the textarea at
|
|
56
|
-
// the edit's span right now (original word, or the corrected word if it was
|
|
57
|
-
// auto-applied). `originalWord` is the pre-correction text, surfaced as the undo
|
|
58
|
-
// option when the current value is already the suggestion.
|
|
59
|
-
export function buildCandidateList({ currentValue, originalWord, suggestions = [] }) {
|
|
60
|
-
const seen = new Set()
|
|
61
|
-
const list = []
|
|
62
|
-
const push = (value, role) => {
|
|
63
|
-
if (value == null || value === '') return
|
|
64
|
-
if (seen.has(value)) return
|
|
65
|
-
seen.add(value)
|
|
66
|
-
list.push({ value, label: value, isCurrent: value === currentValue, role })
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Current document value is always the pre-selected, pre-filled option.
|
|
70
|
-
push(currentValue, currentValue === originalWord ? 'original' : 'applied')
|
|
71
|
-
// If we're showing a corrected word, the original is the undo option.
|
|
72
|
-
push(originalWord, 'original')
|
|
73
|
-
// Suggestions, highest confidence first (assumed already sorted; sort defensively).
|
|
74
|
-
;[...suggestions]
|
|
75
|
-
.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
76
|
-
.forEach((s) => push(s.suggestion, 'suggestion'))
|
|
77
|
-
|
|
78
|
-
return list
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Attach character offsets to each edit. Prefer the server-supplied `offset`
|
|
82
|
-
// (the first occurrence the server verified is *outside* a protected span) so a
|
|
83
|
-
// duplicate word sitting inside code/URL/markup is never anchored — anchoring by
|
|
84
|
-
// our own search would bind the first textual occurrence, which can be the
|
|
85
|
-
// protected one the server already excluded. We only trust `offset` if the
|
|
86
|
-
// substring still matches there (text is identical to what the server saw).
|
|
87
|
-
// Without an offset we fall back to a left-to-right search, advancing the cursor
|
|
88
|
-
// so repeated words bind to distinct occurrences. Edits whose original can no
|
|
89
|
-
// longer be found are dropped — the caller re-runs the detector.
|
|
90
|
-
export function anchorEdits(text, edits = []) {
|
|
91
|
-
const anchored = []
|
|
92
|
-
let cursor = 0
|
|
93
|
-
for (const edit of edits) {
|
|
94
|
-
if (!edit || !edit.original) continue
|
|
95
|
-
let idx
|
|
96
|
-
if (
|
|
97
|
-
Number.isInteger(edit.offset)
|
|
98
|
-
&& text.slice(edit.offset, edit.offset + edit.original.length) === edit.original
|
|
99
|
-
) {
|
|
100
|
-
idx = edit.offset
|
|
101
|
-
} else {
|
|
102
|
-
idx = text.indexOf(edit.original, cursor)
|
|
103
|
-
}
|
|
104
|
-
if (idx === -1) continue
|
|
105
|
-
anchored.push({ ...edit, start: idx, end: idx + edit.original.length })
|
|
106
|
-
cursor = Math.max(cursor, idx + edit.original.length)
|
|
107
|
-
}
|
|
108
|
-
return anchored
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Replace the span [start, end) of `text` with `replacement` and return the new
|
|
112
|
-
// text plus the delta, so remaining edits can be shifted. We re-anchor by delta
|
|
113
|
-
// rather than re-searching to avoid mis-binding when the same word repeats.
|
|
114
|
-
export function applyEditAt(text, { start, end, replacement }) {
|
|
115
|
-
const before = text.slice(0, start)
|
|
116
|
-
const after = text.slice(end)
|
|
117
|
-
const newText = before + replacement + after
|
|
118
|
-
const delta = replacement.length - (end - start)
|
|
119
|
-
return { text: newText, delta }
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Shift every edit positioned at/after `fromOffset` by `delta`. Edits before the
|
|
123
|
-
// applied span are untouched; the applied edit itself should be removed by the
|
|
124
|
-
// caller before calling this.
|
|
125
|
-
export function shiftEditsAfter(edits, fromOffset, delta) {
|
|
126
|
-
return edits.map((edit) => {
|
|
127
|
-
if (edit.start >= fromOffset) {
|
|
128
|
-
return { ...edit, start: edit.start + delta, end: edit.end + delta }
|
|
129
|
-
}
|
|
130
|
-
return edit
|
|
131
|
-
})
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Partition edits the server returned into auto-applied (confidence at/above the
|
|
135
|
-
// user's threshold) and candidates (below it). Threshold is 0–100 to match the
|
|
136
|
-
// profile setting; confidence is 0–1 from the model.
|
|
137
|
-
export function partitionByThreshold(edits = [], threshold = 80) {
|
|
138
|
-
const cut = threshold / 100
|
|
139
|
-
const autoApplied = []
|
|
140
|
-
const candidates = []
|
|
141
|
-
for (const edit of edits) {
|
|
142
|
-
if ((edit.confidence ?? 0) >= cut) autoApplied.push(edit)
|
|
143
|
-
else candidates.push(edit)
|
|
144
|
-
}
|
|
145
|
-
return { autoApplied, candidates }
|
|
146
|
-
}
|
|
@@ -1,365 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment jsdom
|
|
3
|
-
*/
|
|
4
|
-
import { TypoCorrector, initTypoCorrector } from '../typo_corrector'
|
|
5
|
-
|
|
6
|
-
// Exercises the DOM orchestration that the pure-logic tests don't reach:
|
|
7
|
-
// auto-applying high-confidence edits into the textarea (caret preserved),
|
|
8
|
-
// painting the two highlight states onto the backdrop, and resolving an edit
|
|
9
|
-
// through _chooseValue. Network is bypassed by calling _applyResult directly.
|
|
10
|
-
|
|
11
|
-
const settings = {
|
|
12
|
-
enabled: true,
|
|
13
|
-
threshold: 80,
|
|
14
|
-
onVoice: true,
|
|
15
|
-
onSoftKeyboard: true,
|
|
16
|
-
onPhysicalKeyboard: false,
|
|
17
|
-
inChat: true,
|
|
18
|
-
inEditor: false,
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function mount(value) {
|
|
22
|
-
document.body.innerHTML = '<form><textarea>' + value + '</textarea></form>'
|
|
23
|
-
const textarea = document.querySelector('textarea')
|
|
24
|
-
textarea.value = value
|
|
25
|
-
const tc = new TypoCorrector(textarea, { settings })
|
|
26
|
-
return { textarea, tc }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
describe('TypoCorrector DOM orchestration', () => {
|
|
30
|
-
afterEach(() => { document.body.innerHTML = '' })
|
|
31
|
-
|
|
32
|
-
test('wraps textarea in a backdrop overlay', () => {
|
|
33
|
-
const { textarea } = mount('hello')
|
|
34
|
-
expect(textarea.parentNode.classList.contains('typo-input-wrap')).toBe(true)
|
|
35
|
-
expect(textarea.parentNode.querySelector('.typo-highlights')).not.toBeNull()
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
test('auto-applies a high-confidence edit into the textarea value', () => {
|
|
39
|
-
const { textarea, tc } = mount('잇습니다 그리고')
|
|
40
|
-
tc._applyResult({
|
|
41
|
-
edits: [{ original: '잇습니다', suggestion: '있습니다', confidence: 0.95 }],
|
|
42
|
-
threshold: 80,
|
|
43
|
-
})
|
|
44
|
-
expect(textarea.value).toBe('있습니다 그리고')
|
|
45
|
-
// The applied edit is highlighted with the "applied" (resolved) state.
|
|
46
|
-
const mark = textarea.parentNode.querySelector('.typo-mark-applied')
|
|
47
|
-
expect(mark).not.toBeNull()
|
|
48
|
-
expect(mark.textContent).toBe('있습니다')
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
test('low-confidence edit becomes a candidate highlight, text untouched', () => {
|
|
52
|
-
const { textarea, tc } = mount('teh cat')
|
|
53
|
-
tc._applyResult({
|
|
54
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
55
|
-
threshold: 80,
|
|
56
|
-
})
|
|
57
|
-
expect(textarea.value).toBe('teh cat') // not auto-applied
|
|
58
|
-
const mark = textarea.parentNode.querySelector('.typo-mark-candidate')
|
|
59
|
-
expect(mark).not.toBeNull()
|
|
60
|
-
expect(mark.textContent).toBe('teh')
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
test('choosing the suggestion applies it and clears the highlight', () => {
|
|
64
|
-
const { textarea, tc } = mount('teh cat')
|
|
65
|
-
tc._applyResult({
|
|
66
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
67
|
-
threshold: 80,
|
|
68
|
-
})
|
|
69
|
-
const edit = tc.edits[0]
|
|
70
|
-
tc._ensurePopup()
|
|
71
|
-
tc._activeEdit = edit
|
|
72
|
-
tc._chooseValue('the')
|
|
73
|
-
expect(textarea.value).toBe('the cat')
|
|
74
|
-
expect(textarea.parentNode.querySelector('.typo-mark')).toBeNull()
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
test('keeping (current value) just clears the highlight without changing text', () => {
|
|
78
|
-
const { textarea, tc } = mount('teh cat')
|
|
79
|
-
tc._applyResult({
|
|
80
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
81
|
-
threshold: 80,
|
|
82
|
-
})
|
|
83
|
-
const edit = tc.edits[0]
|
|
84
|
-
tc._ensurePopup()
|
|
85
|
-
tc._activeEdit = edit
|
|
86
|
-
tc._chooseValue('teh') // keep
|
|
87
|
-
expect(textarea.value).toBe('teh cat')
|
|
88
|
-
expect(tc.edits).toHaveLength(0)
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
test('typing past a highlighted span drops the stale highlight on repaint', () => {
|
|
92
|
-
const { textarea, tc } = mount('teh cat')
|
|
93
|
-
tc._applyResult({
|
|
94
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
95
|
-
threshold: 80,
|
|
96
|
-
})
|
|
97
|
-
expect(tc.edits).toHaveLength(1)
|
|
98
|
-
textarea.value = 'text cat' // user edited the span
|
|
99
|
-
tc._repaint()
|
|
100
|
-
expect(tc.edits).toHaveLength(0)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
test('device classification uses the input-time stamp, surviving the debounce (P1)', () => {
|
|
104
|
-
// Before the fix, the device was classified at detect time (after the 600ms
|
|
105
|
-
// debounce), so the keydown→detect gap read ~600ms and every physical
|
|
106
|
-
// keypress was misread as a soft keyboard — bypassing the default-off gate.
|
|
107
|
-
const realNow = performance.now
|
|
108
|
-
let t = 1000
|
|
109
|
-
performance.now = () => t
|
|
110
|
-
try {
|
|
111
|
-
const { textarea, tc } = mount('')
|
|
112
|
-
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) // physical keydown @1000
|
|
113
|
-
t = 1002
|
|
114
|
-
textarea.value = 'a'
|
|
115
|
-
textarea.dispatchEvent(new Event('input', { bubbles: true })) // input @1002 → lastInputAt
|
|
116
|
-
t = 1700 // debounce fires ~700ms later, when _detect calls _currentDevice
|
|
117
|
-
expect(tc._currentDevice()).toBe('physical_keyboard')
|
|
118
|
-
} finally {
|
|
119
|
-
performance.now = realNow
|
|
120
|
-
}
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
test('auto-applied edit anchors to the corrected span, not an earlier twin (P2)', () => {
|
|
124
|
-
// "the teh" → correct the 2nd word. The highlight/undo must bind the second
|
|
125
|
-
// word (offset 4), not the pre-existing "the" at offset 0.
|
|
126
|
-
const { textarea, tc } = mount('the teh')
|
|
127
|
-
tc._applyResult({
|
|
128
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.95, offset: 4 }],
|
|
129
|
-
threshold: 80,
|
|
130
|
-
})
|
|
131
|
-
expect(textarea.value).toBe('the the')
|
|
132
|
-
const edit = tc.edits.find((e) => e.state === 'applied')
|
|
133
|
-
expect(edit.start).toBe(4)
|
|
134
|
-
expect(edit.end).toBe(7)
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
test('popup renders option labels as text, never as HTML (DOM-XSS safe)', () => {
|
|
138
|
-
const { tc } = mount('teh cat')
|
|
139
|
-
tc._applyResult({ edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }], threshold: 80 })
|
|
140
|
-
tc._ensurePopup()
|
|
141
|
-
tc._activeEdit = tc.edits[0]
|
|
142
|
-
const payload = '<img src=x onerror=alert(1)>'
|
|
143
|
-
tc.popup.setItems([{ value: payload, label: payload, role: 'custom' }])
|
|
144
|
-
const list = tc.popupEl.querySelector('.typo-popup-list')
|
|
145
|
-
expect(list.querySelector('img')).toBeNull() // not parsed as markup
|
|
146
|
-
expect(list.textContent).toContain(payload) // shown verbatim as text
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
test('undo of an auto-applied edit rewrites the corrected span, not an earlier twin (P2)', () => {
|
|
150
|
-
const { textarea, tc } = mount('the teh')
|
|
151
|
-
tc._applyResult({
|
|
152
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.95, offset: 4 }],
|
|
153
|
-
threshold: 80,
|
|
154
|
-
})
|
|
155
|
-
const edit = tc.edits.find((e) => e.state === 'applied')
|
|
156
|
-
tc._ensurePopup()
|
|
157
|
-
tc._activeEdit = edit
|
|
158
|
-
tc._chooseValue('teh') // undo back to the original
|
|
159
|
-
expect(textarea.value).toBe('the teh')
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
test('auto-apply does not re-trigger detection, so the undo affordance survives (P2)', () => {
|
|
163
|
-
const { textarea, tc } = mount('잇습니다')
|
|
164
|
-
let detectCalls = 0
|
|
165
|
-
tc._scheduleDetect = () => { detectCalls += 1 }
|
|
166
|
-
tc._applyResult({
|
|
167
|
-
edits: [{ original: '잇습니다', suggestion: '있습니다', confidence: 0.95 }],
|
|
168
|
-
threshold: 80,
|
|
169
|
-
})
|
|
170
|
-
// The synthetic `input` event from our own auto-apply must be swallowed: a
|
|
171
|
-
// re-detect would return [] on the now-clean text and wipe the highlight.
|
|
172
|
-
expect(detectCalls).toBe(0)
|
|
173
|
-
expect(textarea.parentNode.querySelector('.typo-mark-applied')).not.toBeNull()
|
|
174
|
-
|
|
175
|
-
// A *real* keystroke afterwards still schedules detection.
|
|
176
|
-
textarea.value = '있습니다 어쩌구'
|
|
177
|
-
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
178
|
-
expect(detectCalls).toBe(1)
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
test('clicking a mark opens the popup and the opening mousedown does not close it', () => {
|
|
182
|
-
// Regression: the popup opens on a mousedown. CommonPopup.showAt registered
|
|
183
|
-
// its document-level outside-click mousedown listener synchronously, so the
|
|
184
|
-
// very mousedown that opened the popup kept bubbling to document, hit that
|
|
185
|
-
// listener (target = the mark, outside the popup), and called hide() — the
|
|
186
|
-
// popup opened and instantly closed, so the user never saw it.
|
|
187
|
-
const { textarea, tc } = mount('teh cat')
|
|
188
|
-
tc._applyResult({
|
|
189
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
190
|
-
threshold: 80,
|
|
191
|
-
})
|
|
192
|
-
const mark = textarea.parentNode.querySelector('.typo-mark-candidate')
|
|
193
|
-
expect(mark).not.toBeNull()
|
|
194
|
-
mark.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
195
|
-
expect(tc.popup.isOpen()).toBe(true)
|
|
196
|
-
expect(tc.popupEl.style.display).toBe('block')
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
test('a mark re-opens the popup on a second click while it is still open', () => {
|
|
200
|
-
// Regression: showAt registers the document outside-click listener one frame
|
|
201
|
-
// after opening. Clicking the (still-open) mark again sends a mousedown that
|
|
202
|
-
// bubbles to that live listener, which hid the popup — and showAt's fresh rAF
|
|
203
|
-
// only re-flips visibility, never display, so the popup stayed display:none
|
|
204
|
-
// and every later click repeated the cycle (permanently dead after click 1).
|
|
205
|
-
// Use queued rAF so the listener registers AFTER the opening mousedown, as in
|
|
206
|
-
// a real browser.
|
|
207
|
-
const rafs = []
|
|
208
|
-
const realRaf = window.requestAnimationFrame
|
|
209
|
-
window.requestAnimationFrame = (cb) => { rafs.push(cb); return rafs.length }
|
|
210
|
-
const flush = () => rafs.splice(0).forEach((cb) => cb())
|
|
211
|
-
try {
|
|
212
|
-
const { textarea, tc } = mount('teh cat')
|
|
213
|
-
tc._applyResult({
|
|
214
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
215
|
-
threshold: 80,
|
|
216
|
-
})
|
|
217
|
-
const mark = () => textarea.parentNode.querySelector('.typo-mark-candidate')
|
|
218
|
-
mark().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
219
|
-
flush() // showAt's rAF registers the outside-click listener
|
|
220
|
-
expect(tc.popup.isOpen()).toBe(true)
|
|
221
|
-
|
|
222
|
-
mark().dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
223
|
-
flush()
|
|
224
|
-
expect(tc.popup.isOpen()).toBe(true)
|
|
225
|
-
} finally {
|
|
226
|
-
window.requestAnimationFrame = realRaf
|
|
227
|
-
}
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
test('opening the popup focuses and selects the input (desktop) for instant replace', () => {
|
|
231
|
-
// showAt keeps the popup visibility:hidden until its own rAF, and a hidden
|
|
232
|
-
// element is not focusable — so focus/select are deferred into a rAF that
|
|
233
|
-
// runs after showAt's. Flush rAF synchronously to exercise that path.
|
|
234
|
-
const realRaf = window.requestAnimationFrame
|
|
235
|
-
window.requestAnimationFrame = (cb) => { cb(); return 0 }
|
|
236
|
-
try {
|
|
237
|
-
const { textarea, tc } = mount('teh cat')
|
|
238
|
-
tc._applyResult({
|
|
239
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }],
|
|
240
|
-
threshold: 80,
|
|
241
|
-
})
|
|
242
|
-
const mark = textarea.parentNode.querySelector('.typo-mark-candidate')
|
|
243
|
-
mark.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
244
|
-
expect(document.activeElement).toBe(tc.popupInput)
|
|
245
|
-
expect(tc.popupInput.value).toBe('teh') // current word, pre-filled
|
|
246
|
-
} finally {
|
|
247
|
-
window.requestAnimationFrame = realRaf
|
|
248
|
-
}
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
test('popup input carries a localized aria-label', () => {
|
|
252
|
-
document.body.innerHTML = '<form><textarea></textarea></form>'
|
|
253
|
-
const textarea = document.querySelector('textarea')
|
|
254
|
-
const tc = new TypoCorrector(textarea, { settings, labels: { inputLabel: '교정' } })
|
|
255
|
-
tc._ensurePopup()
|
|
256
|
-
expect(tc.popupInput.getAttribute('aria-label')).toBe('교정')
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
test('form reset clears stale marks off the now-empty composer', () => {
|
|
260
|
-
// form.reset() empties the textarea without an `input` event, so without the
|
|
261
|
-
// reset listener the previous draft's marks would linger and stay clickable.
|
|
262
|
-
const realRaf = window.requestAnimationFrame
|
|
263
|
-
window.requestAnimationFrame = (cb) => { cb(); return 0 }
|
|
264
|
-
try {
|
|
265
|
-
document.body.innerHTML = '<form><textarea></textarea></form>'
|
|
266
|
-
const form = document.querySelector('form')
|
|
267
|
-
const textarea = document.querySelector('textarea')
|
|
268
|
-
const tc = new TypoCorrector(textarea, { settings })
|
|
269
|
-
textarea.value = 'teh cat'
|
|
270
|
-
tc._applyResult({ edits: [{ original: 'teh', suggestion: 'the', confidence: 0.4 }], threshold: 80 })
|
|
271
|
-
expect(textarea.parentNode.querySelector('.typo-mark')).not.toBeNull()
|
|
272
|
-
|
|
273
|
-
form.reset()
|
|
274
|
-
expect(tc.edits).toHaveLength(0)
|
|
275
|
-
expect(textarea.parentNode.querySelector('.typo-mark')).toBeNull()
|
|
276
|
-
} finally {
|
|
277
|
-
window.requestAnimationFrame = realRaf
|
|
278
|
-
}
|
|
279
|
-
})
|
|
280
|
-
|
|
281
|
-
test('destroy unwraps the textarea and clears the bind marker (Turbo before-cache)', () => {
|
|
282
|
-
// Turbo caches the DOM but not JS listeners; the snapshot must not keep the
|
|
283
|
-
// injected backdrop or typoBound, or the restored page would duplicate the
|
|
284
|
-
// overlay / skip re-init and leave correction dead until a full reload.
|
|
285
|
-
document.body.innerHTML = '<form><textarea></textarea></form>'
|
|
286
|
-
const form = document.querySelector('form')
|
|
287
|
-
const textarea = document.querySelector('textarea')
|
|
288
|
-
textarea.dataset.typoBound = 'true'
|
|
289
|
-
const tc = new TypoCorrector(textarea, { settings })
|
|
290
|
-
expect(textarea.parentNode.classList.contains('typo-input-wrap')).toBe(true)
|
|
291
|
-
|
|
292
|
-
// The combobox popup lives on document.body, so destroy() must remove it too;
|
|
293
|
-
// otherwise the Turbo snapshot serializes an orphan popup and the next
|
|
294
|
-
// corrector appends a duplicate.
|
|
295
|
-
tc._ensurePopup()
|
|
296
|
-
expect(document.body.querySelector('.typo-popup')).not.toBeNull()
|
|
297
|
-
|
|
298
|
-
tc.destroy()
|
|
299
|
-
expect(textarea.dataset.typoBound).toBeUndefined()
|
|
300
|
-
expect(textarea.parentNode).toBe(form) // unwrapped back to the form
|
|
301
|
-
expect(form.querySelector('.typo-input-wrap')).toBeNull()
|
|
302
|
-
expect(form.querySelector('.typo-backdrop')).toBeNull()
|
|
303
|
-
expect(document.body.querySelector('.typo-popup')).toBeNull()
|
|
304
|
-
expect(tc.popupEl).toBeNull()
|
|
305
|
-
})
|
|
306
|
-
|
|
307
|
-
test('earlier corrections stay clickable across detection rounds (not just the last)', () => {
|
|
308
|
-
// Each detection round used to replace this.edits wholesale, and the server
|
|
309
|
-
// is stateless — it never re-reports an already-corrected word. So a second
|
|
310
|
-
// round (triggered by typing more) wiped the first correction's undo mark,
|
|
311
|
-
// leaving only the most recent correction clickable. Earlier corrections must
|
|
312
|
-
// keep their mark so the user can still undo them.
|
|
313
|
-
const { textarea, tc } = mount('잇습니다 hello')
|
|
314
|
-
|
|
315
|
-
// Round 1: auto-correct 잇습니다 → 있습니다 (high confidence).
|
|
316
|
-
tc._applyResult({
|
|
317
|
-
edits: [{ original: '잇습니다', suggestion: '있습니다', confidence: 0.95 }],
|
|
318
|
-
threshold: 80,
|
|
319
|
-
})
|
|
320
|
-
expect(textarea.value).toBe('있습니다 hello')
|
|
321
|
-
expect(textarea.parentNode.querySelectorAll('.typo-mark')).toHaveLength(1)
|
|
322
|
-
|
|
323
|
-
// User types another typo; a fresh detection round returns only the NEW word.
|
|
324
|
-
textarea.value = '있습니다 hello teh'
|
|
325
|
-
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
|
326
|
-
tc._applyResult({
|
|
327
|
-
edits: [{ original: 'teh', suggestion: 'the', confidence: 0.95 }],
|
|
328
|
-
threshold: 80,
|
|
329
|
-
})
|
|
330
|
-
expect(textarea.value).toBe('있습니다 hello the')
|
|
331
|
-
|
|
332
|
-
// Both corrections must be present and anchored to their own word.
|
|
333
|
-
const marks = [...textarea.parentNode.querySelectorAll('.typo-mark')]
|
|
334
|
-
expect(marks.map((m) => m.textContent).sort()).toEqual(['the', '있습니다'])
|
|
335
|
-
// And each mark resolves to its own edit (not all pointing at the last one).
|
|
336
|
-
const byWord = Object.fromEntries(tc.edits.map((e) => [e.currentValue, e]))
|
|
337
|
-
expect(textarea.value.slice(byWord['있습니다'].start, byWord['있습니다'].end)).toBe('있습니다')
|
|
338
|
-
expect(textarea.value.slice(byWord['the'].start, byWord['the'].end)).toBe('the')
|
|
339
|
-
})
|
|
340
|
-
})
|
|
341
|
-
|
|
342
|
-
describe('initTypoCorrector endpoint wiring', () => {
|
|
343
|
-
afterEach(() => { document.body.innerHTML = '' })
|
|
344
|
-
|
|
345
|
-
function mountPopup(endpoint) {
|
|
346
|
-
document.body.innerHTML =
|
|
347
|
-
'<div id="comments-popup" data-typo-enabled="true" data-typo-threshold="80"' +
|
|
348
|
-
(endpoint == null ? '' : ' data-typo-endpoint="' + endpoint + '"') + '></div>' +
|
|
349
|
-
'<form id="new-comment-form"><textarea></textarea></form>'
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// The engine can be mounted at a subpath (e.g. /collavre), so a root-relative
|
|
353
|
-
// default would 404. The endpoint must come from the engine-rendered dataset.
|
|
354
|
-
test('uses the mounted engine endpoint from the dataset', () => {
|
|
355
|
-
mountPopup('/collavre/typo_corrections')
|
|
356
|
-
const tc = initTypoCorrector()
|
|
357
|
-
expect(tc.endpoint).toBe('/collavre/typo_corrections')
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
test('falls back to the root-relative path when no endpoint is provided', () => {
|
|
361
|
-
mountPopup(null)
|
|
362
|
-
const tc = initTypoCorrector()
|
|
363
|
-
expect(tc.endpoint).toBe('/typo_corrections')
|
|
364
|
-
})
|
|
365
|
-
})
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// Creative guide popover functionality
|
|
2
|
-
// Handles the help button click and popover display
|
|
3
|
-
|
|
4
|
-
import { notifyPopupOpen, onOtherPopupOpen } from '../lib/gnb_popup_manager'
|
|
5
|
-
|
|
6
|
-
const POPUP_ID = 'creative-guide'
|
|
7
|
-
|
|
8
|
-
// Single document-level click handler for dismissing popover (delegated)
|
|
9
|
-
let documentClickHandlerAttached = false
|
|
10
|
-
let popupListenerRegistered = false
|
|
11
|
-
|
|
12
|
-
function setupDocumentClickHandler() {
|
|
13
|
-
if (documentClickHandlerAttached) return
|
|
14
|
-
documentClickHandlerAttached = true
|
|
15
|
-
|
|
16
|
-
document.addEventListener('click', function(e) {
|
|
17
|
-
const popover = document.getElementById('creative-guide-popover')
|
|
18
|
-
if (!popover || popover.style.display !== 'block') return
|
|
19
|
-
|
|
20
|
-
const links = document.querySelectorAll('.creative-guide-link')
|
|
21
|
-
const isClickOnLink = Array.from(links).some(link => link.contains(e.target))
|
|
22
|
-
const isClickOnPopover = popover.contains(e.target)
|
|
23
|
-
|
|
24
|
-
if (!isClickOnLink && !isClickOnPopover) {
|
|
25
|
-
popover.style.display = 'none'
|
|
26
|
-
}
|
|
27
|
-
})
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function setupCreativeGuide() {
|
|
31
|
-
const links = document.querySelectorAll('.creative-guide-link')
|
|
32
|
-
const popover = document.getElementById('creative-guide-popover')
|
|
33
|
-
const close = document.getElementById('close-creative-guide')
|
|
34
|
-
|
|
35
|
-
if (links.length && popover && close) {
|
|
36
|
-
if (!popupListenerRegistered) {
|
|
37
|
-
popupListenerRegistered = true
|
|
38
|
-
onOtherPopupOpen(POPUP_ID, function() {
|
|
39
|
-
const p = document.getElementById('creative-guide-popover')
|
|
40
|
-
if (p) p.style.display = 'none'
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
links.forEach(function(link) {
|
|
45
|
-
// Use onclick assignment to avoid duplicate listeners
|
|
46
|
-
link.onclick = function(e) {
|
|
47
|
-
if (link.dataset.helpUrl) {
|
|
48
|
-
window.location.href = link.dataset.helpUrl
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
e.preventDefault()
|
|
52
|
-
notifyPopupOpen(POPUP_ID)
|
|
53
|
-
popover.style.display = 'block'
|
|
54
|
-
}
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
// Use onclick assignment for close button
|
|
58
|
-
close.onclick = function() {
|
|
59
|
-
popover.style.display = 'none'
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Set up single document click handler
|
|
63
|
-
setupDocumentClickHandler()
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
document.addEventListener('turbo:load', setupCreativeGuide)
|