use std::any::Any; use std::sync::Arc; use async_broadcast::InactiveReceiver; use galaxyui::{ AppContext, ModelContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle, WeakViewHandle, WindowId, }; use parking_lot::FairMutex; use pathfinder_geometry::vector::Vector2F; use session_sharing_protocol::common::{ ActivePrompt, AddGuestsResponse, CLIAgentSessionState, CommandExecutionFailureReason, LinkAccessLevelUpdateResponse, LongRunningCommandAgentInteraction, RemoveGuestResponse, SelectedAgentModel, SessionId, TeamAccessLevelUpdateResponse, UniversalDeveloperInputContextUpdate, UpdatePendingUserRoleResponse, }; use session_sharing_protocol::sharer::SessionSourceType; use session_sharing_protocol::viewer::SessionEndedReason; use settings::Setting as _; use super::event_loop::SharedSessionInitialLoadMode; use super::network::{ agent_prompt_failure_reason_string, command_execution_failure_reason_string, control_action_failure_reason_string, session_ended_reason_string, viewer_removed_reason_string, write_to_pty_failure_reason_string, Network, NetworkEvent, }; use super::orchestration_viewer_model::OrchestrationViewerModel; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::conversation::ConversationStatus; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent}; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; use crate::ai::blocklist::{ BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, }; use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; use crate::context_chips::prompt_snapshot::PromptSnapshot; use crate::context_chips::prompt_type::PromptType; use crate::features::FeatureFlag; use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind}; use crate::pane_group::pane::DetachType; use crate::pane_group::TerminalViewResources; use crate::settings::{DebugSettings, InputModeSettings, WarpPromptSeparator}; use crate::terminal::cli_agent_sessions::{ CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, }; use crate::terminal::event_listener::ChannelEventListener; use crate::terminal::input::CommandExecutionSource; use crate::terminal::model::session::Sessions; use crate::terminal::model::ObfuscateSecrets; use crate::terminal::model_events::ModelEventDispatcher; use crate::terminal::session_settings::SessionSettings; use crate::terminal::shared_session::manager::Manager; use crate::terminal::shared_session::permissions_manager::SessionPermissionsManager; use crate::terminal::shared_session::shared_handlers::{ apply_auto_approve_agent_actions_update, apply_cli_agent_state_update, apply_input_mode_update, apply_selected_agent_model_update, apply_selected_conversation_update, build_selected_conversation_update, ActiveRemoteUpdate, RemoteUpdateGuard, }; use crate::terminal::shared_session::SharedSessionStatus; use crate::terminal::terminal_manager::{compute_block_size, terminal_colors_list}; use crate::terminal::view::ambient_agent::is_cloud_agent_pre_first_exchange; use crate::terminal::view::ExecuteCommandEvent; use crate::terminal::{ Event as TerminalViewEvent, TerminalModel, TerminalView, PTY_READS_BROADCAST_CHANNEL_SIZE, }; use crate::view_components::ToastFlavor; enum NetworkState { /// No viewer network is attached yet; deferred cloud-mode viewers start here until the /// follow-up shared session is created. Idle, Active(ModelHandle), /// Transient state while connecting a viewer network. Connecting, } struct NetworkResources { prompt_type: ModelHandle, channel_event_proxy: ChannelEventListener, } pub struct TerminalManager { model: Arc>, view: ViewHandle, // We store this here just to keep it from being dropped. _model_events: ModelHandle, /// An inactive receiver for PTY reads received from the sharer over the network. /// We hold onto this so that the broadcast channel isn't closed prematurely. _inactive_pty_reads_rx: InactiveReceiver>>, /// The network state for the shared session viewer. network_state: NetworkState, network_resources: NetworkResources, current_network: Arc>>>, viewer_remote_update_guard: RemoteUpdateGuard, outbound_handlers_registered: bool, /// Owns child discovery + status polling for an orchestrated ambient /// agent run. Lazily created in `JoinedSuccessfully` on the first /// ambient session join. `Arc>>` matches /// `current_network` so the network-event closure can write into it /// without `&mut self`. orchestration_viewer_model: Arc>>>, /// `true` for the root viewer pane of an orchestrator, `false` for /// per-child viewer panes. Skipping polling on children avoids /// duplicated REST traffic and grandchild double-registration via the /// transitive `ancestor_run_id` filter. enable_orchestration_polling: bool, } pub struct TerminalManagerInit { pub(crate) manager: TerminalManager, pub(crate) view: ViewHandle, } impl TerminalManager { fn send_selected_conversation_update_for_viewer_to_current_network( guard: &RemoteUpdateGuard, model: &Arc>, current_network: &Arc>>>, agent_view_controller: &ModelHandle, ai_context_model: &ModelHandle, ctx: &mut AppContext, ) { let Some(update) = build_selected_conversation_update(agent_view_controller, ai_context_model, ctx) else { return; }; Self::send_input_context_update_to_current_network( guard, model, current_network, update, ctx, ); } fn current_network( current_network: &Arc>>>, ) -> Option> { current_network.lock().clone() } fn update_current_network( current_network: &Arc>>>, ctx: &mut AppContext, update: impl FnOnce(&mut Network, &mut ModelContext), ) { let Some(network) = Self::current_network(current_network) else { return; }; network.update(ctx, update); } fn send_input_context_update_to_current_network( guard: &RemoteUpdateGuard, model: &Arc>, current_network: &Arc>>>, update: UniversalDeveloperInputContextUpdate, ctx: &mut AppContext, ) { if !guard.should_broadcast() { return; } if !model.lock().shared_session_status().is_executor() { return; } Self::update_current_network(current_network, ctx, |network, _| { network.send_universal_developer_input_context_update(update); }); } /// Handles a failed viewer command request and clears any queued-command dispatch state. fn handle_command_execution_request_failed( terminal_view: &mut TerminalView, reason: &CommandExecutionFailureReason, ctx: &mut ViewContext, ) { let reason_string = command_execution_failure_reason_string(reason); terminal_view.show_persistent_toast(reason_string, ToastFlavor::Error, ctx); terminal_view.clear_queued_command_in_flight(ctx); // On command execution request, the input is frozen and set to a loading state. // We only need to restore the input for errors that aren't the result of a new buffer. if matches!( reason, CommandExecutionFailureReason::InsufficientPermissions ) { terminal_view.input().update(ctx, |input, ctx| { input.on_execute_command_for_shared_session_participant_failure(ctx); }) } } /// Internal constructor that creates all the models for viewing a shared session. This does not rely on the shared session existing yet. fn new_internal( resources: TerminalViewResources, initial_size: Vector2F, window_id: WindowId, enable_orchestration_polling: bool, is_cloud_mode: bool, ctx: &mut AppContext, ) -> TerminalManagerInit { // Create all the necessary channels we need for communication. let (wakeups_tx, wakeups_rx) = async_channel::unbounded(); let (events_tx, events_rx) = async_channel::unbounded(); let (executor_command_tx, _executor_command_rx) = async_channel::unbounded(); // Although the viewer doesn't have a local PTY, it receives PTY bytes from the sharer // over the network. Those bytes are still broadcast through the ChannelEventListener, // so we keep an inactive listener alive for PTY recordings and other consumers. let (pty_reads_tx, pty_reads_rx) = async_broadcast::broadcast(PTY_READS_BROADCAST_CHANNEL_SIZE); let inactive_pty_reads_rx = pty_reads_rx.deactivate(); let channel_event_proxy = ChannelEventListener::new(wakeups_tx, events_tx, pty_reads_tx); let show_memory_stats = DebugSettings::as_ref(ctx).should_show_memory_stats(); // TODO: we have to figure out what prompt the viewer will see. // For now, just respect the viewer's settings. let honor_ps1 = *SessionSettings::as_ref(ctx).honor_ps1; let input_mode = *InputModeSettings::as_ref(ctx).input_mode.value(); let is_inverted = input_mode.is_inverted_blocklist(); // TODO: use the sharer's size. let sizes = compute_block_size(initial_size, ctx); let model = if is_cloud_mode { TerminalModel::new_for_cloud_mode_shared_session_viewer( sizes, terminal_colors_list(ctx), channel_event_proxy.clone(), ctx.background_executor().clone(), show_memory_stats, honor_ps1, is_inverted, // When viewing a shared session, we don't want to apply our own // secret redaction rules but rather rely on the sharer obfuscating // the contents before reaching us. ObfuscateSecrets::No, ) } else { TerminalModel::new_for_shared_session_viewer( sizes, terminal_colors_list(ctx), channel_event_proxy.clone(), ctx.background_executor().clone(), show_memory_stats, honor_ps1, is_inverted, // When viewing a shared session, we don't want to apply our own // secret redaction rules but rather rely on the sharer obfuscating // the contents before reaching us. ObfuscateSecrets::No, ) }; let colors = model.colors(); let model = Arc::new(FairMutex::new(model)); let sessions: ModelHandle = ctx.add_model(|ctx| Sessions::new(executor_command_tx, ctx)); let cloned_model = model.clone(); let model_events = ctx.add_model(|ctx| ModelEventDispatcher::new(events_rx, sessions.clone(), ctx)); // The prompt is initially empty until we receive the update from the server. let prompt_type = ctx.add_model(|_| PromptType::new_static(vec![], false, WarpPromptSeparator::None)); let view = ctx.add_typed_action_view(window_id, |ctx| { let size_info = cloned_model.lock().block_list().size().to_owned(); TerminalView::new( resources, wakeups_rx, model_events.clone(), cloned_model, sessions.clone(), size_info, colors, None, // model_event_sender - not used for viewer prompt_type.clone(), None, // initial_input_config - not used for viewer None, // no conversation restoration for shared session viewer Some(inactive_pty_reads_rx.clone()), is_cloud_mode, ctx, ) }); let terminal_view_id = view.id(); let agent_view_controller = view.as_ref(ctx).agent_view_controller().clone(); let active_session = view.as_ref(ctx).active_session().clone(); ActiveAgentViewsModel::handle(ctx).update(ctx, |model, ctx| { model.register_agent_view_controller( &agent_view_controller, &active_session, terminal_view_id, ctx, ); }); let terminal_view = view.clone(); let manager = Self { model, _model_events: model_events, view, _inactive_pty_reads_rx: inactive_pty_reads_rx, network_state: NetworkState::Idle, network_resources: NetworkResources { prompt_type, channel_event_proxy, }, current_network: Arc::new(FairMutex::new(None)), viewer_remote_update_guard: RemoteUpdateGuard::new(), outbound_handlers_registered: false, orchestration_viewer_model: Arc::new(FairMutex::new(None)), enable_orchestration_polling, }; TerminalManagerInit { manager, view: terminal_view, } } /// Create a new terminal manager for viewing a shared session. See /// [`Self::enable_orchestration_polling`] for the meaning of the flag. /// /// `is_cloud_mode` controls whether the resulting `TerminalView` is /// constructed with an `ambient_agent_view_model`. This must be `true` for /// shared-session viewers that represent the local pane of a cloud /// orchestration parent agent, so the snapshot/restore path can emit a /// `LeafContents::AmbientAgent` rather than falling through to an empty /// terminal pane. #[allow(clippy::new_ret_no_self)] pub fn new( session_id: SessionId, resources: TerminalViewResources, initial_size: Vector2F, window_id: WindowId, enable_orchestration_polling: bool, is_cloud_mode: bool, ctx: &mut AppContext, ) -> TerminalManagerInit { let TerminalManagerInit { manager: mut terminal_manager, view: terminal_view, } = Self::new_internal( resources, initial_size, window_id, enable_orchestration_polling, is_cloud_mode, ctx, ); terminal_manager.connect_session( session_id, SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, ctx, ); TerminalManagerInit { manager: terminal_manager, view: terminal_view, } } /// Create a new terminal manager for eventually viewing a cloud mode /// shared session that is not yet available. See /// [`Self::enable_orchestration_polling`] for the meaning of the flag. pub fn new_deferred( resources: TerminalViewResources, initial_size: Vector2F, window_id: WindowId, enable_orchestration_polling: bool, ctx: &mut AppContext, ) -> TerminalManagerInit { Self::new_internal( resources, initial_size, window_id, enable_orchestration_polling, true, // is_cloud_mode ctx, ) } /// Connects a deferred terminal manager to a shared session. /// This can only be called on a TerminalManager created with `new_deferred`. /// Returns `true` if the connection was initiated, `false` if already connected. /// /// `append_followup_scrollback` controls whether the initial join uses /// `AppendFollowupScrollback` mode instead of `ReplaceFromSessionScrollback`. /// Local-to-cloud handoff panes set this to `true` so the pre-populated /// forked conversation is not replaced by the cloud session's replay /// scrollback. pub fn connect_to_session( &mut self, session_id: SessionId, append_followup_scrollback: bool, ctx: &mut AppContext, ) -> bool { let load_mode = if append_followup_scrollback { SharedSessionInitialLoadMode::AppendFollowupScrollback } else { SharedSessionInitialLoadMode::ReplaceFromSessionScrollback }; match self.network_state { NetworkState::Idle => { self.connect_session(session_id, load_mode, ctx); true } NetworkState::Connecting => { log::warn!("connect_to_session called while already connecting to shared session"); false } NetworkState::Active(_) => false, } } pub fn attach_execution_session( &mut self, session_id: SessionId, ctx: &mut AppContext, ) -> bool { match std::mem::replace(&mut self.network_state, NetworkState::Connecting) { NetworkState::Active(network) => { network.update(ctx, |network, _| { network.close_without_reconnection(); }); self.model .lock() .clear_write_to_pty_events_for_shared_session_tx(); *self.current_network.lock() = None; self.network_state = NetworkState::Idle; } NetworkState::Idle => { self.network_state = NetworkState::Idle; } NetworkState::Connecting => { self.network_state = NetworkState::Connecting; log::warn!( "attach_execution_session called while already connecting to shared session" ); return false; } } self.connect_session( session_id, SharedSessionInitialLoadMode::AppendFollowupScrollback, ctx, ); self.start_cloud_mode_setup_command_tracking(); true } pub fn start_cloud_mode_setup_command_tracking(&mut self) { if FeatureFlag::CloudModeSetupV2.is_enabled() { self.model .lock() .block_list_mut() .set_is_executing_oz_environment_startup_commands(true); } } /// Connects this terminal manager to a shared session. /// This method sets up the network model and all associated event handlers. fn connect_session( &mut self, session_id: SessionId, initial_load_mode: SharedSessionInitialLoadMode, ctx: &mut AppContext, ) { match std::mem::replace(&mut self.network_state, NetworkState::Connecting) { NetworkState::Idle => {} other => { self.network_state = other; log::warn!("connect_session called on already-connected TerminalManager"); return; } } // Set up the channel for forwarding write-to-pty events over the network to the sharer. // Whenever the user writes to a long-running command (e.g. ctrl-c or typing), those bytes // are sent from the terminal view through this channel to the network. let (write_to_pty_events_tx, write_to_pty_events_rx) = async_channel::unbounded(); self.model .lock() .set_write_to_pty_events_for_shared_session_tx(write_to_pty_events_tx); self.model .lock() .set_shared_session_status(SharedSessionStatus::ViewPending); let network = ctx.add_model(|ctx| { Network::new( session_id, self.network_resources.channel_event_proxy.clone(), self.view.downgrade(), self.model.clone(), write_to_pty_events_rx, initial_load_mode, self.viewer_remote_update_guard.clone(), ctx, ) }); *self.current_network.lock() = Some(network.clone()); Self::handle_network_events( &network, &self.view, self.model.clone(), self.current_network.clone(), self.network_resources.prompt_type.clone(), self.viewer_remote_update_guard.clone(), self.orchestration_viewer_model.clone(), self.enable_orchestration_polling, ctx, ); if !self.outbound_handlers_registered { Self::handle_view_events( self.current_network.clone(), &self.view, self.model.clone(), self.viewer_remote_update_guard.clone(), ctx, ); Self::handle_network_status_events(&self.view, self.current_network.clone(), ctx); // Send model selection updates during session sharing (if viewer has Editor role) let current_network_for_models = self.current_network.clone(); let terminal_view_id = self.view.id(); let model_clone = self.model.clone(); let model_remote_update_guard = self.viewer_remote_update_guard.clone(); ctx.subscribe_to_model(&LLMPreferences::handle(ctx), move |_prefs, event, ctx| { // Only react to agent mode LLM changes if !matches!(event, LLMPreferencesEvent::UpdatedActiveAgentModeLLM) { return; } let llm_prefs = &LLMPreferences::as_ref(ctx); let selected_model_id: String = llm_prefs .get_active_base_model(ctx, Some(terminal_view_id)) .id .clone() .into(); Self::send_input_context_update_to_current_network( &model_remote_update_guard, &model_clone, ¤t_network_for_models, UniversalDeveloperInputContextUpdate { selected_model: Some(SelectedAgentModel::new(selected_model_id)), ..Default::default() }, ctx, ); }); // Send input mode updates during session sharing (if viewer has Editor role). // When AgentView is enabled, we only send updates when in an active agent view. // For ambient agent sessions, input mode is controlled locally, so we skip sending updates. let current_network_for_input_mode = self.current_network.clone(); let model_clone_for_input = self.model.clone(); let ai_input_model = self.view.as_ref(ctx).ai_input_model().clone(); let weak_view_for_input_mode = self.view.downgrade(); let input_mode_remote_update_guard = self.viewer_remote_update_guard.clone(); ctx.subscribe_to_model(&ai_input_model, move |_, event, ctx| { // In ambient agent sessions, input mode is controlled locally. if model_clone_for_input .lock() .is_shared_ambient_agent_session() { return; } // When AgentView is enabled, only send input mode updates when in an active agent view. if FeatureFlag::AgentView.is_enabled() { let Some(view) = weak_view_for_input_mode.upgrade(ctx) else { return; }; let agent_view_controller = view.as_ref(ctx).agent_view_controller().clone(); if !agent_view_controller.as_ref(ctx).is_active() { return; } } let config = event.updated_config(); Self::send_input_context_update_to_current_network( &input_mode_remote_update_guard, &model_clone_for_input, ¤t_network_for_input_mode, UniversalDeveloperInputContextUpdate { input_mode: Some((*config).into()), ..Default::default() }, ctx, ); }); let agent_view_controller = self.view.as_ref(ctx).agent_view_controller().clone(); let ai_context_model = self.view.as_ref(ctx).ai_context_model().clone(); // Send selected conversation updates during session sharing (if viewer has Editor role) if FeatureFlag::AgentView.is_enabled() { // When agent view is enabled, we listen to the agent view controller // as the authoritative source for which conversation is selected. let current_network_for_conversation = self.current_network.clone(); let model_for_conversation = self.model.clone(); let ai_context_model_for_conversation = ai_context_model.clone(); let conversation_remote_update_guard = self.viewer_remote_update_guard.clone(); ctx.subscribe_to_model( &agent_view_controller, move |agent_view_controller, event, ctx| match event { AgentViewControllerEvent::EnteredAgentView { .. } | AgentViewControllerEvent::ExitedAgentView { .. } => { Self::send_selected_conversation_update_for_viewer_to_current_network( &conversation_remote_update_guard, &model_for_conversation, ¤t_network_for_conversation, &agent_view_controller, &ai_context_model_for_conversation, ctx, ); } AgentViewControllerEvent::ExitConfirmed { .. } => {} }, ); } else { // When agent view is disabled, we fallback to the legacy behavior // of listening for pending query state changes to know which conversation is selected. let current_network_for_conversation = self.current_network.clone(); let model_for_conversation = self.model.clone(); let agent_view_controller_for_conversation = agent_view_controller.clone(); let conversation_remote_update_guard = self.viewer_remote_update_guard.clone(); ctx.subscribe_to_model(&ai_context_model, move |ai_context_model, event, ctx| { if !matches!(event, BlocklistAIContextEvent::PendingQueryStateUpdated) { return; } Self::send_selected_conversation_update_for_viewer_to_current_network( &conversation_remote_update_guard, &model_for_conversation, ¤t_network_for_conversation, &agent_view_controller_for_conversation, &ai_context_model, ctx, ); }); } // Send auto-approve updates during session sharing (if viewer has Editor role) let current_network_for_auto = self.current_network.clone(); let model_clone_for_auto = self.model.clone(); let view_id_for_auto = self.view.id(); let weak_view_for_auto = self.view.downgrade(); let auto_approve_remote_update_guard = self.viewer_remote_update_guard.clone(); ctx.subscribe_to_model( &BlocklistAIHistoryModel::handle(ctx), move |_, event, ctx| { // We intentionally keep this as a full match so new variants // are forced to be handled here #[allow(clippy::single_match)] match event { BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { terminal_surface_id, } => { if *terminal_surface_id != view_id_for_auto { return; } let Some(view) = weak_view_for_auto.upgrade(ctx) else { return; }; let auto_approve = view .as_ref(ctx) .ai_context_model() .as_ref(ctx) .pending_query_autoexecute_override(ctx) .is_autoexecute_any_action(); Self::send_input_context_update_to_current_network( &auto_approve_remote_update_guard, &model_clone_for_auto, ¤t_network_for_auto, UniversalDeveloperInputContextUpdate { auto_approve_agent_actions: Some(auto_approve), ..Default::default() }, ctx, ); } _ => {} } }, ); // Broadcast CLI agent rich input open/close changes from viewer back to sharer. let current_network_for_cli = self.current_network.clone(); let model_for_cli = self.model.clone(); let view_id_for_cli = self.view.id(); let cli_remote_update_guard = self.viewer_remote_update_guard.clone(); ctx.subscribe_to_model(&CLIAgentSessionsModel::handle(ctx), move |_, event, ctx| { let CLIAgentSessionsModelEvent::InputSessionChanged { terminal_view_id, new_input_state, .. } = event else { return; }; if *terminal_view_id != view_id_for_cli || !cli_remote_update_guard.should_broadcast() { return; } let cli_agent_session = { let sessions_model = CLIAgentSessionsModel::as_ref(ctx); match sessions_model.session(view_id_for_cli) { Some(session) => CLIAgentSessionState::Active { cli_agent: session.agent.to_serialized_name(), is_rich_input_open: matches!( new_input_state, CLIAgentInputState::Open { .. } ), }, None => CLIAgentSessionState::Inactive, } }; Self::send_input_context_update_to_current_network( &cli_remote_update_guard, &model_for_cli, ¤t_network_for_cli, UniversalDeveloperInputContextUpdate { cli_agent_session: Some(cli_agent_session), ..Default::default() }, ctx, ); }); self.outbound_handlers_registered = true; } self.network_state = NetworkState::Active(network); } // Aggregating these into a struct would just shift the same set of // fields into another type purely to placate Clippy without any // readability win, since the closure body still needs each clone // individually. Suppress the lint instead. #[allow(clippy::too_many_arguments)] fn handle_network_events( network: &ModelHandle, view: &ViewHandle, model: Arc>, current_network: Arc>>>, prompt_type: ModelHandle, viewer_remote_update_guard: RemoteUpdateGuard, orchestration_viewer_model: Arc>>>, enable_orchestration_polling: bool, ctx: &mut AppContext, ) { // We use a weak view handle instead of a strong reference because we may add a subscription to the view which moves a strong reference of the Model into the callback, // which would create a reference cycle and cause a memory leak. Instead, upgrade the weak view handle lazily. let weak_view_handle = view.downgrade(); ctx.subscribe_to_model(network, move |network, event, ctx| match event { NetworkEvent::JoinedSuccessfully { active_prompt, viewer_id, viewer_firebase_uid, participant_list, input_replica_id, universal_developer_input_context, source, } => { model.lock().set_shared_session_source(source.clone()); Self::handle_active_prompt_update( model.clone(), prompt_type.clone(), weak_view_handle.clone(), active_prompt, ctx, ); // Apply the universal developer input context if present. let active_remote_update = viewer_remote_update_guard.start_remote_update(); if let Some(universal_developer_input_context) = universal_developer_input_context { if let Some(ref model) = universal_developer_input_context.selected_model { Self::handle_selected_agent_model_update(&weak_view_handle, model, &active_remote_update, ctx); } if let Some(ref input_mode) = universal_developer_input_context.input_mode { Self::handle_input_mode_update(&weak_view_handle, input_mode, &active_remote_update, ctx); } apply_cli_agent_state_update( &weak_view_handle, &universal_developer_input_context.cli_agent_session, &active_remote_update, ctx, ); if let Some(ref selected_conversation) = universal_developer_input_context.selected_conversation { Self::handle_selected_conversation_update( &weak_view_handle, selected_conversation, &active_remote_update, ctx, ); } // TODO(roland): we do not apply universal_developer_input_context.long_running_command_agent_interaction here // because the block it should apply to won't exist yet, until the `OrderedTerminalEvents` that come after are processed. // We should try to apply it after catching up to the latest state. } let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; let ambient_task_id: Option = source .orchestrator_task_id() .and_then(|s| s.parse().ok()); // Mark terminal view as a shared ambient agent session view. if matches!(&source.source_type, SessionSourceType::AmbientAgent { .. }) { let terminal_view_id = view.id(); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _ctx| { history.mark_terminal_surface_as_ambient_agent_session_view(terminal_view_id); }); // Register this ambient session as active for conversation list tracking. if let Some(task_id) = ambient_task_id { ActiveAgentViewsModel::handle(ctx).update(ctx, |model, ctx| { model.register_ambient_session(terminal_view_id, task_id, ctx); }); } } if enable_orchestration_polling && orchestration_viewer_model.lock().is_none() { if let Some(task_id) = ambient_task_id { let terminal_view_id = view.id(); let weak_view_handle_for_orch = weak_view_handle.clone(); let orchestration_viewer_model_slot = orchestration_viewer_model.clone(); let model = ctx.add_model(|model_ctx| { OrchestrationViewerModel::new( task_id, terminal_view_id, weak_view_handle_for_orch, model_ctx, ) }); *orchestration_viewer_model_slot.lock() = Some(model); } } let session_id = network.as_ref(ctx).session_id(); Manager::handle(ctx).update(ctx, |manager, ctx| { manager.joined_share(weak_view_handle.clone(), session_id, ctx); }); view.update(ctx, |terminal_view, ctx| { if let Some(task_id) = ambient_task_id { if let Some(ambient_agent_view_model) = terminal_view.ambient_agent_view_model() { ambient_agent_view_model.update(ctx, |model, ctx| { model.enter_viewing_existing_session(task_id, ctx); }); } } terminal_view.on_session_share_joined( viewer_id.clone(), *viewer_firebase_uid, input_replica_id.clone(), participant_list.clone(), session_id, source.source_type.clone(), ctx, ); }); #[cfg(target_family = "wasm")] crate::platform::wasm::emit_event(crate::platform::wasm::GalaxyEvent::SessionJoined); } NetworkEvent::SessionEnded { reason } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; let is_ambient_agent = model.lock().is_shared_ambient_agent_session(); if !Self::handle_viewer_session_end( &view, model.clone(), ¤t_network, &network, &orchestration_viewer_model, is_ambient_agent, ctx, ) { return; } view.update(ctx, |terminal_view, ctx| { let reason_string = session_ended_reason_string(reason); match reason { SessionEndedReason::EndedBySharer | SessionEndedReason::ExceededSizeLimit => {} SessionEndedReason::InactivityLimitReached => { terminal_view.show_persistent_toast( reason_string, ToastFlavor::Error, ctx, ); } SessionEndedReason::InternalServerError if is_ambient_agent => { // Don't show toast for cloud mode sessions - the error message // "ask sharer to reshare" doesn't apply. } _ => { terminal_view.show_persistent_toast( reason_string, ToastFlavor::Error, ctx, ); } } }); } NetworkEvent::ViewerRemoved { reason } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; // Viewer access was removed and the network will not re-attach. // Ambient agent panes route through the resumable // execution-ended path (clearing the now-dead live session) so // an owner can still start a cloud follow-up; non-ambient // viewers fall back to the generic finished-viewer teardown. let is_ambient_agent = model.lock().is_shared_ambient_agent_session(); if !Self::handle_viewer_session_end( &view, model.clone(), ¤t_network, &network, &orchestration_viewer_model, is_ambient_agent, ctx, ) { return; } view.update(ctx, |terminal_view, ctx| { let reason_string = viewer_removed_reason_string(reason); terminal_view.show_persistent_toast(reason_string, ToastFlavor::Error, ctx); }); } NetworkEvent::FailedToJoin { reason } => { let session_id = network.as_ref(ctx).session_id(); log::warn!( "viewer TerminalManager: NetworkEvent::FailedToJoin \ session_id={session_id} reason={reason:?}; pane stays in ViewPending \ until manual retry or a fresh ensure_shared_session_viewer_child_pane" ); let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { terminal_view.show_persistent_toast( reason.user_facing_error_message().to_string(), ToastFlavor::Error, ctx, ); }); } NetworkEvent::FailedToReconnect => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; // Reconnection has been abandoned. Ambient agent panes route // through the resumable execution-ended path (which clears the // stale live-session state so an owner can start a cloud // follow-up); non-ambient viewers fall back to the generic // finished-viewer teardown. let is_ambient_agent = model.lock().is_shared_ambient_agent_session(); if !Self::handle_viewer_session_end( &view, model.clone(), ¤t_network, &network, &orchestration_viewer_model, is_ambient_agent, ctx, ) { return; } // Ambient panes surface the resumable tombstone / follow-up // input instead, so the generic "please try again" toast (which // implies a retryable transport error) would be misleading. if !is_ambient_agent { view.update(ctx, |terminal_view, ctx| { terminal_view.show_persistent_toast( "Failed to reconnect. Please try again later.".to_owned(), ToastFlavor::Error, ctx, ); }); } } NetworkEvent::SharerActivePromptUpdated(active_prompt_update) => { Self::handle_active_prompt_update( model.clone(), prompt_type.clone(), weak_view_handle.clone(), &active_prompt_update.active_prompt, ctx, ); } NetworkEvent::UniversalDeveloperInputContextUpdated(context_update) => { let active_remote_update = viewer_remote_update_guard.start_remote_update(); if let Some(ref model) = context_update.selected_model { Self::handle_selected_agent_model_update(&weak_view_handle, model, &active_remote_update, ctx); } if let Some(ref input_mode) = context_update.input_mode { Self::handle_input_mode_update(&weak_view_handle, input_mode, &active_remote_update, ctx); } if let Some(ref selected_conversation) = context_update.selected_conversation { Self::handle_selected_conversation_update( &weak_view_handle, selected_conversation, &active_remote_update, ctx, ); } if let Some(auto_approve) = context_update.auto_approve_agent_actions { apply_auto_approve_agent_actions_update(&weak_view_handle, auto_approve, &active_remote_update, ctx); } if model .lock() .block_list() .active_block() .is_active_and_long_running() { if let Some(interaction) = context_update.long_running_command_agent_interaction.clone() { if let Some(view) = weak_view_handle.upgrade(ctx) { view.update(ctx, |view, ctx| { view.apply_long_running_command_agent_interaction(interaction, ctx); }); } } else if let Some(interaction_state) = context_update.long_running_command_agent_interaction_state { // TODO (roland): this is kept around for backward compatibility. Remove after 6 weeks (around Jul 23, 2026) // once clients have updated to use context_update.long_running_command_agent_interaction above. if let Some(view) = weak_view_handle.upgrade(ctx) { view.update(ctx, |view, ctx| { view.apply_long_running_command_agent_interaction_state( interaction_state, None, ctx, ); }); } } } if let Some(ref cli_agent_session) = context_update.cli_agent_session { apply_cli_agent_state_update( &weak_view_handle, cli_agent_session, &active_remote_update, ctx, ); } } NetworkEvent::Reconnecting => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |view, ctx| { view.on_shared_session_reconnection_status_changed(true, ctx) }); } NetworkEvent::ParticipantListUpdated(participant_list) => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; // A change to our role may have originated from the server, // make sure that our own state changes if it does. view.update(ctx, |view, ctx| { view.on_self_role_maybe_changed(participant_list.as_ref(), ctx); }); if let Some(presence_manager) = view.as_ref(ctx).shared_session_presence_manager() { presence_manager.update(ctx, |presence_manager, ctx| { presence_manager.update_participants(*participant_list.clone(), ctx) }); }; if let Some(session_id) = view.as_ref(ctx).shared_session_id().cloned() { SessionPermissionsManager::handle(ctx).update( ctx, |permissions_manager, ctx| { permissions_manager.updated_guests( ctx, session_id, participant_list.guests.clone(), participant_list.pending_guests.clone(), ); }, ); } } NetworkEvent::ParticipantPresenceUpdated(update) => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |view, ctx| { view.on_participant_presence_updated(update, ctx); }); } NetworkEvent::ReconnectedSuccessfully => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |view, ctx| { view.on_shared_session_reconnection_status_changed(false, ctx) }); } NetworkEvent::ParticipantRoleChanged { participant_id, reason, role, } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |view, ctx| { view.maybe_show_role_changed_toast(participant_id, *reason, *role, ctx); view.on_participant_role_changed(participant_id, *role, ctx); }); } NetworkEvent::InputUpdated { block_id, operations, } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |view, ctx| { // In cloud-mode startup (before the first exchange), shared-session input // sync reflects environment setup commands. Skip applying remote edits so // the visible input isn't populated with setup-command text. let skip_during_setup = FeatureFlag::CloudModeSetupV2.is_enabled() && { let model = view.model.lock(); is_cloud_agent_pre_first_exchange( view.ambient_agent_view_model(), view.agent_view_controller(), &model, ctx, ) }; if skip_during_setup { return; } view.apply_viewer_shared_session_input_update(block_id, operations.clone(), ctx); }) } NetworkEvent::RoleRequestInFlight(role_request_id) => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |view, ctx| { view.on_shared_session_viewer_role_request_in_flight( role_request_id.clone(), ctx, ); }); } NetworkEvent::RoleRequestResponse(role_request_response) => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |view, ctx| { view.on_shared_session_role_request_response( role_request_response.clone(), ctx, ); }); } NetworkEvent::CommandExecutionRequestFailed { reason, .. } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { Self::handle_command_execution_request_failed(terminal_view, reason, ctx); }); } NetworkEvent::WriteToPtyRequestFailed { reason } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { let reason_string = write_to_pty_failure_reason_string(reason); terminal_view.show_persistent_toast(reason_string, ToastFlavor::Error, ctx); }); } NetworkEvent::AgentPromptRequestInFlight(_id) => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { // Restore frozen visual state. optimistically_show_empty=true creates // a display-only empty ephemeral for immediate UX feedback. Unlike a // regular ephemeral, this one discards its content on materialization // instead of restoring it to the regular buffer, so no spurious CRDT // delete ops are generated for concurrent edits by other viewers. terminal_view.input().update(ctx, |input, ctx| { input.unfreeze_agent_input(true, ctx); }); }); } NetworkEvent::AgentPromptRequestFailed { reason, .. } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { let reason_string = agent_prompt_failure_reason_string(reason); terminal_view.show_persistent_toast(reason_string, ToastFlavor::Error, ctx); // Restore frozen visual state without clearing the buffer — the prompt // failed so no CRDT delete ops were sent, and the user should be able // to retry with their original text. terminal_view.input().update(ctx, |input, ctx| { input.unfreeze_agent_input(false, ctx); }); }); } NetworkEvent::ControlActionRequestFailed { reason } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { let reason_string = control_action_failure_reason_string(reason); terminal_view.show_persistent_toast(reason_string, ToastFlavor::Error, ctx); }); } NetworkEvent::LinkAccessLevelUpdated { role } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { let Some(session_id) = terminal_view.shared_session_id() else { return; }; SessionPermissionsManager::handle(ctx).update( ctx, |permissions_manager, ctx| { permissions_manager.updated_link_permissions(*session_id, *role, ctx); }, ); }); } NetworkEvent::TeamAccessLevelUpdated { team_acl } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { let Some(session_id) = terminal_view.shared_session_id() else { return; }; SessionPermissionsManager::handle(ctx).update( ctx, |permissions_manager, ctx| { permissions_manager.updated_team_permissions( *session_id, team_acl.clone(), ctx, ); }, ); }); } NetworkEvent::LinkAccessLevelUpdateResponse { response } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| match response { LinkAccessLevelUpdateResponse::Ok { role } => { let Some(session_id) = terminal_view.shared_session_id() else { return; }; SessionPermissionsManager::handle(ctx).update( ctx, |permissions_manager, ctx| { permissions_manager.updated_link_permissions( *session_id, *role, ctx, ); }, ); } LinkAccessLevelUpdateResponse::Error => { terminal_view.show_persistent_toast( "Failed to update permissions for shared session".to_owned(), ToastFlavor::Error, ctx, ); } }); } NetworkEvent::TeamAccessLevelUpdateResponse { response } => { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| match response { TeamAccessLevelUpdateResponse::Success { team_acl, .. } => { let Some(session_id) = terminal_view.shared_session_id() else { return; }; SessionPermissionsManager::handle(ctx).update( ctx, |permissions_manager, ctx| { permissions_manager.updated_team_permissions( *session_id, team_acl.clone(), ctx, ); }, ); } TeamAccessLevelUpdateResponse::Error(_) => { terminal_view.show_persistent_toast( "Something went wrong. Please try again.".to_owned(), ToastFlavor::Error, ctx, ); } }); } NetworkEvent::AddGuestsResponse { response } => { if let AddGuestsResponse::Error(reason) = response { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { let reason_string = match reason { session_sharing_protocol::common::FailedToAddGuestsReason::NotWarpUsers => { "One or more of the emails are not Galaxy users.".to_owned() } session_sharing_protocol::common::FailedToAddGuestsReason::GuestAlreadyAdded => { "One or more of the guests has already been added.".to_owned() } _ => "Something went wrong. Please try again.".to_owned(), }; terminal_view.show_persistent_toast(reason_string, ToastFlavor::Error, ctx); }); } } NetworkEvent::RemoveGuestResponse { response } => { if let RemoveGuestResponse::Error(_) = response { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { terminal_view.show_persistent_toast( "Something went wrong. Please try again.".to_owned(), ToastFlavor::Error, ctx, ); }); } } NetworkEvent::UpdatePendingUserRoleResponse { response } => { if let UpdatePendingUserRoleResponse::Error(_) = response { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; view.update(ctx, |terminal_view, ctx| { terminal_view.show_persistent_toast( "Something went wrong. Please try again.".to_owned(), ToastFlavor::Error, ctx, ); }); } } }); } fn handle_active_prompt_update( model: Arc>, prompt_type: ModelHandle, weak_view_handle: WeakViewHandle, active_prompt: &ActivePrompt, ctx: &mut AppContext, ) { let mut model = model.lock(); match active_prompt { ActivePrompt::WarpPrompt(serialized_prompt_snapshot) => { match serde_json::from_str::(serialized_prompt_snapshot) { Ok(prompt_snapshot) => { model.block_list_mut().set_honor_ps1(false); // Overwrite the static prompt with the new snapshot. prompt_type.update(ctx, |prompt_type, ctx| { if let PromptType::Static { snapshot } = prompt_type { *snapshot = prompt_snapshot; ctx.notify(); } else { log::warn!("Received ActivePrompt::WarpPrompt updated but prompt type is not Static"); } }); } Err(e) => { log::error!( "Failed to deserialize prompt snapshot from shared session server: {e}" ) } } } ActivePrompt::PS1 => { // The viewer already receives bytes from the pty for the PS1 prompt, so we only need to choose to render it. model.block_list_mut().set_honor_ps1(true); } } let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; // This is needed to re-render the input if we changed prompt types. view.update(ctx, |view, ctx| { view.input().update(ctx, |input, ctx| { input.notify_and_notify_children(ctx); }) }); } fn handle_selected_agent_model_update( weak_view_handle: &WeakViewHandle, selected_model: &SelectedAgentModel, guard: &ActiveRemoteUpdate, ctx: &mut AppContext, ) { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; let terminal_view_id = view.id(); apply_selected_agent_model_update(terminal_view_id, selected_model, guard, ctx); } fn handle_input_mode_update( weak_view_handle: &WeakViewHandle, input_mode: &session_sharing_protocol::common::InputMode, guard: &ActiveRemoteUpdate, ctx: &mut AppContext, ) { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; // During cloud startup (pre-first-exchange), keep local input mode stable // and ignore remote shell/ai mode toggles from session-sharing context sync. let is_pre_first_exchange = FeatureFlag::CloudModeSetupV2.is_enabled() && { let view_ref = view.as_ref(ctx); let model = view_ref.model.lock(); is_cloud_agent_pre_first_exchange( view_ref.ambient_agent_view_model(), view_ref.agent_view_controller(), &model, ctx, ) }; let suppress_input_mode_update = view.as_ref(ctx).is_shared_ambient_agent_session() || is_pre_first_exchange; if suppress_input_mode_update { return; } apply_input_mode_update(weak_view_handle, input_mode, guard, ctx); } fn handle_selected_conversation_update( weak_view_handle: &WeakViewHandle, selected_conversation: &session_sharing_protocol::common::SelectedConversation, guard: &ActiveRemoteUpdate, ctx: &mut AppContext, ) { apply_selected_conversation_update(weak_view_handle, selected_conversation, guard, ctx); } fn handle_view_events( current_network: Arc>>>, view: &ViewHandle, model: Arc>, viewer_remote_update_guard: RemoteUpdateGuard, ctx: &mut AppContext, ) { ctx.subscribe_to_view(view, move |view, event, ctx| match event { TerminalViewEvent::SelectedBlocksChanged | TerminalViewEvent::SelectedTextChanged => { let selection = view.read(ctx, |view, ctx| { view.get_shared_session_presence_selection(ctx) }); Self::update_current_network(¤t_network, ctx, |network, _| { network.send_presence_selection_if_changed(selection); }); } TerminalViewEvent::RequestSharedSessionRole(role) => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_role_request(*role); }); } TerminalViewEvent::CancelRoleRequest(role_request_id) => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_cancel_role_request(role_request_id.clone()); }); } TerminalViewEvent::InputEditorUpdated { block_id, operations, } => { let should_send_input_update = view.read(ctx, |view, ctx| { let model = model.lock(); model.block_list().active_block_id() == block_id && model.shared_session_status().is_executor() && view.should_publish_shared_session_input_editor_update(&model, ctx) }); if should_send_input_update { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_input_update(block_id, operations.iter()); }); } } TerminalViewEvent::ExecuteCommand(ExecuteCommandEvent { command, source, .. }) => { // For a viewer, only the SharedSession execution source is valid. let CommandExecutionSource::SharedSession { block_id, .. } = source else { log::warn!("Got a TerminalViewEvent::ExecuteCommand in viewer::TerminalManager where the source was not SharedSession"); return; }; // If the block ID has become stale by the time we get here, // we don't need to send this update to the server. if model.lock().block_list().active_block_id() != block_id { return; } // Only send command execution request if the viewer is an executor. if model.lock().shared_session_status().is_executor() { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_command_execution_request(block_id, command.to_owned()); }); } } TerminalViewEvent::RejoinCurrentSession => { Self::update_current_network(¤t_network, ctx, |network, ctx| { network.reauthenticate_viewer(ctx); }); } TerminalViewEvent::SendAgentPrompt { server_conversation_token, prompt, attachments, } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_agent_prompt_request( *server_conversation_token, prompt.clone(), attachments.clone(), ); }); } TerminalViewEvent::CancelSharedSessionConversation { server_conversation_token, } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_cancel_control_action(*server_conversation_token); }); } TerminalViewEvent::ReportViewerTerminalSize { window_size } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_report_terminal_size(*window_size); }); } TerminalViewEvent::LongRunningCommandAgentInteractionStateChanged { state, block_id, } => { let interaction = block_id .clone() .map(|block_id| LongRunningCommandAgentInteraction { block_id: block_id.into(), state: *state, }); Self::send_input_context_update_to_current_network( &viewer_remote_update_guard, &model, ¤t_network, UniversalDeveloperInputContextUpdate { long_running_command_agent_interaction_state: Some(*state), long_running_command_agent_interaction: interaction, ..Default::default() }, ctx, ); } TerminalViewEvent::UpdateSessionLinkPermissions { role } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_link_permission_update(*role); }); } TerminalViewEvent::UpdateSessionTeamPermissions { role, team_uid } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_team_permission_update(*role, team_uid.clone()); }); } TerminalViewEvent::AddGuests { emails, role } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_add_guests(emails.clone(), *role); }); } TerminalViewEvent::RemoveGuest { user_uid } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_remove_guest(*user_uid); }); } TerminalViewEvent::RemovePendingGuest { email } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_remove_pending_guest(email.clone()); }); } TerminalViewEvent::UpdateUserRole { user_uid, role } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_user_role_update(*user_uid, *role); }); } TerminalViewEvent::UpdatePendingUserRole { email, role } => { Self::update_current_network(¤t_network, ctx, |network, _| { network.send_pending_user_role_update(email.clone(), *role); }); } _ => (), }); } fn handle_network_status_events( view: &ViewHandle, current_network: Arc>>>, ctx: &mut AppContext, ) { let weak_view_handle = view.downgrade(); let network_status = NetworkStatus::handle(ctx); ctx.subscribe_to_model(&network_status, move |_, event, ctx| { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; let NetworkStatusEvent::NetworkStatusChanged { new_status } = event; match new_status { NetworkStatusKind::Online => { if Self::current_network(¤t_network) .is_some_and(|network| network.as_ref(ctx).is_connected()) { view.update(ctx, |view, ctx| { view.on_shared_session_reconnection_status_changed(false, ctx) }); } } NetworkStatusKind::Offline => { view.update(ctx, |view, ctx| { view.on_shared_session_reconnection_status_changed(true, ctx) }); } } }); } /// Drops the [`OrchestrationViewerModel`] from the shared slot if one /// exists. Called from terminal session-end paths. The model's /// `ctx.spawn` continuations are entity-scoped, so dropping the /// entity makes them no-ops; no explicit `.abort()` needed. /// /// Under `FeatureFlag::OrchestrationViewerStreamer`, the model also /// holds a viewer-mode registration on the shared /// [`OrchestrationEventStreamer`]; we unregister explicitly here so /// the streamer can refcount-tear-down the ancestor SSE on the last /// pane close. The unregister API is idempotent, so calling it when /// the flag is off (or when the streamer has already removed the /// entry) is harmless. fn stop_orchestration_polling( orchestration_viewer_model: &Arc>>>, ctx: &mut AppContext, ) { let Some(handle) = orchestration_viewer_model.lock().take() else { return; }; let parent_task_id = handle.as_ref(ctx).parent_task_id(); let consumer_id = handle.id(); log::debug!( "[orch-viewer] stopping orchestration viewer model parent_task_id={parent_task_id} \ consumer_id={consumer_id:?}" ); if FeatureFlag::OrchestrationViewerStreamer.is_enabled() { OrchestrationEventStreamer::handle(ctx).update(ctx, move |streamer, _ctx| { streamer.unregister_viewer_mode_consumer(parent_task_id, consumer_id); }); } // `handle` drops here, releasing the per-pane viewer model. drop(handle); } /// Common teardown for the viewer session-end network events /// (`SessionEnded`, `ViewerRemoved`, `FailedToReconnect`). /// /// Ambient agent panes route through [`Self::end_current_ambient_session`], /// which clears the live `active_execution_session_id`, records the ended /// session, and surfaces the resumable tombstone / follow-up input — so a /// session lost via reconnect failure or access removal lands in the same /// editable post-run state as a clean `SessionEnded`, rather than leaving a /// stale "session is live" gate that misroutes follow-ups to a local agent. /// Non-ambient panes use the generic [`Self::shared_session_ended`] /// finished-viewer teardown (which cancels in-progress conversations). /// /// Returns `false` when an ambient end was ignored because the ended network /// is no longer the current one (a stale event); callers should bail without /// surfacing an end-of-session toast. fn handle_viewer_session_end( terminal_view: &ViewHandle, model: Arc>, current_network: &Arc>>>, ended_network: &ModelHandle, orchestration_viewer_model: &Arc>>>, is_ambient_agent: bool, ctx: &mut AppContext, ) -> bool { if is_ambient_agent { if !Self::end_current_ambient_session( terminal_view, model, current_network, ended_network, ctx, ) { return false; } // Non-owner viewers (read-only) won't get a follow-up session; // owners may handoff via `attach_execution_session` (same // `TerminalManager`, same orchestrator `task_id`), so keep their // model. let is_owner = terminal_view.read(ctx, |terminal_view, app| { terminal_view.owned_ambient_agent_task_id(app).is_some() }); if !is_owner { Self::stop_orchestration_polling(orchestration_viewer_model, ctx); } } else { Self::stop_orchestration_polling(orchestration_viewer_model, ctx); Self::shared_session_ended(terminal_view, model, ctx); } true } fn shared_session_ended( terminal_view: &ViewHandle, model: Arc>, ctx: &mut AppContext, ) { let terminal_view_id = terminal_view.id(); // When a shared session ends for a viewer, cancel any in-progress conversations. BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model .all_live_conversations_for_terminal_surface(terminal_view_id) .filter(|conversation| conversation.status().is_in_progress()) .map(|conversation| conversation.id()) .collect::>() .into_iter() .for_each(|conversation_id| { history_model.update_conversation_status( terminal_view_id, conversation_id, ConversationStatus::Cancelled, ctx, ) }); }); Manager::handle(ctx).update(ctx, |manager, _| { manager.left_share(terminal_view_id); }); terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.on_session_share_ended(ctx); }); model .lock() .set_shared_session_status(SharedSessionStatus::FinishedViewer); model .lock() .clear_write_to_pty_events_for_shared_session_tx(); } fn end_current_ambient_session( terminal_view: &ViewHandle, model: Arc>, current_network: &Arc>>>, ended_network: &ModelHandle, ctx: &mut AppContext, ) -> bool { let ended_session_id = ended_network.as_ref(ctx).session_id(); if !Self::current_network(current_network) .is_some_and(|network| network.as_ref(ctx).session_id() == ended_session_id) { return false; } Manager::handle(ctx).update(ctx, |manager, _| { manager.left_share(terminal_view.id()); }); model .lock() .clear_write_to_pty_events_for_shared_session_tx(); if FeatureFlag::HandoffCloudCloud.is_enabled() { terminal_view.update(ctx, |terminal_view, ctx| { // Owned ambient tasks remain editable Cloud Mode panes after their live shared // session ends; non-owners are still read-only viewers of a finished session. let owns_ambient_task = terminal_view.owned_ambient_agent_task_id(ctx).is_some(); model .lock() .set_shared_session_status(if owns_ambient_task { SharedSessionStatus::NotShared } else { SharedSessionStatus::FinishedViewer }); if let Some(ambient_agent_view_model) = terminal_view.ambient_agent_view_model().cloned() { ambient_agent_view_model.update(ctx, |model, ctx| { model.record_ambient_execution_ended(ended_session_id, ctx); }); } terminal_view.on_ambient_agent_execution_ended(ctx); }); } if Self::current_network(current_network) .is_some_and(|network| network.as_ref(ctx).session_id() == ended_session_id) { *current_network.lock() = None; } true } } impl crate::terminal::TerminalManager for TerminalManager { fn model(&self) -> Arc> { self.model.clone() } fn on_view_detached(&self, detach_type: DetachType, app: &mut AppContext) { // Keep the network + shared-session state — and the orchestration // viewer model (OVM) — alive for non-permanent detaches: // - `HiddenForClose`: the pane may be restored from the undo-close stack within the // grace window (~60s default). We deliberately leave the OVM (and its ancestor // streamer registration) in place so undo-close-tab restores the pill bar // seamlessly. If the tab is never restored, we'll be invoked again with `Closed` // from the grace-period expiry and tear down then. // - `Moved`: the same `TerminalManager` is reused in the target pane group (the // `Box` is transferred via `remove_pane_for_move` and then // immediately re-attached), so tearing down the network or OVM would break the // live session. // Only `Closed` tears down the OVM here. if !matches!(detach_type, DetachType::Closed) { return; } let terminal_view_id = self.view.id(); ActiveAgentViewsModel::handle(app).update(app, |model, ctx| { model.unregister_agent_view_controller(terminal_view_id, ctx); model.unregister_ambient_session(terminal_view_id, ctx); }); // Tear down the orchestration viewer model so its streamer // registration is released and the ancestor SSE can close. The // network-event paths (SessionEnded / ViewerRemoved / // FailedToReconnect) also call this, but pane-close doesn't flow // through them — without this, the SSE leaks until the app exits. // `stop_orchestration_polling` is idempotent, so a later // network-event-driven call is a no-op. Self::stop_orchestration_polling(&self.orchestration_viewer_model, app); if let NetworkState::Active(ref network) = self.network_state { network.update(app, |network, _| { network.close_without_reconnection(); }); } self.model .lock() .set_shared_session_status(SharedSessionStatus::FinishedViewer); self.view .update(app, |view, ctx| view.on_session_share_ended(ctx)); } fn as_any(&self) -> &dyn std::any::Any { self } fn as_any_mut(&mut self) -> &mut dyn Any { self } } #[cfg(test)] #[path = "terminal_manager_tests.rs"] mod tests;