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
|
@@ -4,6 +4,7 @@ import { wrapHtmlInCodeBlocks } from '../../lib/html_code_block_wrapper'
|
|
|
4
4
|
import { refreshCsrfToken } from '../../lib/api/csrf_fetch'
|
|
5
5
|
import ReviewQuotesStore from './review_quotes_store'
|
|
6
6
|
import { alertDialog } from '../../lib/utils/dialog'
|
|
7
|
+
import chatDrafts from '../../lib/chat_drafts'
|
|
7
8
|
|
|
8
9
|
// In-flight comment sends, keyed by creative id. This lives at module scope —
|
|
9
10
|
// not on the controller instance — so the duplicate-submit guard survives a
|
|
@@ -36,14 +37,14 @@ export default class extends Controller {
|
|
|
36
37
|
|
|
37
38
|
connect() {
|
|
38
39
|
this.creativeId = null
|
|
39
|
-
this.editingId
|
|
40
|
+
this.editingId ??= null
|
|
40
41
|
this.sending = false
|
|
41
|
-
this._reviewStore
|
|
42
|
+
this._reviewStore ||= new ReviewQuotesStore()
|
|
42
43
|
this.cachedImageFiles = null
|
|
43
44
|
|
|
44
45
|
this.handleSubmit = this.handleSubmit.bind(this)
|
|
45
46
|
this.handleSend = this.handleSend.bind(this)
|
|
46
|
-
this.defaultSubmitHTML
|
|
47
|
+
this.defaultSubmitHTML ??= this.submitTarget.innerHTML
|
|
47
48
|
this.handlePointerSend = this.handlePointerSend.bind(this)
|
|
48
49
|
this.handleTouchSend = this.handleTouchSend.bind(this)
|
|
49
50
|
this.handleCancel = this.handleCancel.bind(this)
|
|
@@ -89,11 +90,125 @@ export default class extends Controller {
|
|
|
89
90
|
}
|
|
90
91
|
this.textareaTarget.addEventListener('input', this._autoResize)
|
|
91
92
|
|
|
93
|
+
// Draft persistence: debounce-save unsent input per chat.
|
|
94
|
+
// _activeDraftKey always identifies the chat whose text is in the textarea.
|
|
95
|
+
this._activeDraftKey ??= null
|
|
96
|
+
this._activeDraftCreativeId ??= null
|
|
97
|
+
this._awaitingEffectiveDraftKeyFor ??= null
|
|
98
|
+
this._draftSaveTimer = null
|
|
99
|
+
this._draftSaveSuspendedForPermission ??= false
|
|
100
|
+
// A cross-tab logout permanently retires the old user's namespace for this
|
|
101
|
+
// controller lifetime, including Stimulus reconnects in the stale tab.
|
|
102
|
+
this._disabledDraftNamespaces ||= new Set()
|
|
103
|
+
this._draftBackupCleanupPendingNamespaces ||= new Set()
|
|
104
|
+
this._observedDraftClearNonces ||= new Map()
|
|
105
|
+
// A pending send survives a Stimulus reconnect on this controller instance,
|
|
106
|
+
// so its completion must keep comparing against the same draft history.
|
|
107
|
+
this._draftRevisions ||= new Map()
|
|
108
|
+
this._observedDrafts ||= new Map()
|
|
109
|
+
this._observedDisplayedDrafts ||= new Map()
|
|
110
|
+
this._observedDraftRevisions ||= new Map()
|
|
111
|
+
this._observedStoredDraftRevisions ||= new Map()
|
|
112
|
+
// A linked chat can replace its temporary raw key while its request is in
|
|
113
|
+
// flight. Keep mutable submission state across that migration/reconnect.
|
|
114
|
+
this._pendingDraftSubmissions ||= new Set()
|
|
115
|
+
const draftNamespace = chatDrafts.namespace()
|
|
116
|
+
const draftClearNonce = chatDrafts.clearNonce(draftNamespace)
|
|
117
|
+
const draftClearNoncePending = chatDrafts.clearNoncePending(draftNamespace)
|
|
118
|
+
const observedDraftClearNonce = this._observedDraftClearNonces.has(draftNamespace)
|
|
119
|
+
let canObserveDraftClearNonce = true
|
|
120
|
+
if (draftClearNonce && !observedDraftClearNonce) {
|
|
121
|
+
canObserveDraftClearNonce = chatDrafts.clearSubmissionBackupsForClear(
|
|
122
|
+
draftNamespace,
|
|
123
|
+
draftClearNonce,
|
|
124
|
+
)
|
|
125
|
+
if (canObserveDraftClearNonce && !draftClearNoncePending) {
|
|
126
|
+
this._draftBackupCleanupPendingNamespaces.delete(draftNamespace)
|
|
127
|
+
} else {
|
|
128
|
+
this._draftBackupCleanupPendingNamespaces.add(draftNamespace)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (
|
|
132
|
+
draftClearNonce &&
|
|
133
|
+
observedDraftClearNonce &&
|
|
134
|
+
this._observedDraftClearNonces.get(draftNamespace) !== draftClearNonce
|
|
135
|
+
) {
|
|
136
|
+
this._disableDraftNamespace(draftNamespace)
|
|
137
|
+
}
|
|
138
|
+
if (
|
|
139
|
+
draftClearNonce !== undefined &&
|
|
140
|
+
canObserveDraftClearNonce &&
|
|
141
|
+
!draftClearNoncePending
|
|
142
|
+
) {
|
|
143
|
+
this._observedDraftClearNonces.set(draftNamespace, draftClearNonce)
|
|
144
|
+
}
|
|
145
|
+
this._handlePageHide = () => {
|
|
146
|
+
if (!this.element.isConnected) return
|
|
147
|
+
|
|
148
|
+
this._flushDraftSave()
|
|
149
|
+
}
|
|
150
|
+
this._handleDraftStorage = (event) => {
|
|
151
|
+
if (!this.element.isConnected || !chatDrafts.wasCleared(event)) return
|
|
152
|
+
|
|
153
|
+
const clearedNamespace = chatDrafts.namespace()
|
|
154
|
+
this._disableDraftNamespace(clearedNamespace)
|
|
155
|
+
}
|
|
156
|
+
this._handleCompositionEnd = () => {
|
|
157
|
+
this._imeCommitPending = true
|
|
158
|
+
}
|
|
159
|
+
// Chrome fires the commit `input` after `compositionend`, but Firefox has
|
|
160
|
+
// shipped the reverse order, where the commit input consumes the latch
|
|
161
|
+
// before it is even set. Nothing would clear the leftover latch, so the
|
|
162
|
+
// next ordinary edit would be misread as a commit. The commit input always
|
|
163
|
+
// arrives before the user can act again, so any fresh gesture expires it.
|
|
164
|
+
this._expireImeCommitLatch = (event) => {
|
|
165
|
+
if (event?.isComposing || event?.keyCode === 229) return
|
|
166
|
+
|
|
167
|
+
this._imeCommitPending = false
|
|
168
|
+
}
|
|
169
|
+
this._handleDraftInput = (event) => {
|
|
170
|
+
// Consume the composition flag before any early return, so it can never
|
|
171
|
+
// outlive the `input` event that the commit itself fired. Firefox has
|
|
172
|
+
// shipped both orderings, so accept isComposing on the input event too.
|
|
173
|
+
const isImeCommit = Boolean(event?.isComposing) || this._imeCommitPending === true
|
|
174
|
+
this._imeCommitPending = false
|
|
175
|
+
if (
|
|
176
|
+
this.editingId ||
|
|
177
|
+
this._draftPersistenceDisabled() ||
|
|
178
|
+
this._draftSaveSuspendedForPermission ||
|
|
179
|
+
this._shouldSuppressDraftSaveForStash() ||
|
|
180
|
+
!this._reviewStore.isEmpty ||
|
|
181
|
+
!this._activeDraftKey
|
|
182
|
+
) return
|
|
183
|
+
// An IME commit fires `input` after the keydown that started a send, so
|
|
184
|
+
// the textarea still holds exactly what went to the server. Counting it
|
|
185
|
+
// as a revision would defeat _currentPendingSubmission and make the sent
|
|
186
|
+
// message look like a draft typed mid-flight, restoring it on success.
|
|
187
|
+
// Text equality alone is not enough to suppress: re-entering the same
|
|
188
|
+
// string mid-flight (select-all + paste to send it twice) is a real edit.
|
|
189
|
+
if (isImeCommit && this._currentTextIsPendingSubmission()) return
|
|
190
|
+
const revisionKey = `${chatDrafts.namespace()}:${this._activeDraftKey}`
|
|
191
|
+
this._draftRevisions.set(revisionKey, (this._draftRevisions.get(revisionKey) || 0) + 1)
|
|
192
|
+
clearTimeout(this._draftSaveTimer)
|
|
193
|
+
this._draftSaveTimer = setTimeout(() => this._saveDraftNow(), 500)
|
|
194
|
+
}
|
|
195
|
+
this.textareaTarget.addEventListener('compositionend', this._handleCompositionEnd)
|
|
196
|
+
this.textareaTarget.addEventListener('keydown', this._expireImeCommitLatch)
|
|
197
|
+
this.textareaTarget.addEventListener('paste', this._expireImeCommitLatch)
|
|
198
|
+
this.textareaTarget.addEventListener('drop', this._expireImeCommitLatch)
|
|
199
|
+
this.textareaTarget.addEventListener('input', this._handleDraftInput)
|
|
200
|
+
window.addEventListener('pagehide', this._handlePageHide)
|
|
201
|
+
window.addEventListener('storage', this._handleDraftStorage)
|
|
202
|
+
|
|
92
203
|
this.textareaTarget.addEventListener('keydown', (event) => {
|
|
93
204
|
if (event.key === 'Escape') {
|
|
94
205
|
this.presenceController?.cancelAllAgentTasks()
|
|
95
206
|
return
|
|
96
207
|
}
|
|
208
|
+
// While an IME is composing (Korean, Japanese, Chinese), Enter confirms
|
|
209
|
+
// the pending syllable rather than submitting. keyCode 229 covers
|
|
210
|
+
// browsers that leave isComposing unset on the keydown itself.
|
|
211
|
+
if (event.isComposing || event.keyCode === 229) return
|
|
97
212
|
if (event.key === 'Enter' && !event.shiftKey) {
|
|
98
213
|
if (this.isMentionMenuVisible()) return
|
|
99
214
|
this.handleSend(event)
|
|
@@ -107,9 +222,76 @@ export default class extends Controller {
|
|
|
107
222
|
|
|
108
223
|
this.handleListLoaded = () => this._updateInboxReplyMode()
|
|
109
224
|
this.element.addEventListener('comments--list:loaded', this.handleListLoaded)
|
|
225
|
+
|
|
226
|
+
// A slash command with an input schema takes over the textarea and submits
|
|
227
|
+
// itself (modules/command_menu.js), so a draft the user had already typed
|
|
228
|
+
// cannot go out with it. The menu hands the draft over here instead of
|
|
229
|
+
// discarding it, and we put it back once the send settles.
|
|
230
|
+
this.handleStashDraft = this.handleStashDraft.bind(this)
|
|
231
|
+
this.element.addEventListener('comments--form:stash-draft', this.handleStashDraft)
|
|
110
232
|
}
|
|
111
233
|
|
|
112
234
|
handleTopicChange(event) {
|
|
235
|
+
// This event fires before onPopupOpened during a chat switch, while the
|
|
236
|
+
// textarea still contains the outgoing chat's text. Flush before re-keying.
|
|
237
|
+
const draftPersistenceDisabled = this._draftPersistenceDisabled()
|
|
238
|
+
const nextDraftKey = draftPersistenceDisabled
|
|
239
|
+
? null
|
|
240
|
+
: this.element.dataset.effectiveCreativeId || this.element.dataset.creativeId || null
|
|
241
|
+
const nextCreativeId = draftPersistenceDisabled
|
|
242
|
+
? null
|
|
243
|
+
: this.element.dataset.creativeId || null
|
|
244
|
+
const draftKeyChanged = String(this._activeDraftKey || '') !== String(nextDraftKey || '')
|
|
245
|
+
const creativeChanged =
|
|
246
|
+
String(this._activeDraftCreativeId || '') !== String(nextCreativeId || '')
|
|
247
|
+
const resolvingIncomingDraftKey =
|
|
248
|
+
this._awaitingEffectiveDraftKeyFor &&
|
|
249
|
+
String(this._awaitingEffectiveDraftKeyFor) === String(nextCreativeId || '')
|
|
250
|
+
let keepAwaitingEffectiveDraftKey = false
|
|
251
|
+
if (draftKeyChanged || creativeChanged) {
|
|
252
|
+
const previousDraftKey = this._activeDraftKey
|
|
253
|
+
// onChatWillOpen already flushed the outgoing chat. While the effective
|
|
254
|
+
// key is loading, save only actual new input; rewriting a restored raw
|
|
255
|
+
// draft here would make stale text appear newer than the canonical draft.
|
|
256
|
+
if (!resolvingIncomingDraftKey || this._draftSaveTimer) this._flushDraftSave()
|
|
257
|
+
this._activeDraftKey = nextDraftKey ? String(nextDraftKey) : null
|
|
258
|
+
this._activeDraftCreativeId = nextCreativeId ? String(nextCreativeId) : null
|
|
259
|
+
if (
|
|
260
|
+
resolvingIncomingDraftKey &&
|
|
261
|
+
previousDraftKey &&
|
|
262
|
+
this._activeDraftKey
|
|
263
|
+
) {
|
|
264
|
+
const sourceDraft = chatDrafts.snapshot(previousDraftKey)
|
|
265
|
+
const moveCompleted = chatDrafts.move(previousDraftKey, this._activeDraftKey)
|
|
266
|
+
const targetDraft = chatDrafts.snapshot(this._activeDraftKey)
|
|
267
|
+
const movedBackups = moveCompleted
|
|
268
|
+
? chatDrafts.moveSubmissionBackups(previousDraftKey, this._activeDraftKey)
|
|
269
|
+
: new Map()
|
|
270
|
+
if (moveCompleted && (targetDraft.revision || !sourceDraft.revision)) {
|
|
271
|
+
this._rebindPendingDraftSubmissions(
|
|
272
|
+
previousDraftKey,
|
|
273
|
+
this._activeDraftKey,
|
|
274
|
+
sourceDraft,
|
|
275
|
+
targetDraft,
|
|
276
|
+
movedBackups,
|
|
277
|
+
)
|
|
278
|
+
} else if (!moveCompleted) {
|
|
279
|
+
this._trackPartialDraftMigration(
|
|
280
|
+
previousDraftKey,
|
|
281
|
+
this._activeDraftKey,
|
|
282
|
+
sourceDraft,
|
|
283
|
+
targetDraft,
|
|
284
|
+
)
|
|
285
|
+
if (chatDrafts.isNewer(previousDraftKey, this._activeDraftKey)) {
|
|
286
|
+
this._activeDraftKey = String(previousDraftKey)
|
|
287
|
+
keepAwaitingEffectiveDraftKey = true
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (resolvingIncomingDraftKey && !keepAwaitingEffectiveDraftKey) {
|
|
293
|
+
this._awaitingEffectiveDraftKeyFor = null
|
|
294
|
+
}
|
|
113
295
|
this.currentTopicId = event.detail.topicId
|
|
114
296
|
this._isInbox = event.detail.isInbox || false
|
|
115
297
|
this._systemTopicId = event.detail.systemTopicId || null
|
|
@@ -125,6 +307,14 @@ export default class extends Controller {
|
|
|
125
307
|
}
|
|
126
308
|
|
|
127
309
|
disconnect() {
|
|
310
|
+
this._flushDraftSave()
|
|
311
|
+
this.textareaTarget.removeEventListener('compositionend', this._handleCompositionEnd)
|
|
312
|
+
this.textareaTarget.removeEventListener('keydown', this._expireImeCommitLatch)
|
|
313
|
+
this.textareaTarget.removeEventListener('paste', this._expireImeCommitLatch)
|
|
314
|
+
this.textareaTarget.removeEventListener('drop', this._expireImeCommitLatch)
|
|
315
|
+
this.textareaTarget.removeEventListener('input', this._handleDraftInput)
|
|
316
|
+
window.removeEventListener('pagehide', this._handlePageHide)
|
|
317
|
+
window.removeEventListener('storage', this._handleDraftStorage)
|
|
128
318
|
this.formTarget.removeEventListener('submit', this.handleSubmit)
|
|
129
319
|
this.submitTarget.removeEventListener('click', this.handleSend)
|
|
130
320
|
this.submitTarget.removeEventListener('pointerup', this.handlePointerSend)
|
|
@@ -142,6 +332,7 @@ export default class extends Controller {
|
|
|
142
332
|
this.textareaTarget.removeEventListener('paste', this.handlePaste)
|
|
143
333
|
this.element.removeEventListener('comments--topics:change', this.handleTopicChange)
|
|
144
334
|
this.element.removeEventListener('comments--list:loaded', this.handleListLoaded)
|
|
335
|
+
this.element.removeEventListener('comments--form:stash-draft', this.handleStashDraft)
|
|
145
336
|
}
|
|
146
337
|
|
|
147
338
|
get listController() {
|
|
@@ -155,31 +346,86 @@ export default class extends Controller {
|
|
|
155
346
|
onPopupOpened({ creativeId, canComment }) {
|
|
156
347
|
this.creativeId = creativeId
|
|
157
348
|
this.element.dataset.creativeId = creativeId || ''
|
|
349
|
+
if (canComment) this._draftSaveSuspendedForPermission = false
|
|
158
350
|
// Stale topic ids from the previous creative are cleared by the popup
|
|
159
351
|
// controller BEFORE topics loadTopics() dispatches comments--topics:change,
|
|
160
352
|
// so by the time we get here, currentTopicId already reflects the new
|
|
161
353
|
// creative's restored topic. Do not re-clear it.
|
|
162
354
|
this.formTarget.style.display = canComment ? '' : 'none'
|
|
355
|
+
// Capture input entered while topics were loading before reset clears it.
|
|
356
|
+
// Without a pending input timer, a blank textarea must not erase a draft
|
|
357
|
+
// that is waiting in storage to be restored below.
|
|
358
|
+
if (this._draftSaveTimer) this._flushDraftSave()
|
|
163
359
|
this.resetForm()
|
|
360
|
+
this._draftSaveSuspendedForPermission = !canComment
|
|
164
361
|
if (canComment && this.shouldAutoFocusOnOpen()) {
|
|
165
362
|
requestAnimationFrame(() => this.textareaTarget.focus())
|
|
166
363
|
}
|
|
364
|
+
this._restoreDraft()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
onChatWillOpen({ creativeId }) {
|
|
368
|
+
const nextCreativeId = creativeId ? String(creativeId) : null
|
|
369
|
+
if (
|
|
370
|
+
String(this._activeDraftCreativeId || '') === String(nextCreativeId || '')
|
|
371
|
+
) return
|
|
372
|
+
|
|
373
|
+
// The popup publishes its raw creative id before awaiting topics. Flush the
|
|
374
|
+
// outgoing chat now, then give any input typed during that await a key owned
|
|
375
|
+
// by the incoming chat. The topics event replaces it with the effective id.
|
|
376
|
+
this._flushDraftSave()
|
|
377
|
+
if (this._draftPersistenceDisabled()) {
|
|
378
|
+
this._activeDraftKey = null
|
|
379
|
+
this._activeDraftCreativeId = null
|
|
380
|
+
this._awaitingEffectiveDraftKeyFor = null
|
|
381
|
+
this.creativeId = creativeId
|
|
382
|
+
this.resetForm()
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
this._activeDraftKey = nextCreativeId
|
|
386
|
+
this._activeDraftCreativeId = nextCreativeId
|
|
387
|
+
this._awaitingEffectiveDraftKeyFor = nextCreativeId
|
|
388
|
+
this.creativeId = creativeId
|
|
389
|
+
this.resetForm()
|
|
390
|
+
this._restoreDraft()
|
|
167
391
|
}
|
|
168
392
|
|
|
169
393
|
onPopupClosed() {
|
|
394
|
+
this._flushDraftSave()
|
|
395
|
+
this._activeDraftKey = null
|
|
396
|
+
this._activeDraftCreativeId = null
|
|
397
|
+
this._awaitingEffectiveDraftKeyFor = null
|
|
170
398
|
this.stopSpeechRecognition()
|
|
171
399
|
this.resetForm()
|
|
172
400
|
}
|
|
173
401
|
|
|
402
|
+
discardDraft() {
|
|
403
|
+
const namespace = chatDrafts.namespace()
|
|
404
|
+
this._pendingDraftSubmissions?.forEach((submission) => {
|
|
405
|
+
if (submission.namespace === namespace) submission.invalidated = true
|
|
406
|
+
})
|
|
407
|
+
clearTimeout(this._draftSaveTimer)
|
|
408
|
+
this._draftSaveTimer = null
|
|
409
|
+
this._activeDraftKey = null
|
|
410
|
+
this._activeDraftCreativeId = null
|
|
411
|
+
this._awaitingEffectiveDraftKeyFor = null
|
|
412
|
+
this._stashedDraft = null
|
|
413
|
+
this.resetForm()
|
|
414
|
+
}
|
|
415
|
+
|
|
174
416
|
setCommentPermission(canComment) {
|
|
175
417
|
this.formTarget.style.display = canComment ? '' : 'none'
|
|
176
418
|
|
|
177
419
|
if (!canComment) {
|
|
420
|
+
this._flushDraftSave()
|
|
421
|
+
this._draftSaveSuspendedForPermission = true
|
|
178
422
|
this.stopSpeechRecognition()
|
|
179
423
|
this.resetForm()
|
|
180
424
|
return
|
|
181
425
|
}
|
|
182
426
|
|
|
427
|
+
this._draftSaveSuspendedForPermission = false
|
|
428
|
+
this._restoreDraft()
|
|
183
429
|
if (this.shouldAutoFocusOnOpen()) {
|
|
184
430
|
requestAnimationFrame(() => this.textareaTarget.focus())
|
|
185
431
|
}
|
|
@@ -200,6 +446,7 @@ export default class extends Controller {
|
|
|
200
446
|
}
|
|
201
447
|
|
|
202
448
|
startEditing({ id, content, private: isPrivate }) {
|
|
449
|
+
this._flushDraftSave()
|
|
203
450
|
this.editingId = id
|
|
204
451
|
this.textareaTarget.value = content || ''
|
|
205
452
|
if (this.privateCheckboxTarget) {
|
|
@@ -213,6 +460,58 @@ export default class extends Controller {
|
|
|
213
460
|
this.focusTextarea()
|
|
214
461
|
}
|
|
215
462
|
|
|
463
|
+
handleStashDraft(event) {
|
|
464
|
+
const draft = event.detail?.draft || null
|
|
465
|
+
if (draft) this._flushDraftSave()
|
|
466
|
+
// Tagged with the conversation it was typed in: the popup reuses this one
|
|
467
|
+
// controller for every creative, so a stash left over from another one must
|
|
468
|
+
// not be handed back here.
|
|
469
|
+
this._stashedDraft = draft ? { draft, creativeId: this.creativeId } : null
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
_stashedDraftBelongsToCurrentCreative() {
|
|
473
|
+
return Boolean(
|
|
474
|
+
this._stashedDraft &&
|
|
475
|
+
String(this._stashedDraft.creativeId) === String(this.creativeId),
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
_shouldSuppressDraftSaveForStash() {
|
|
480
|
+
if (!this._stashedDraftBelongsToCurrentCreative()) return false
|
|
481
|
+
|
|
482
|
+
// Before handleSend captures the command, the command-menu input event
|
|
483
|
+
// must not replace the stashed ordinary draft. Once the send starts, only
|
|
484
|
+
// the submitted command remains suppressed; later input is a new draft.
|
|
485
|
+
return !this._stashedDraft.submittedText ||
|
|
486
|
+
this.textareaTarget.value === this._stashedDraft.submittedText
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
_restoreStashedDraft(submittedText) {
|
|
490
|
+
const stashed = this._stashedDraft
|
|
491
|
+
this._stashedDraft = null
|
|
492
|
+
if (!stashed) return
|
|
493
|
+
// Switching creatives calls onPopupOpened on this same instance (there is
|
|
494
|
+
// no disconnect between conversations), so a send that settles after the
|
|
495
|
+
// switch would drop the previous conversation's draft into the new one.
|
|
496
|
+
// The draft belongs to a conversation that is no longer on screen; discard
|
|
497
|
+
// it rather than misfile it.
|
|
498
|
+
if (String(stashed.creativeId ?? '') !== String(this.creativeId ?? '')) return
|
|
499
|
+
const draft = stashed.draft
|
|
500
|
+
// Two boxes are safe to overwrite: an empty one (the success path runs
|
|
501
|
+
// resetForm) and one still holding exactly what we submitted (the failure
|
|
502
|
+
// path never clears it, so the command text is left sitting there). Any
|
|
503
|
+
// other content is text the user typed while the request was in flight, or
|
|
504
|
+
// a review quote the failure path restored, and must not be clobbered.
|
|
505
|
+
const current = this.textareaTarget.value
|
|
506
|
+
if (current.trim().length > 0 && current !== submittedText) return
|
|
507
|
+
this.textareaTarget.value = draft
|
|
508
|
+
// Resize and re-enable send directly rather than dispatching `input`, which
|
|
509
|
+
// would re-open the command menu for a draft that starts with "/".
|
|
510
|
+
this._autoResize()
|
|
511
|
+
this._updateSubmitButton()
|
|
512
|
+
this._saveDraftNow()
|
|
513
|
+
}
|
|
514
|
+
|
|
216
515
|
resetForm() {
|
|
217
516
|
this.formTarget.reset()
|
|
218
517
|
this.editingId = null
|
|
@@ -224,11 +523,286 @@ export default class extends Controller {
|
|
|
224
523
|
this.presenceController?.clearManualTypingMessage()
|
|
225
524
|
this.clearImageAttachments()
|
|
226
525
|
this.cancelQuote()
|
|
227
|
-
this.textareaTarget.placeholder =
|
|
526
|
+
this.textareaTarget.placeholder = this._defaultPlaceholder()
|
|
228
527
|
// Reset textarea height after clearing content
|
|
229
528
|
this.textareaTarget.style.height = 'auto'
|
|
230
529
|
}
|
|
231
530
|
|
|
531
|
+
_saveDraftNow() {
|
|
532
|
+
if (
|
|
533
|
+
!this._activeDraftKey ||
|
|
534
|
+
this._draftPersistenceDisabled() ||
|
|
535
|
+
this._draftSaveSuspendedForPermission ||
|
|
536
|
+
this.editingId ||
|
|
537
|
+
this._shouldSuppressDraftSaveForStash() ||
|
|
538
|
+
!this._reviewStore.isEmpty
|
|
539
|
+
) return
|
|
540
|
+
if (this._currentTextIsPendingSubmission()) return
|
|
541
|
+
const draftKey = this._activeDraftKey
|
|
542
|
+
const text = this.textareaTarget.value
|
|
543
|
+
const blank = !text.trim()
|
|
544
|
+
const storedDraft = chatDrafts.snapshot(draftKey)
|
|
545
|
+
const storedText = storedDraft.text
|
|
546
|
+
const hasStoredEntry = chatDrafts.updatedAt(draftKey) !== null
|
|
547
|
+
const observationKey = `${chatDrafts.namespace()}:${draftKey}`
|
|
548
|
+
const hasObservedDraft = this._observedDrafts?.has(observationKey)
|
|
549
|
+
const observedText = this._observedDrafts?.get(observationKey)
|
|
550
|
+
const hasObservedDisplayedDraft =
|
|
551
|
+
this._observedDisplayedDrafts?.has(observationKey)
|
|
552
|
+
const observedDisplayedText = hasObservedDisplayedDraft
|
|
553
|
+
? this._observedDisplayedDrafts.get(observationKey)
|
|
554
|
+
: observedText
|
|
555
|
+
const observedRevision = this._observedDraftRevisions?.get(observationKey) || 0
|
|
556
|
+
const observedStoredRevision =
|
|
557
|
+
this._observedStoredDraftRevisions?.get(observationKey) || null
|
|
558
|
+
const currentRevision = this._draftRevisions?.get(observationKey) || 0
|
|
559
|
+
const inputChangedLocally = currentRevision !== observedRevision
|
|
560
|
+
const preserveBlank =
|
|
561
|
+
blank && Boolean(this._awaitingEffectiveDraftKeyFor) && inputChangedLocally
|
|
562
|
+
const displayedDraftChanged =
|
|
563
|
+
(hasObservedDisplayedDraft || hasObservedDraft) &&
|
|
564
|
+
(observedDisplayedText || '') !== text
|
|
565
|
+
const draftChangedLocally =
|
|
566
|
+
inputChangedLocally || displayedDraftChanged
|
|
567
|
+
const storedDraftChangedOutsideController = hasObservedDraft && (
|
|
568
|
+
observedText !== storedText || observedStoredRevision !== storedDraft.revision
|
|
569
|
+
)
|
|
570
|
+
// An idle tab can retain stale restored text after another tab updates the
|
|
571
|
+
// same draft. Closing or switching that idle tab must not write it back.
|
|
572
|
+
if (!draftChangedLocally && storedDraftChangedOutsideController) return
|
|
573
|
+
if (preserveBlank) {
|
|
574
|
+
if (storedText !== null || !hasStoredEntry) {
|
|
575
|
+
chatDrafts.set(draftKey, '', { preserveBlank: true })
|
|
576
|
+
}
|
|
577
|
+
} else if (blank) {
|
|
578
|
+
if (hasStoredEntry) {
|
|
579
|
+
chatDrafts.clear(draftKey)
|
|
580
|
+
}
|
|
581
|
+
} else if (storedText !== text) {
|
|
582
|
+
chatDrafts.set(draftKey, text)
|
|
583
|
+
}
|
|
584
|
+
if (blank && draftChangedLocally) chatDrafts.clearSubmissionBackups(draftKey)
|
|
585
|
+
this._observeDraft(draftKey)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
_flushDraftSave() {
|
|
589
|
+
clearTimeout(this._draftSaveTimer)
|
|
590
|
+
this._draftSaveTimer = null
|
|
591
|
+
this._saveDraftNow()
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
_currentTextIsPendingSubmission() {
|
|
595
|
+
return Boolean(this._currentPendingSubmission())
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
_currentPendingSubmission() {
|
|
599
|
+
const namespace = chatDrafts.namespace()
|
|
600
|
+
const key = String(this._activeDraftKey || '')
|
|
601
|
+
|
|
602
|
+
return [...(this._pendingDraftSubmissions || [])].find((submission) => (
|
|
603
|
+
!submission.invalidated &&
|
|
604
|
+
submission.namespace === namespace &&
|
|
605
|
+
String(submission.key || '') === key &&
|
|
606
|
+
!submission.hadStash &&
|
|
607
|
+
!submission.hadReview &&
|
|
608
|
+
!submission.editing &&
|
|
609
|
+
submission.text === this.textareaTarget.value &&
|
|
610
|
+
(this._draftRevisions?.get(submission.revisionKey) || 0) === submission.keyRevision
|
|
611
|
+
))
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
_restoreDraft() {
|
|
615
|
+
if (
|
|
616
|
+
!this._activeDraftKey ||
|
|
617
|
+
this._draftPersistenceDisabled() ||
|
|
618
|
+
this.editingId
|
|
619
|
+
) return
|
|
620
|
+
if (this.textareaTarget.value.trim()) return
|
|
621
|
+
|
|
622
|
+
const draft = chatDrafts.snapshot(this._activeDraftKey)
|
|
623
|
+
const backup = chatDrafts.latestSubmissionBackup(this._activeDraftKey)
|
|
624
|
+
const restoreBackup = Boolean(
|
|
625
|
+
backup?.text &&
|
|
626
|
+
(draft.updatedAt === null || backup.updatedAt > draft.updatedAt),
|
|
627
|
+
)
|
|
628
|
+
if (backup && !restoreBackup) chatDrafts.removeSubmissionBackup(backup.key)
|
|
629
|
+
const restoredText = restoreBackup ? backup.text : draft.text
|
|
630
|
+
this._observeDraft(
|
|
631
|
+
this._activeDraftKey,
|
|
632
|
+
draft.text,
|
|
633
|
+
chatDrafts.namespace(),
|
|
634
|
+
draft.revision,
|
|
635
|
+
restoredText,
|
|
636
|
+
)
|
|
637
|
+
if (restoredText) {
|
|
638
|
+
this.textareaTarget.value = restoredText
|
|
639
|
+
requestAnimationFrame(() => this._autoResize())
|
|
640
|
+
} else if (draft.updatedAt === null) {
|
|
641
|
+
this._restorePendingSubmittedDraft()
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
_restorePendingSubmittedDraft() {
|
|
646
|
+
const namespace = chatDrafts.namespace()
|
|
647
|
+
const pending = [...(this._pendingDraftSubmissions || [])].find((submission) => (
|
|
648
|
+
!submission.invalidated &&
|
|
649
|
+
submission.namespace === namespace &&
|
|
650
|
+
String(submission.key || '') === String(this._activeDraftKey || '') &&
|
|
651
|
+
!submission.hadStash &&
|
|
652
|
+
!submission.hadReview &&
|
|
653
|
+
!submission.editing
|
|
654
|
+
))
|
|
655
|
+
if (!pending) return
|
|
656
|
+
|
|
657
|
+
this.textareaTarget.value = pending.text
|
|
658
|
+
requestAnimationFrame(() => this._autoResize())
|
|
659
|
+
this._updateSubmitButton()
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
_draftPersistenceDisabled(namespace = chatDrafts.namespace()) {
|
|
663
|
+
return this._disabledDraftNamespaces?.has(namespace) ||
|
|
664
|
+
this._draftBackupCleanupPendingNamespaces?.has(namespace) ||
|
|
665
|
+
false
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
_disableDraftNamespace(namespace) {
|
|
669
|
+
this._disabledDraftNamespaces.add(namespace)
|
|
670
|
+
this.discardDraft()
|
|
671
|
+
// A timer in this tab may have raced with the logout tab's first clear.
|
|
672
|
+
chatDrafts.clearAll({ broadcast: false })
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
_observeDraft(
|
|
676
|
+
draftKey,
|
|
677
|
+
text,
|
|
678
|
+
namespace = chatDrafts.namespace(),
|
|
679
|
+
storedRevision,
|
|
680
|
+
displayedText,
|
|
681
|
+
) {
|
|
682
|
+
if (!draftKey) return
|
|
683
|
+
const storedDraft = storedRevision === undefined
|
|
684
|
+
? chatDrafts.snapshot(draftKey)
|
|
685
|
+
: { text, revision: storedRevision }
|
|
686
|
+
const observationKey = `${namespace}:${draftKey}`
|
|
687
|
+
this._observedDrafts?.set(observationKey, storedDraft.text)
|
|
688
|
+
this._observedDisplayedDrafts?.set(
|
|
689
|
+
observationKey,
|
|
690
|
+
displayedText === undefined ? storedDraft.text : displayedText,
|
|
691
|
+
)
|
|
692
|
+
this._observedDraftRevisions?.set(
|
|
693
|
+
observationKey,
|
|
694
|
+
this._draftRevisions?.get(observationKey) || 0,
|
|
695
|
+
)
|
|
696
|
+
this._observedStoredDraftRevisions?.set(observationKey, storedDraft.revision)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
_rebindPendingDraftSubmissions(
|
|
700
|
+
sourceKey,
|
|
701
|
+
targetKey,
|
|
702
|
+
sourceDraft,
|
|
703
|
+
targetDraft,
|
|
704
|
+
movedBackups = new Map(),
|
|
705
|
+
) {
|
|
706
|
+
const namespace = chatDrafts.namespace()
|
|
707
|
+
const targetRevisionKey = `${namespace}:${targetKey}`
|
|
708
|
+
|
|
709
|
+
this._pendingDraftSubmissions?.forEach((submission) => {
|
|
710
|
+
if (
|
|
711
|
+
submission.namespace !== namespace ||
|
|
712
|
+
String(submission.key) !== String(sourceKey)
|
|
713
|
+
) return
|
|
714
|
+
|
|
715
|
+
const targetMatchesStoredBaseline = submission.storedRevision && targetDraft.revision === submission.storedRevision
|
|
716
|
+
const sourceMatchesSubmission =
|
|
717
|
+
sourceDraft.revision &&
|
|
718
|
+
targetDraft.revision === sourceDraft.revision &&
|
|
719
|
+
targetDraft.text === submission.text
|
|
720
|
+
const submittedDraftWasUnstored =
|
|
721
|
+
!submission.storedRevision &&
|
|
722
|
+
!sourceDraft.revision &&
|
|
723
|
+
!targetDraft.revision
|
|
724
|
+
const submittedDraftWasMoved = targetMatchesStoredBaseline || sourceMatchesSubmission || submittedDraftWasUnstored
|
|
725
|
+
if (
|
|
726
|
+
sourceDraft.revision &&
|
|
727
|
+
sourceDraft.text === submission.text
|
|
728
|
+
) {
|
|
729
|
+
submission.migratedSources.push({
|
|
730
|
+
key: String(sourceKey),
|
|
731
|
+
revision: sourceDraft.revision,
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
submission.key = String(targetKey)
|
|
735
|
+
submission.revisionKey = targetRevisionKey
|
|
736
|
+
submission.keyRevision = this._draftRevisions?.get(targetRevisionKey) || 0
|
|
737
|
+
submission.storedRevision = targetDraft.revision
|
|
738
|
+
submission.storedUpdatedAt = targetDraft.updatedAt
|
|
739
|
+
submission.storedChangedOutsideController ||=
|
|
740
|
+
!submittedDraftWasMoved
|
|
741
|
+
if (submission.backupKey && movedBackups.has(submission.backupKey)) {
|
|
742
|
+
submission.backupKey = movedBackups.get(submission.backupKey)
|
|
743
|
+
}
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
_trackPartialDraftMigration(sourceKey, targetKey, sourceDraft, targetDraft) {
|
|
748
|
+
if (
|
|
749
|
+
!sourceDraft.revision ||
|
|
750
|
+
targetDraft.revision !== sourceDraft.revision
|
|
751
|
+
) return
|
|
752
|
+
|
|
753
|
+
const namespace = chatDrafts.namespace()
|
|
754
|
+
this._pendingDraftSubmissions?.forEach((submission) => {
|
|
755
|
+
if (
|
|
756
|
+
submission.namespace !== namespace ||
|
|
757
|
+
String(submission.key) !== String(sourceKey) ||
|
|
758
|
+
submission.hadStash ||
|
|
759
|
+
submission.hadReview ||
|
|
760
|
+
submission.editing ||
|
|
761
|
+
submission.storedRevision !== sourceDraft.revision
|
|
762
|
+
) return
|
|
763
|
+
|
|
764
|
+
submission.migratedSources.push({
|
|
765
|
+
key: String(targetKey),
|
|
766
|
+
revision: targetDraft.revision,
|
|
767
|
+
})
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
_clearMigratedSubmittedSources(submission) {
|
|
772
|
+
submission.migratedSources.forEach(({ key, revision }) => {
|
|
773
|
+
if (chatDrafts.revision(key) !== revision) return
|
|
774
|
+
|
|
775
|
+
chatDrafts.clear(key)
|
|
776
|
+
this._observeDraft(key, null, submission.namespace)
|
|
777
|
+
})
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
_persistFailedSubmissionDraft(submission) {
|
|
781
|
+
if (
|
|
782
|
+
submission.invalidated ||
|
|
783
|
+
submission.hadStash ||
|
|
784
|
+
submission.hadReview ||
|
|
785
|
+
submission.editing ||
|
|
786
|
+
submission.namespace !== chatDrafts.namespace()
|
|
787
|
+
) return
|
|
788
|
+
|
|
789
|
+
const currentDraft = chatDrafts.snapshot(submission.key)
|
|
790
|
+
const currentKeyRevision =
|
|
791
|
+
this._draftRevisions?.get(submission.revisionKey) || 0
|
|
792
|
+
const submittedChatAdvanced =
|
|
793
|
+
submission.storedChangedOutsideController ||
|
|
794
|
+
currentKeyRevision !== submission.keyRevision ||
|
|
795
|
+
currentDraft.revision !== submission.storedRevision ||
|
|
796
|
+
currentDraft.updatedAt !== submission.storedUpdatedAt
|
|
797
|
+
if (submittedChatAdvanced) return
|
|
798
|
+
|
|
799
|
+
submission.backupKey ||= chatDrafts.saveSubmissionBackup(
|
|
800
|
+
submission.key,
|
|
801
|
+
submission.text,
|
|
802
|
+
{ updatedAt: submission.backupUpdatedAt },
|
|
803
|
+
)
|
|
804
|
+
}
|
|
805
|
+
|
|
232
806
|
setSendingState(isSending) {
|
|
233
807
|
if (!this.submitTarget) return
|
|
234
808
|
|
|
@@ -248,6 +822,7 @@ export default class extends Controller {
|
|
|
248
822
|
|
|
249
823
|
handleSend(event) {
|
|
250
824
|
event.preventDefault()
|
|
825
|
+
const commandSubmissionId = event.commandSubmissionId || null
|
|
251
826
|
|
|
252
827
|
// If active quote exists, handle based on type
|
|
253
828
|
const store = this._reviewStore
|
|
@@ -271,6 +846,19 @@ export default class extends Controller {
|
|
|
271
846
|
this.sending = true
|
|
272
847
|
this.setSendingState(true)
|
|
273
848
|
this.presenceController?.stoppedTyping()
|
|
849
|
+
if (commandSubmissionId) {
|
|
850
|
+
this.element.dispatchEvent(new CustomEvent('comments--form:submit-started', {
|
|
851
|
+
detail: { submissionId: commandSubmissionId },
|
|
852
|
+
}))
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Cancel any pending input debounce before capturing the submission. A
|
|
856
|
+
// write while the request is in flight looks like a newer draft and can
|
|
857
|
+
// restore the already-sent text on success. Failures persist below.
|
|
858
|
+
if (this._draftSaveTimer) {
|
|
859
|
+
clearTimeout(this._draftSaveTimer)
|
|
860
|
+
this._draftSaveTimer = null
|
|
861
|
+
}
|
|
274
862
|
|
|
275
863
|
// Build final content from review quotes + user text
|
|
276
864
|
if (hasQuotes) {
|
|
@@ -281,6 +869,56 @@ export default class extends Controller {
|
|
|
281
869
|
|
|
282
870
|
const wasPrivate = this.privateCheckboxTarget?.checked ?? false
|
|
283
871
|
|
|
872
|
+
// Captured after the review-quote rewrite above, so it is the exact content
|
|
873
|
+
// going to the server. _restoreStashedDraft compares against it to tell a
|
|
874
|
+
// failure that left the command text behind from text typed mid-flight.
|
|
875
|
+
const submittedText = this.textareaTarget.value
|
|
876
|
+
const initialSubmittedDraftKey = this._activeDraftKey
|
|
877
|
+
const submittedDraftNamespace = chatDrafts.namespace()
|
|
878
|
+
const initialSubmittedDraftRevisionKey =
|
|
879
|
+
`${submittedDraftNamespace}:${initialSubmittedDraftKey}`
|
|
880
|
+
const initialSubmittedDraft = chatDrafts.snapshot(initialSubmittedDraftKey)
|
|
881
|
+
const submittedHadStash = this._stashedDraftBelongsToCurrentCreative()
|
|
882
|
+
const submittedBackup = chatDrafts.latestSubmissionBackup(initialSubmittedDraftKey)
|
|
883
|
+
const submittedBackupUpdatedAt = Math.max(
|
|
884
|
+
Date.now(),
|
|
885
|
+
(initialSubmittedDraft.updatedAt || 0) + 1,
|
|
886
|
+
)
|
|
887
|
+
const observedSubmittedText =
|
|
888
|
+
this._observedDrafts?.get(initialSubmittedDraftRevisionKey)
|
|
889
|
+
const observedSubmittedStoredRevision =
|
|
890
|
+
this._observedStoredDraftRevisions?.get(initialSubmittedDraftRevisionKey) || null
|
|
891
|
+
const submittedTextChangedOutsideController =
|
|
892
|
+
observedSubmittedText !== initialSubmittedDraft.text
|
|
893
|
+
const submittedRevisionChangedOutsideController =
|
|
894
|
+
observedSubmittedStoredRevision !== initialSubmittedDraft.revision
|
|
895
|
+
const submittedStoredDraftChangedOutsideController =
|
|
896
|
+
this._observedDrafts?.has(initialSubmittedDraftRevisionKey) &&
|
|
897
|
+
(submittedTextChangedOutsideController || submittedRevisionChangedOutsideController)
|
|
898
|
+
const submittedDraft = {
|
|
899
|
+
key: initialSubmittedDraftKey,
|
|
900
|
+
namespace: submittedDraftNamespace,
|
|
901
|
+
revisionKey: initialSubmittedDraftRevisionKey,
|
|
902
|
+
storedChangedOutsideController: submittedStoredDraftChangedOutsideController,
|
|
903
|
+
keyRevision: this._draftRevisions?.get(initialSubmittedDraftRevisionKey) || 0,
|
|
904
|
+
storedRevision: initialSubmittedDraft.revision,
|
|
905
|
+
storedUpdatedAt: initialSubmittedDraft.updatedAt,
|
|
906
|
+
backupUpdatedAt: submittedBackupUpdatedAt,
|
|
907
|
+
text: submittedText,
|
|
908
|
+
hadStash: submittedHadStash,
|
|
909
|
+
backupKey: submittedBackup?.text === submittedText ? submittedBackup.key : null,
|
|
910
|
+
migratedSources: [],
|
|
911
|
+
}
|
|
912
|
+
this._pendingDraftSubmissions ||= new Set()
|
|
913
|
+
this._pendingDraftSubmissions.add(submittedDraft)
|
|
914
|
+
const submittedEditingId = this.editingId
|
|
915
|
+
const submittedHadReview = hasQuotes
|
|
916
|
+
submittedDraft.editing = Boolean(submittedEditingId)
|
|
917
|
+
submittedDraft.hadReview = submittedHadReview
|
|
918
|
+
if (submittedHadStash) {
|
|
919
|
+
this._stashedDraft.submittedText = submittedText
|
|
920
|
+
}
|
|
921
|
+
|
|
284
922
|
const formData = new FormData(this.formTarget)
|
|
285
923
|
const effectiveTopicId = this.currentTopicId || this._mainTopicId
|
|
286
924
|
if (effectiveTopicId) {
|
|
@@ -293,11 +931,12 @@ export default class extends Controller {
|
|
|
293
931
|
|
|
294
932
|
let url = `/creatives/${this.creativeId}/comments`
|
|
295
933
|
let method = 'POST'
|
|
296
|
-
if (
|
|
297
|
-
url += `/${
|
|
934
|
+
if (submittedEditingId) {
|
|
935
|
+
url += `/${submittedEditingId}`
|
|
298
936
|
method = 'PATCH'
|
|
299
937
|
}
|
|
300
938
|
|
|
939
|
+
let settlementUi = Promise.resolve()
|
|
301
940
|
const doFetch = () => fetch(url, {
|
|
302
941
|
method,
|
|
303
942
|
headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content },
|
|
@@ -323,12 +962,97 @@ export default class extends Controller {
|
|
|
323
962
|
})
|
|
324
963
|
})
|
|
325
964
|
.then((html) => {
|
|
326
|
-
|
|
965
|
+
if (submittedDraft.backupKey) {
|
|
966
|
+
chatDrafts.clearSubmissionBackupsThrough(
|
|
967
|
+
submittedDraft.backupKey,
|
|
968
|
+
submittedDraft.namespace,
|
|
969
|
+
)
|
|
970
|
+
submittedDraft.backupKey = null
|
|
971
|
+
}
|
|
972
|
+
if (
|
|
973
|
+
submittedDraft.invalidated ||
|
|
974
|
+
submittedDraft.namespace !== chatDrafts.namespace()
|
|
975
|
+
) return
|
|
976
|
+
|
|
977
|
+
const {
|
|
978
|
+
key: submittedDraftKey,
|
|
979
|
+
namespace: submittedDraftNamespace,
|
|
980
|
+
revisionKey: submittedDraftRevisionKey,
|
|
981
|
+
storedChangedOutsideController: submittedStoredDraftChangedOutsideController,
|
|
982
|
+
keyRevision: submittedDraftKeyRevision,
|
|
983
|
+
storedRevision: submittedDraftStoredRevision,
|
|
984
|
+
hadStash: submittedHadStash,
|
|
985
|
+
} = submittedDraft
|
|
986
|
+
const ownsSubmittedDraftNamespace =
|
|
987
|
+
!submittedDraft.invalidated &&
|
|
988
|
+
submittedDraftNamespace === chatDrafts.namespace()
|
|
989
|
+
const switchedChats =
|
|
990
|
+
ownsSubmittedDraftNamespace &&
|
|
991
|
+
submittedDraftKey &&
|
|
992
|
+
this._activeDraftKey &&
|
|
993
|
+
String(submittedDraftKey) !== String(this._activeDraftKey)
|
|
994
|
+
const submittedChatStillActive =
|
|
995
|
+
submittedDraftKey &&
|
|
996
|
+
this._activeDraftKey &&
|
|
997
|
+
String(submittedDraftKey) === String(this._activeDraftKey)
|
|
998
|
+
const hasNewerActiveDraft =
|
|
999
|
+
ownsSubmittedDraftNamespace &&
|
|
1000
|
+
!submittedEditingId &&
|
|
1001
|
+
!submittedHadReview &&
|
|
1002
|
+
submittedChatStillActive &&
|
|
1003
|
+
(this._draftRevisions?.get(submittedDraftRevisionKey) || 0) !==
|
|
1004
|
+
submittedDraftKeyRevision
|
|
1005
|
+
const hasNewerStoredDraft =
|
|
1006
|
+
ownsSubmittedDraftNamespace &&
|
|
1007
|
+
!submittedEditingId &&
|
|
1008
|
+
!submittedHadReview &&
|
|
1009
|
+
submittedDraftKey &&
|
|
1010
|
+
(
|
|
1011
|
+
(this._draftRevisions?.get(submittedDraftRevisionKey) || 0) !==
|
|
1012
|
+
submittedDraftKeyRevision ||
|
|
1013
|
+
submittedStoredDraftChangedOutsideController ||
|
|
1014
|
+
chatDrafts.revision(submittedDraftKey) !== submittedDraftStoredRevision
|
|
1015
|
+
)
|
|
1016
|
+
const hasNewerDraft = hasNewerActiveDraft || hasNewerStoredDraft
|
|
1017
|
+
const newerDraft = hasNewerActiveDraft
|
|
1018
|
+
? (
|
|
1019
|
+
this._draftSaveSuspendedForPermission
|
|
1020
|
+
? chatDrafts.get(submittedDraftKey)
|
|
1021
|
+
: this.textareaTarget.value
|
|
1022
|
+
)
|
|
1023
|
+
: null
|
|
1024
|
+
if (switchedChats) this._flushDraftSave()
|
|
1025
|
+
clearTimeout(this._draftSaveTimer)
|
|
1026
|
+
this._draftSaveTimer = null
|
|
327
1027
|
this.resetForm()
|
|
328
|
-
if (
|
|
1028
|
+
if (submittedEditingId) {
|
|
1029
|
+
if (ownsSubmittedDraftNamespace) this._restoreDraft()
|
|
329
1030
|
// If editing, just replace the item in place
|
|
330
1031
|
this.renderCommentHtml(html, { replaceExisting: true })
|
|
331
1032
|
} else {
|
|
1033
|
+
if (submittedHadReview && ownsSubmittedDraftNamespace) {
|
|
1034
|
+
this._restoreDraft()
|
|
1035
|
+
} else if (hasNewerActiveDraft) {
|
|
1036
|
+
this.textareaTarget.value = newerDraft
|
|
1037
|
+
this._autoResize()
|
|
1038
|
+
this._updateSubmitButton()
|
|
1039
|
+
chatDrafts.set(submittedDraftKey, newerDraft)
|
|
1040
|
+
this._observeDraft(submittedDraftKey, newerDraft, submittedDraftNamespace)
|
|
1041
|
+
} else if (hasNewerStoredDraft && submittedChatStillActive) {
|
|
1042
|
+
this._restoreDraft()
|
|
1043
|
+
} else if (
|
|
1044
|
+
!submittedHadStash &&
|
|
1045
|
+
!hasNewerDraft &&
|
|
1046
|
+
submittedDraftKey &&
|
|
1047
|
+
ownsSubmittedDraftNamespace
|
|
1048
|
+
) {
|
|
1049
|
+
chatDrafts.clear(submittedDraftKey)
|
|
1050
|
+
this._observeDraft(submittedDraftKey, null, submittedDraftNamespace)
|
|
1051
|
+
}
|
|
1052
|
+
if (ownsSubmittedDraftNamespace && !submittedHadReview) {
|
|
1053
|
+
this._clearMigratedSubmittedSources(submittedDraft)
|
|
1054
|
+
}
|
|
1055
|
+
if (switchedChats && !submittedHadReview) this._restoreDraft()
|
|
332
1056
|
// New Comment:
|
|
333
1057
|
// 1. If we are in "History Mode" (scrolled up), sending a message should jump us to the latest.
|
|
334
1058
|
// 2. Ideally, we just reload the "Latest" page to ensure sync and Live Mode.
|
|
@@ -363,6 +1087,8 @@ export default class extends Controller {
|
|
|
363
1087
|
}
|
|
364
1088
|
})
|
|
365
1089
|
.catch((error) => {
|
|
1090
|
+
if (submittedDraft.invalidated) return
|
|
1091
|
+
|
|
366
1092
|
// Restore review quotes state on failure so user doesn't lose work
|
|
367
1093
|
if (this._reviewStore.hasBackup()) {
|
|
368
1094
|
const restoredText = this._reviewStore.restore()
|
|
@@ -370,12 +1096,28 @@ export default class extends Controller {
|
|
|
370
1096
|
this._renderReviewQuoteChips()
|
|
371
1097
|
this._updateSubmitButton()
|
|
372
1098
|
}
|
|
373
|
-
|
|
1099
|
+
this._persistFailedSubmissionDraft(submittedDraft)
|
|
1100
|
+
settlementUi = alertDialog(error?.message || 'Failed to submit comment')
|
|
374
1101
|
})
|
|
375
1102
|
.finally(() => {
|
|
1103
|
+
this._pendingDraftSubmissions.delete(submittedDraft)
|
|
376
1104
|
inFlightSends.delete(sendKey)
|
|
377
1105
|
this._hasRetried = false
|
|
378
1106
|
this.setSendingState(false)
|
|
1107
|
+
if (
|
|
1108
|
+
!submittedDraft.invalidated &&
|
|
1109
|
+
submittedDraft.namespace === chatDrafts.namespace()
|
|
1110
|
+
) {
|
|
1111
|
+
this._restoreStashedDraft(submittedText)
|
|
1112
|
+
} else {
|
|
1113
|
+
this._stashedDraft = null
|
|
1114
|
+
}
|
|
1115
|
+
const announceSettlement = () => {
|
|
1116
|
+
this.element.dispatchEvent(new CustomEvent('comments--form:submit-settled', {
|
|
1117
|
+
detail: { submissionId: commandSubmissionId },
|
|
1118
|
+
}))
|
|
1119
|
+
}
|
|
1120
|
+
settlementUi.then(announceSettlement, announceSettlement)
|
|
379
1121
|
})
|
|
380
1122
|
}
|
|
381
1123
|
|
|
@@ -393,6 +1135,7 @@ export default class extends Controller {
|
|
|
393
1135
|
handleCancel(event) {
|
|
394
1136
|
event.preventDefault()
|
|
395
1137
|
this.resetForm()
|
|
1138
|
+
this._restoreDraft()
|
|
396
1139
|
}
|
|
397
1140
|
|
|
398
1141
|
handleSearch(event) {
|
|
@@ -729,6 +1472,7 @@ export default class extends Controller {
|
|
|
729
1472
|
if (!selectedText) return
|
|
730
1473
|
|
|
731
1474
|
const store = this._reviewStore
|
|
1475
|
+
if (store.isEmpty) this._flushDraftSave()
|
|
732
1476
|
store.saveActiveFeedback(this.textareaTarget.value)
|
|
733
1477
|
|
|
734
1478
|
if (commentId) {
|
|
@@ -767,7 +1511,7 @@ export default class extends Controller {
|
|
|
767
1511
|
this.textareaTarget.value = ''
|
|
768
1512
|
|
|
769
1513
|
if (store.isEmpty) {
|
|
770
|
-
this.
|
|
1514
|
+
this._restoreOrdinaryDraft()
|
|
771
1515
|
} else if (!store.hasActive) {
|
|
772
1516
|
this.textareaTarget.placeholder = this._getI18nText('reviewSummaryPlaceholder', 'Overall comment (optional)...')
|
|
773
1517
|
}
|
|
@@ -884,6 +1628,9 @@ export default class extends Controller {
|
|
|
884
1628
|
if (quote.id === store.activeId) {
|
|
885
1629
|
const commentEl = document.querySelector(`[data-comment-id="${quote.commentId}"]`)
|
|
886
1630
|
if (commentEl) {
|
|
1631
|
+
// Programmatic list scroll — drop the prev-message anchor so the next
|
|
1632
|
+
// previous-message click resolves from the quoted comment now in view.
|
|
1633
|
+
this.listController?.notifyProgrammaticScroll()
|
|
887
1634
|
commentEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
888
1635
|
commentEl.classList.add('comment-highlight')
|
|
889
1636
|
setTimeout(() => commentEl.classList.remove('comment-highlight'), 2000)
|
|
@@ -921,7 +1668,7 @@ export default class extends Controller {
|
|
|
921
1668
|
store.remove(quote.id)
|
|
922
1669
|
if (wasActive) this.textareaTarget.value = ''
|
|
923
1670
|
if (store.isEmpty) {
|
|
924
|
-
this.
|
|
1671
|
+
this._restoreOrdinaryDraft()
|
|
925
1672
|
} else if (!store.hasActive) {
|
|
926
1673
|
this.textareaTarget.placeholder = this._getI18nText('reviewSummaryPlaceholder', 'Overall comment (optional)...')
|
|
927
1674
|
}
|
|
@@ -956,10 +1703,20 @@ export default class extends Controller {
|
|
|
956
1703
|
this.submitTarget.textContent = labels[state]
|
|
957
1704
|
}
|
|
958
1705
|
|
|
1706
|
+
_restoreOrdinaryDraft() {
|
|
1707
|
+
this.textareaTarget.value = ''
|
|
1708
|
+
this.textareaTarget.placeholder = this._defaultPlaceholder()
|
|
1709
|
+
this._restoreDraft()
|
|
1710
|
+
}
|
|
1711
|
+
|
|
959
1712
|
_getI18nText(key, fallback) {
|
|
960
1713
|
return this.element.dataset[key] || fallback
|
|
961
1714
|
}
|
|
962
1715
|
|
|
1716
|
+
_defaultPlaceholder() {
|
|
1717
|
+
return this._getI18nText('chatInputHint', 'Type a message, or use / for commands')
|
|
1718
|
+
}
|
|
1719
|
+
|
|
963
1720
|
// --- Inbox inline reply mode ---
|
|
964
1721
|
|
|
965
1722
|
get _isInboxSystemTopic() {
|
|
@@ -1035,7 +1792,7 @@ export default class extends Controller {
|
|
|
1035
1792
|
this._reviewStore.clear()
|
|
1036
1793
|
this._renderReviewQuoteChips()
|
|
1037
1794
|
this._updateSubmitButton()
|
|
1038
|
-
this.textareaTarget.placeholder =
|
|
1795
|
+
this.textareaTarget.placeholder = this._defaultPlaceholder()
|
|
1039
1796
|
// Re-apply inbox reply mode indicator if we're in inbox System topic
|
|
1040
1797
|
if (this._isInboxSystemTopic) {
|
|
1041
1798
|
requestAnimationFrame(() => this._updateInboxReplyMode())
|
|
@@ -1051,6 +1808,20 @@ export default class extends Controller {
|
|
|
1051
1808
|
const commentElement = doc.querySelector('.comment-item')
|
|
1052
1809
|
if (!commentElement) return
|
|
1053
1810
|
|
|
1811
|
+
// A search-filtered list is the result set of a server-side query, and a
|
|
1812
|
+
// freshly posted comment carries no verdict on whether it matches. Splicing
|
|
1813
|
+
// it in drops an unrelated message among the results and — when there were
|
|
1814
|
+
// none — takes the "no results" notice with it, since removePlaceholder
|
|
1815
|
+
// below clears whatever empty state is on screen. Leave search and reload
|
|
1816
|
+
// the live list instead, the same exit the history-mode branch takes.
|
|
1817
|
+
// Edits are exempt: that comment is already in the result set, matching the
|
|
1818
|
+
// stream path, which blocks `append` but never `replace`/`remove`.
|
|
1819
|
+
const listCtrl = this.listController
|
|
1820
|
+
if (!replaceExisting && listCtrl?.manualSearchQuery) {
|
|
1821
|
+
listCtrl.resetToLatest()
|
|
1822
|
+
return
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1054
1825
|
this.removePlaceholder()
|
|
1055
1826
|
|
|
1056
1827
|
const existing = listElement.querySelector(`#${commentElement.id}`)
|
|
@@ -1060,6 +1831,14 @@ export default class extends Controller {
|
|
|
1060
1831
|
listElement.appendChild(commentElement)
|
|
1061
1832
|
}
|
|
1062
1833
|
|
|
1834
|
+
// The submit response can win the race against its Turbo Stream echo. Keep
|
|
1835
|
+
// the All Messages read snapshot in step with this visible local append;
|
|
1836
|
+
// the stream then deduplicates without getting a second chance to do it.
|
|
1837
|
+
const addedTopic = listCtrl?.recordRenderedAllTopicWatermarks(
|
|
1838
|
+
commentElement,
|
|
1839
|
+
{ includeNewTopics: !replaceExisting },
|
|
1840
|
+
)
|
|
1841
|
+
if (addedTopic) listCtrl?.reportRenderedAllTopics()
|
|
1063
1842
|
renderMarkdownInContainer(commentElement)
|
|
1064
1843
|
if (replaceExisting) {
|
|
1065
1844
|
this.listController?.markCommentsRead()
|
|
@@ -1068,7 +1847,9 @@ export default class extends Controller {
|
|
|
1068
1847
|
|
|
1069
1848
|
removePlaceholder() {
|
|
1070
1849
|
const listElement = document.getElementById('comments-list')
|
|
1071
|
-
|
|
1072
|
-
|
|
1850
|
+
// Every empty-list state carries .comments-placeholder: the discovery
|
|
1851
|
+
// cards (#no-comments) and the no-search-results notice. Either can be on
|
|
1852
|
+
// screen when the user posts.
|
|
1853
|
+
listElement?.querySelectorAll('.comments-placeholder').forEach((el) => el.remove())
|
|
1073
1854
|
}
|
|
1074
1855
|
}
|