1930 lines
84 KiB
Rust
1930 lines
84 KiB
Rust
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<Network>),
|
|
/// Transient state while connecting a viewer network.
|
|
Connecting,
|
|
}
|
|
|
|
struct NetworkResources {
|
|
prompt_type: ModelHandle<PromptType>,
|
|
channel_event_proxy: ChannelEventListener,
|
|
}
|
|
|
|
pub struct TerminalManager {
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
view: ViewHandle<TerminalView>,
|
|
|
|
// We store this here just to keep it from being dropped.
|
|
_model_events: ModelHandle<ModelEventDispatcher>,
|
|
|
|
/// 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<Arc<Vec<u8>>>,
|
|
|
|
/// The network state for the shared session viewer.
|
|
network_state: NetworkState,
|
|
network_resources: NetworkResources,
|
|
current_network: Arc<FairMutex<Option<ModelHandle<Network>>>>,
|
|
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<FairMutex<Option<...>>>` matches
|
|
/// `current_network` so the network-event closure can write into it
|
|
/// without `&mut self`.
|
|
orchestration_viewer_model: Arc<FairMutex<Option<ModelHandle<OrchestrationViewerModel>>>>,
|
|
/// `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<TerminalView>,
|
|
}
|
|
|
|
impl TerminalManager {
|
|
fn send_selected_conversation_update_for_viewer_to_current_network(
|
|
guard: &RemoteUpdateGuard,
|
|
model: &Arc<FairMutex<TerminalModel>>,
|
|
current_network: &Arc<FairMutex<Option<ModelHandle<Network>>>>,
|
|
agent_view_controller: &ModelHandle<AgentViewController>,
|
|
ai_context_model: &ModelHandle<BlocklistAIContextModel>,
|
|
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<FairMutex<Option<ModelHandle<Network>>>>,
|
|
) -> Option<ModelHandle<Network>> {
|
|
current_network.lock().clone()
|
|
}
|
|
|
|
fn update_current_network(
|
|
current_network: &Arc<FairMutex<Option<ModelHandle<Network>>>>,
|
|
ctx: &mut AppContext,
|
|
update: impl FnOnce(&mut Network, &mut ModelContext<Network>),
|
|
) {
|
|
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<FairMutex<TerminalModel>>,
|
|
current_network: &Arc<FairMutex<Option<ModelHandle<Network>>>>,
|
|
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<TerminalView>,
|
|
) {
|
|
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<Sessions> =
|
|
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<Network>,
|
|
view: &ViewHandle<TerminalView>,
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
current_network: Arc<FairMutex<Option<ModelHandle<Network>>>>,
|
|
prompt_type: ModelHandle<PromptType>,
|
|
viewer_remote_update_guard: RemoteUpdateGuard,
|
|
orchestration_viewer_model: Arc<FairMutex<Option<ModelHandle<OrchestrationViewerModel>>>>,
|
|
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<AmbientAgentTaskId> = 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<FairMutex<TerminalModel>>,
|
|
prompt_type: ModelHandle<PromptType>,
|
|
weak_view_handle: WeakViewHandle<TerminalView>,
|
|
active_prompt: &ActivePrompt,
|
|
ctx: &mut AppContext,
|
|
) {
|
|
let mut model = model.lock();
|
|
match active_prompt {
|
|
ActivePrompt::WarpPrompt(serialized_prompt_snapshot) => {
|
|
match serde_json::from_str::<PromptSnapshot>(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<TerminalView>,
|
|
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<TerminalView>,
|
|
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<TerminalView>,
|
|
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<FairMutex<Option<ModelHandle<Network>>>>,
|
|
view: &ViewHandle<TerminalView>,
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
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<TerminalView>,
|
|
current_network: Arc<FairMutex<Option<ModelHandle<Network>>>>,
|
|
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<FairMutex<Option<ModelHandle<OrchestrationViewerModel>>>>,
|
|
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<TerminalView>,
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
current_network: &Arc<FairMutex<Option<ModelHandle<Network>>>>,
|
|
ended_network: &ModelHandle<Network>,
|
|
orchestration_viewer_model: &Arc<FairMutex<Option<ModelHandle<OrchestrationViewerModel>>>>,
|
|
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<TerminalView>,
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
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::<Vec<_>>()
|
|
.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<TerminalView>,
|
|
model: Arc<FairMutex<TerminalModel>>,
|
|
current_network: &Arc<FairMutex<Option<ModelHandle<Network>>>>,
|
|
ended_network: &ModelHandle<Network>,
|
|
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<FairMutex<TerminalModel>> {
|
|
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<dyn AnyPaneContent>` 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;
|