use std::collections::HashMap; use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; use ai::project_context::model::ProjectContextModel; use chrono::Utc; use galaxy_core::features::FeatureFlag; use pathfinder_geometry::rect::RectF; use persistence::model::{ AgentConversation, AgentConversationData, AgentConversationRecord, ConversationUsageMetadata, }; use repo_metadata::repositories::DetectedRepositories; use repo_metadata::watcher::DirectoryWatcher; #[cfg(feature = "local_fs")] use repo_metadata::RepoMetadataModel; use session_sharing_protocol::common::SessionId; use shared_session::permissions_manager::SessionPermissionsManager; use uuid::Uuid; use warp_server_client::iap::IapManager; use warpui::platform::{WindowBounds, WindowStyle}; use warpui::windowing::state::ApplicationStage; use warpui::windowing::WindowManager; use warpui::{App, ModelHandle}; use watcher::HomeDirectoryWatcher; use super::child_agent::hydration::{ decide_remote_child_hydration_action, RemoteChildHydrationAction, }; use super::child_agent::{ create_hidden_child_agent_conversation, HiddenChildAgentConversationRequest, HiddenChildAgentTaskContext, }; use super::*; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::{ AIAgentHarness, AIConversation, AIConversationId, ServerAIConversationMetadata, }; use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; use crate::ai::ambient_agents::task::TaskPrincipalInfo; use crate::ai::ambient_agents::{ AgentSource, AmbientAgentLiveSessionState, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState, }; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; use crate::ai::blocklist::history_model::CloudConversationData; use crate::ai::blocklist::local_agent_task_sync_model::LocalAgentTaskSyncModel; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; use crate::ai::blocklist::orchestration_events::OrchestrationEventService; use crate::ai::blocklist::orchestration_topology::descendant_conversation_ids_in_spawn_order; use crate::ai::blocklist::{BlocklistAIHistoryModel, QueuedQueryModel}; use crate::ai::document::ai_document_model::AIDocumentModel; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::harness_availability::HarnessAvailabilityModel; use crate::ai::llms::LLMPreferences; use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager; use crate::ai::mcp::{FileBasedMCPManager, FileMCPWatcher}; use crate::ai::outline::RepoOutlines; use crate::ai::persisted_workspace::PersistedWorkspace; use crate::ai::restored_conversations::RestoredAgentConversations; use crate::ai::skills::SkillManager; use crate::ai::AIRequestUsageModel; use crate::auth::auth_manager::AuthManager; use crate::auth::user::TEST_USER_UID; use crate::changelog_model::ChangelogModel; use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{Owner, Revision, ServerMetadata, ServerPermissions}; use crate::context_chips::prompt::Prompt; use crate::network::NetworkStatus; use crate::notebooks::editor::keys::NotebookKeybindings; use crate::notebooks::manager::NotebookManager; use crate::notebooks::notebook::NotebookView; use crate::pricing::PricingInfoModel; use crate::resource_center::TipsCompleted; use crate::search::files::model::FileSearchModel; use crate::server::cloud_objects::listener::Listener; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::ids::ServerId; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; use crate::server::telemetry::context_provider::AppTelemetryContextProvider; use crate::settings::PrivacySettings; use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; use crate::system::SystemStats; use crate::terminal::alt_screen_reporting::AltScreenReporting; use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::terminal::history::History; use crate::terminal::keys::TerminalKeybindings; use crate::terminal::local_tty::spawner::PtySpawner; use crate::terminal::local_tty::TerminalManager; use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; use crate::terminal::resizable_data::ResizableData; use crate::terminal::shared_session::{ IsSharedSessionCreator, SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionSource, SharedSessionStatus, }; use crate::test_util::settings::initialize_settings_for_tests; use crate::undo_close::UndoCloseStack; use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher; use crate::workflows::local_workflows::LocalWorkflows; use crate::workspace::sync_inputs::SyncedInputState; use crate::workspace::{ActiveSession, OneTimeModalModel, WorkspaceRegistry}; use crate::workspaces::team_tester::TeamTesterStatus; use crate::workspaces::update_manager::TeamUpdateManager; use crate::workspaces::user_profiles::UserProfiles; use crate::workspaces::user_workspaces::UserWorkspaces; use crate::{ experiments, AgentNotificationsModel, GlobalResourceHandles, GlobalResourceHandlesProvider, }; fn initialize_app(app: &mut App) { initialize_settings_for_tests(app); app.add_singleton_model(|_ctx| ServerApiProvider::new_for_test()); // Disabled (`None`) IapManager so shared-session viewer code that reads the // singleton doesn't panic in tests; it is an inert no-op. app.add_singleton_model(|ctx| { IapManager::new( None, Box::new(|_| futures::FutureExt::boxed(futures::future::ready(None::))), ctx, ) }); app.add_singleton_model(|ctx| ChangelogModel::new(ServerApiProvider::as_ref(ctx).get())); app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(AppTelemetryContextProvider::new_context_provider); app.add_singleton_model(AuthManager::new_for_test); app.add_singleton_model(|_ctx| PtySpawner::new_for_test()); app.add_singleton_model(|_| NetworkStatus::new()); app.add_singleton_model(|_| SystemStats::new()); app.add_singleton_model(SyncQueue::mock); app.add_singleton_model(CloudModel::mock); app.add_singleton_model(UserWorkspaces::default_mock); app.add_singleton_model(TeamTesterStatus::mock); app.add_singleton_model(TeamUpdateManager::mock); app.add_singleton_model(Listener::mock); app.add_singleton_model(UpdateManager::mock); // Initialize file-based MCP dependencies. app.add_singleton_model(|_| DetectedRepositories::default()); app.add_singleton_model(HomeDirectoryWatcher::new_for_test); app.add_singleton_model(DirectoryWatcher::new); app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing); app.add_singleton_model(FileMCPWatcher::new); app.add_singleton_model(|_| FileBasedMCPManager::default()); app.add_singleton_model(|_| TemplatableMCPServerManager::default()); app.add_singleton_model(|_ctx| UserProfiles::new(Vec::new())); app.add_singleton_model(|_| Appearance::mock()); app.add_singleton_model(PrivacySettings::mock); app.add_singleton_model(|_ctx| SyncedInputState::mock()); app.add_singleton_model(LocalWorkflows::new); app.add_singleton_model(|_| Prompt::mock()); app.add_singleton_model(|_| ResizableData::default()); app.add_singleton_model(NotebookManager::mock); app.add_singleton_model(shared_session::manager::Manager::new); app.add_singleton_model(|_| ActiveSession::default()); let global_resources = GlobalResourceHandles::mock(app); app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resources.clone())); app.add_singleton_model(|_| KeybindingChangedNotifier::new()); app.add_singleton_model(NotebookKeybindings::new); app.add_singleton_model(TerminalKeybindings::new); app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); // QueuedQueryModel subscribes to history events; register after the // history model is in place. app.add_singleton_model(QueuedQueryModel::new); // Pill bar model subscribes to history events; register after the // history model is in place. app.add_singleton_model(|ctx| { crate::ai::blocklist::agent_view::orchestration_pill_bar_model::OrchestrationPillBarModel::new( Default::default(), ctx, ) }); app.add_singleton_model(|_| CLIAgentSessionsModel::new()); app.add_singleton_model(OrchestrationEventService::new); app.add_singleton_model(LocalAgentTaskSyncModel::new); app.add_singleton_model(OrchestrationEventStreamer::new); app.add_singleton_model(|_| ActiveAgentViewsModel::new()); app.add_singleton_model(crate::ai::blocklist::BlocklistAIPermissions::new); app.add_singleton_model(AgentNotificationsModel::new); app.add_singleton_model(|ctx| { AIExecutionProfilesModel::new(&crate::LaunchMode::new_for_unit_test(), ctx) }); app.add_singleton_model(|ctx| { AIRequestUsageModel::new_for_test(ServerApiProvider::as_ref(ctx).get_ai_client(), ctx) }); app.add_singleton_model(SessionPermissionsManager::new); app.add_singleton_model(LLMPreferences::new); app.add_singleton_model(HarnessAvailabilityModel::new); #[cfg(feature = "voice_input")] app.add_singleton_model(voice_input::VoiceInput::new); #[cfg(feature = "local_fs")] app.add_singleton_model(RepoMetadataModel::new); app.add_singleton_model(SkillManager::new); app.add_singleton_model(FileSearchModel::new); app.add_singleton_model(|_| crate::code_review::git_repo_model::GitRepoModels::new()); app.add_singleton_model(RepoOutlines::new_for_test); crate::terminal::available_shells::register(app); app.update(experiments::init); AltScreenReporting::register(app); app.add_singleton_model(|ctx| { CodebaseIndexManager::new_for_test(ServerApiProvider::as_ref(ctx).get(), ctx) }); app.add_singleton_model(|ctx| PersistedWorkspace::new(vec![], HashMap::new(), None, ctx)); app.add_singleton_model(|_| ProjectContextModel::default()); app.add_singleton_model(|ctx| crate::ai::agent_tips::AITipModel::new_for_agent_tips(ctx)); app.add_singleton_model(|_| RestoredAgentConversations::new(vec![])); app.add_singleton_model(OneTimeModalModel::new); app.add_singleton_model(|_| WorkspaceRegistry::new()); app.add_singleton_model(UndoCloseStack::new); app.add_singleton_model(|_| IgnoredSuggestionsModel::new(vec![])); app.add_singleton_model(|_| PricingInfoModel::new()); app.add_singleton_model(AIDocumentModel::new); app.add_singleton_model(|_| History::new(vec![])); app.add_singleton_model(|_| GitHubAuthNotifier::new()); app.add_singleton_model(AgentConversationsModel::new); app.add_singleton_model(remote_server::manager::RemoteServerManager::new); } struct MockOptions { layout: PanesLayout, window_bounds: WindowBounds, } impl Default for MockOptions { fn default() -> Self { Self { layout: Default::default(), window_bounds: WindowBounds::ExactPosition(RectF::new( Vector2F::zero(), Vector2F::new(1024., 768.), )), } } } fn mock_pane_group(app: &mut App, options: MockOptions) -> ViewHandle { let tips_model = app.add_model(|_| TipsCompleted::default()); let (_, pane_group) = app.add_window_with_bounds(WindowStyle::NotStealFocus, options.window_bounds, |ctx| { let user_default_shell_changed_banner_dismissal_model_handle = ctx.add_model(|_| BannerState::default()); let block_lists = Arc::new(HashMap::new()); PaneGroup::new_with_panes_layout( tips_model, user_default_shell_changed_banner_dismissal_model_handle, ServerApiProvider::as_ref(ctx).get(), options.layout, block_lists, None, ctx, ) }); pane_group } fn get_newly_created_pane_id(panes: &PaneGroup, existing_ids: &[PaneId]) -> PaneId { panes .pane_ids() .find(|id| !existing_ids.contains(id)) .unwrap() } fn split_pane_state(panes: &PaneGroup, pane_id: PaneId, ctx: &AppContext) -> SplitPaneState { panes .focus_state_handle() .as_ref(ctx) .split_pane_state_for(pane_id) } fn is_active_session(panes: &PaneGroup, pane_id: PaneId, ctx: &AppContext) -> bool { panes.active_session_id(ctx).map(Into::into) == Some(pane_id) } fn new_notebook(ctx: &mut ViewContext) -> ViewHandle { ctx.add_typed_action_view(NotebookView::new) } fn new_ambient_agent_task_id() -> AmbientAgentTaskId { Uuid::new_v4().to_string().parse().unwrap() } fn ambient_agent_task_for_current_user(task_id: AmbientAgentTaskId) -> AmbientAgentTask { let now = Utc::now(); AmbientAgentTask { task_id, parent_run_id: None, title: "Owned task".to_string(), state: AmbientAgentTaskState::Succeeded, prompt: "test".to_string(), created_at: now, started_at: Some(now), updated_at: now, run_time: Some("PT1S".parse().unwrap()), status_message: None, source: Some(AgentSource::CloudMode), session_id: None, session_link: None, executor: None, creator: Some(TaskPrincipalInfo { creator_type: "USER".to_string(), uid: TEST_USER_UID.to_string(), display_name: None, }), conversation_id: None, request_usage: None, is_sandbox_running: false, agent_config_snapshot: None, artifacts: vec![], last_event_sequence: None, children: vec![], } } fn mock_server_metadata() -> ServerMetadata { ServerMetadata { uid: ServerId::default(), revision: Revision::now(), metadata_last_updated_ts: Utc::now().into(), trashed_ts: None, folder_id: None, is_welcome_object: false, creator_uid: None, last_editor_uid: None, current_editor_uid: None, } } fn mock_server_permissions() -> ServerPermissions { ServerPermissions { space: Owner::mock_current_user(), guests: Vec::new(), anyone_link_sharing: None, permissions_last_updated_ts: Utc::now().into(), } } fn test_server_conversation_metadata( task_id: Option, ) -> ServerAIConversationMetadata { ServerAIConversationMetadata { title: "Restored cloud conversation".to_string(), working_directory: None, harness: AIAgentHarness::Oz, usage: ConversationUsageMetadata { was_summarized: false, context_window_usage: 0.0, credits_spent: 0.0, platform_credits_spent: 0.0, credits_spent_for_last_block: None, token_usage: vec![], tool_usage_metadata: Default::default(), context_window_segments: Vec::new(), ..Default::default() }, metadata: mock_server_metadata(), creator: None, permissions: mock_server_permissions(), ambient_agent_task_id: task_id, server_conversation_token: ServerConversationToken::new("test-server-token".to_string()), artifacts: Vec::new(), } } fn cloud_conversation_with_ambient_task(task_id: AmbientAgentTaskId) -> CloudConversationData { let mut conversation = AIConversation::new(false, false); conversation.set_task_id(task_id); conversation.set_server_metadata(test_server_conversation_metadata(Some(task_id))); CloudConversationData::Oz(Box::new(conversation)) } fn persisted_remote_child_conversation( conversation_id: AIConversationId, parent_conversation_id: Option, parent_agent_id: Option, task_id: AmbientAgentTaskId, ) -> AgentConversation { AgentConversation { conversation: AgentConversationRecord { id: 0, conversation_id: conversation_id.to_string(), conversation_data: serde_json::to_string(&AgentConversationData { server_conversation_token: Some("restored-child-token".to_string()), conversation_usage_metadata: None, reverted_action_ids: None, forked_from_server_conversation_token: None, artifacts_json: None, parent_agent_id, agent_name: Some("Agent 1".to_string()), orchestration_harness_type: None, parent_conversation_id: parent_conversation_id.map(|id| id.to_string()), is_remote_child: true, root_task_is_optimistic: None, run_id: Some(task_id.to_string()), autoexecute_override: None, last_event_sequence: None, pinned: false, progressive_summary: None, messages_summarized_up_to: 0, }) .expect("conversation data should serialize"), last_modified_at: Utc::now().naive_utc(), }, tasks: vec![warp_multi_agent_api::Task { id: Uuid::new_v4().to_string(), messages: vec![], dependencies: None, description: String::new(), summary: String::new(), server_data: String::new(), }], } } fn start_parent_conversation( panes: &PaneGroup, parent_pane_id: PaneId, ctx: &mut ViewContext, ) -> AIConversationId { let parent_terminal_view_id = panes .terminal_view_from_pane_id(parent_pane_id, ctx) .expect("parent pane should have a terminal view") .id(); start_parent_conversation_for_terminal_view(parent_terminal_view_id, ctx) } fn start_parent_conversation_for_terminal_view( terminal_view_id: EntityId, ctx: &mut ViewContext, ) -> AIConversationId { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model.start_new_conversation(terminal_view_id, false, false, false, ctx) }) } fn restore_conversation_for_terminal_view( terminal_view_id: EntityId, conversation: AIConversation, ctx: &mut ViewContext, ) -> AIConversationId { let conversation_id = conversation.id(); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model.restore_conversations(terminal_view_id, vec![conversation], ctx); }); conversation_id } fn restore_child_conversation_for_terminal_view( terminal_view_id: EntityId, parent_conversation_id: AIConversationId, ctx: &mut ViewContext, ) -> AIConversationId { let mut child_conversation = AIConversation::new(false, false); child_conversation.set_parent_conversation_id(parent_conversation_id); restore_conversation_for_terminal_view(terminal_view_id, child_conversation, ctx) } fn restore_child_conversation_with_task_context_for_terminal_view( terminal_view_id: EntityId, parent_conversation_id: AIConversationId, task_id: AmbientAgentTaskId, ctx: &mut ViewContext, ) -> AIConversationId { let mut child_conversation = AIConversation::new(false, false); child_conversation.set_parent_conversation_id(parent_conversation_id); child_conversation.set_task_id(task_id); restore_conversation_for_terminal_view(terminal_view_id, child_conversation, ctx) } fn restore_remote_child_conversation_for_terminal_view( terminal_view_id: EntityId, parent_conversation_id: AIConversationId, task_id: AmbientAgentTaskId, ctx: &mut ViewContext, ) -> AIConversationId { let mut child_conversation = AIConversation::new(false, false); child_conversation.set_parent_conversation_id(parent_conversation_id); child_conversation.set_task_id(task_id); child_conversation.mark_as_remote_child(); restore_conversation_for_terminal_view(terminal_view_id, child_conversation, ctx) } fn restore_child_conversation( panes: &PaneGroup, pane_id: PaneId, parent_conversation_id: AIConversationId, ctx: &mut ViewContext, ) -> AIConversationId { let terminal_view_id = panes .terminal_view_from_pane_id(pane_id, ctx) .expect("child pane should have a terminal view") .id(); restore_child_conversation_for_terminal_view(terminal_view_id, parent_conversation_id, ctx) } fn restore_child_conversation_with_task_context( panes: &PaneGroup, pane_id: PaneId, parent_conversation_id: AIConversationId, task_id: AmbientAgentTaskId, ctx: &mut ViewContext, ) -> AIConversationId { let terminal_view_id = panes .terminal_view_from_pane_id(pane_id, ctx) .expect("child pane should have a terminal view") .id(); restore_child_conversation_with_task_context_for_terminal_view( terminal_view_id, parent_conversation_id, task_id, ctx, ) } fn restore_remote_child_conversation( panes: &PaneGroup, pane_id: PaneId, parent_conversation_id: AIConversationId, task_id: AmbientAgentTaskId, ctx: &mut ViewContext, ) -> AIConversationId { let terminal_view_id = panes .terminal_view_from_pane_id(pane_id, ctx) .expect("child pane should have a terminal view") .id(); restore_remote_child_conversation_for_terminal_view( terminal_view_id, parent_conversation_id, task_id, ctx, ) } fn enter_agent_view_for_conversation( panes: &PaneGroup, pane_id: PaneId, conversation_id: AIConversationId, ctx: &mut ViewContext, ) { panes .terminal_view_from_pane_id(pane_id, ctx) .expect("pane should have a terminal view") .update(ctx, |terminal_view, ctx| { terminal_view.enter_agent_view_for_conversation( None, AgentViewEntryOrigin::RestoreExistingConversation, conversation_id, ctx, ); }); } fn create_already_fullscreen_parent_pane_data( panes: &PaneGroup, ctx: &mut ViewContext, ) -> (TerminalPane, PaneId, AIConversationId) { let (pane_data, terminal_view) = panes.create_terminal_pane_data( None, HashMap::new(), IsSharedSessionCreator::No, None, None, ctx, ); let pane_id = pane_data.terminal_pane_id().into(); let parent_conversation_id = start_parent_conversation_for_terminal_view(terminal_view.id(), ctx); let child_conversation_id = restore_child_conversation_for_terminal_view( terminal_view.id(), parent_conversation_id, ctx, ); terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.enter_agent_view_for_conversation( None, AgentViewEntryOrigin::RestoreExistingConversation, parent_conversation_id, ctx, ); }); (pane_data, pane_id, child_conversation_id) } fn request_ambient_agent_task_id_for_hidden_child( panes: &PaneGroup, child_pane_id: PaneId, ctx: &mut ViewContext, ) -> Option { let terminal_view = panes .terminal_view_from_pane_id(child_pane_id, ctx) .expect("child pane should have a terminal view"); let ai_controller = terminal_view.as_ref(ctx).ai_controller().clone(); ai_controller.update(ctx, |controller, _| controller.get_ambient_agent_task_id()) } fn ambient_child_session_state( panes: &PaneGroup, child_pane_id: PaneId, ctx: &mut ViewContext, ) -> (Option, bool, Option) { let terminal_view = panes .terminal_view_from_pane_id(child_pane_id, ctx) .expect("child pane should have a terminal view"); let terminal_view_ref = terminal_view.as_ref(ctx); let active_conversation_id = terminal_view_ref.active_conversation_id(ctx); let ambient_model = terminal_view_ref .ambient_agent_view_model() .expect("child pane should have an ambient agent model") .as_ref(ctx); ( ambient_model.task_id(), ambient_model.is_agent_running(), active_conversation_id, ) } struct PreAttachReturnsFalsePane { pane_id: PaneId, pane_configuration: ModelHandle, } impl PreAttachReturnsFalsePane { fn new(ctx: &mut ViewContext) -> Self { Self { pane_id: PaneId::dummy_pane_id(), pane_configuration: ctx.add_model(|_ctx| PaneConfiguration::new("")), } } } impl pane::PaneContent for PreAttachReturnsFalsePane { fn id(&self) -> PaneId { self.pane_id } fn pre_attach(&self, _group: &PaneGroup, _ctx: &mut ViewContext) -> bool { false } fn attach( &self, _group: &PaneGroup, _focus_handle: focus_state::PaneFocusHandle, _ctx: &mut ViewContext, ) { } fn detach( &self, _group: &PaneGroup, _detach_type: pane::DetachType, _ctx: &mut ViewContext, ) { } fn snapshot(&self, _app: &AppContext) -> LeafContents { LeafContents::GetStarted } fn has_application_focus(&self, _ctx: &mut ViewContext) -> bool { false } fn focus(&self, _ctx: &mut ViewContext) {} fn shareable_link( &self, _ctx: &mut ViewContext, ) -> Result { Ok(pane::ShareableLink::Base) } fn pane_configuration(&self) -> ModelHandle { self.pane_configuration.clone() } fn is_pane_being_dragged(&self, _ctx: &AppContext) -> bool { false } } // TODO: This test is commented out for now until we can fix it. It is flaky and sometimes hangs, causing the CI to cancel. // #[test] // #[allow(clippy::clone_on_copy)] // fn test_pane_history() { // App::test((), |mut app| async move { // let pane_group = mock_pane_group(&mut app, platform); // pane_group.update(&mut app, |panes, ctx| { // let mut entity_ids: Vec = // panes.view_id_to_session_data.keys().cloned().collect(); // let first_entity_id = entity_ids.get(0).unwrap().clone(); // // Add pane Left. // panes.add_pane(Direction::Left, ctx); // entity_ids = panes.view_id_to_session_data.keys().cloned().collect(); // entity_ids.retain(|x| *x != first_entity_id); // let second_entity_id = entity_ids.get(0).unwrap().clone(); // // Add pane Up. // panes.add_pane(Direction::Up, ctx); // entity_ids = panes.view_id_to_session_data.keys().cloned().collect(); // entity_ids.retain(|x| *x != first_entity_id && *x != second_entity_id); // let third_entity_id = entity_ids.get(0).unwrap().clone(); // assert!(panes.prev_session_id(third_entity_id).unwrap() == second_entity_id); // }) // }); // } #[test] #[allow(clippy::clone_on_copy)] fn test_pane_focus_on_close() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let first_pane_id = get_newly_created_pane_id(panes, &[]); // Add pane Left. panes.add_terminal_pane(Direction::Left, None, ctx); let second_pane_id = get_newly_created_pane_id(panes, &[first_pane_id]); assert!(panes.prev_pane_id(second_pane_id).unwrap() == first_pane_id); // Add pane Up. panes.add_terminal_pane(Direction::Up, None, ctx); let third_pane_id = get_newly_created_pane_id(panes, &[first_pane_id, second_pane_id]); // Close the third pane and check that the second pane opened is now focused. panes.close_pane(third_pane_id, ctx); assert_eq!(second_pane_id, panes.focused_pane_id(ctx)); }) }); } #[test] fn test_insert_hidden_child_agent_pane_keeps_focus_and_active_session() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let initial_tree_pane_count = panes.pane_count(); let initial_content_pane_count = panes.pane_ids().count(); let initial_visible_count = panes.visible_pane_count(); let initial_active_session = panes.active_session_id(ctx); let child_pane_id = panes.insert_terminal_pane_hidden_for_child_agent( parent_pane_id, HashMap::new(), IsSharedSessionCreator::No, ctx, ); assert_eq!(panes.pane_count(), initial_tree_pane_count); assert_eq!(panes.pane_ids().count(), initial_content_pane_count + 1); assert_eq!(panes.visible_pane_count(), initial_visible_count); assert!(panes.has_pane_id(child_pane_id.into())); assert!(!panes.panes.is_pane_in_tree(child_pane_id.into())); // The new child pane should remain off-tree and not affect visible ordering. assert_eq!(panes.pane_id_by_index(0), Some(parent_pane_id)); assert_eq!(panes.pane_id_by_index(1), None); // Creating a hidden child pane should not steal focus or active session. assert_eq!(panes.focused_pane_id(ctx), parent_pane_id); assert_eq!(panes.active_session_id(ctx), initial_active_session); }); }); } #[test] fn test_swapping_to_child_agent_from_maximized_pane_keeps_maximized_state() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); panes.add_terminal_pane(Direction::Right, None, ctx); panes.focus_pane(parent_pane_id, true, ctx); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let child = create_hidden_child_agent_conversation( panes, HiddenChildAgentConversationRequest { parent_pane_id, name: "Agent 1".to_string(), parent_conversation_id, orchestration_harness: None, env_vars: HashMap::new(), task_context: None, is_shared_session_creator: IsSharedSessionCreator::No, }, ctx, ) .expect("fresh hidden child conversation should be created"); let child_pane_id = panes .child_agent_panes .get(&child.conversation_id) .copied() .expect("fresh hidden child pane should be tracked"); panes.toggle_maximize_pane(ctx); assert!(panes.is_focused_pane_maximized(ctx)); panes.swap_active_pane_to_conversation(parent_pane_id, child.conversation_id, ctx); assert_eq!(panes.focused_pane_id(ctx), child_pane_id); assert!(panes.is_focused_pane_maximized(ctx)); assert_eq!( split_pane_state(panes, child_pane_id, ctx), SplitPaneState::InSplitPane(PaneState::Maximized), ); }); }); } #[test] fn test_insert_hidden_ambient_child_agent_pane_suppresses_details_auto_open() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let child_pane_id = panes.insert_ambient_agent_pane_hidden_for_child_agent(parent_pane_id, ctx); let terminal_view = panes .terminal_view_from_pane_id(child_pane_id, ctx) .expect("hidden ambient child pane should have a terminal view"); assert!( terminal_view .as_ref(ctx) .is_initial_conversation_details_panel_auto_open_suppressed_for_test(), "hidden ambient child panes opened from the parent orchestration UI should not \ auto-open details during environment setup or session readiness" ); }); }); } #[test] fn test_hidden_child_creation_applies_ambient_task_id_to_controller() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let task_id = new_ambient_agent_task_id(); let child = create_hidden_child_agent_conversation( panes, HiddenChildAgentConversationRequest { parent_pane_id, name: "Agent 1".to_string(), parent_conversation_id, orchestration_harness: None, env_vars: HashMap::new(), task_context: Some(HiddenChildAgentTaskContext { task_id, working_dir: None, }), is_shared_session_creator: IsSharedSessionCreator::No, }, ctx, ) .expect("fresh hidden child conversation should be created"); let child_pane_id = panes .child_agent_panes .get(&child.conversation_id) .copied() .expect("fresh hidden child pane should be tracked"); assert_eq!( request_ambient_agent_task_id_for_hidden_child(panes, child_pane_id, ctx,), Some(task_id) ); }); }); } #[test] fn test_restored_hidden_child_pane_reapplies_ambient_task_id_to_controller() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let task_id = new_ambient_agent_task_id(); let mut child_conversation = AIConversation::new(false, false); child_conversation.set_parent_conversation_id(parent_conversation_id); child_conversation.set_task_id(task_id); let child_conversation_id = child_conversation.id(); panes.create_hidden_child_agent_pane(child_conversation, parent_pane_id, ctx); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect("restored hidden child pane should be tracked"); assert_eq!( request_ambient_agent_task_id_for_hidden_child(panes, child_pane_id, ctx,), Some(task_id) ); }); }); } #[test] fn test_restored_remote_hidden_child_pane_enters_existing_ambient_session() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let task_id = new_ambient_agent_task_id(); // Fix B: inject mock cloud task data so the task-backed hydration // path resolves to the live ambient session via // `resolve_open_action` -> `OpenOrAttachAmbientAgentConversation`. // The default mock task has `is_sandbox_running = false` so it // resolves to the fallback path; we leave it at the default to // ensure the pre-Fix-B "attach to existing ambient session + // tombstone" behavior is preserved. AgentConversationsModel::handle(ctx).update(ctx, |model, _| { model.insert_task_for_test(ambient_agent_task_for_current_user(task_id)); }); let mut child_conversation = AIConversation::new(false, false); child_conversation.set_parent_conversation_id(parent_conversation_id); child_conversation.set_task_id(task_id); child_conversation.mark_as_remote_child(); let child_conversation_id = child_conversation.id(); panes.create_hidden_child_agent_pane(child_conversation, parent_pane_id, ctx); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect("restored remote hidden child pane should be tracked"); let (ambient_task_id, is_agent_running, active_conversation_id) = ambient_child_session_state(panes, child_pane_id, ctx); assert_eq!(ambient_task_id, Some(task_id)); assert!( is_agent_running, "remote child restore should view the existing ambient session" ); assert_eq!(active_conversation_id, Some(child_conversation_id)); // Fix B: the placeholder's local AIConversationId must remain the // canonical key in `child_agent_panes`. Any in-place hydration // (live attach, transcript merge, or fallback) must preserve this // key so the orchestration pill bar and topology indexes can // still find the pane. assert!( panes.child_agent_panes.contains_key(&child_conversation_id), "placeholder AIConversationId must stay the child_agent_panes key after Fix B \ hydration", ); let terminal_view = panes .terminal_view_from_pane_id(child_pane_id, ctx) .expect("remote child pane should have a terminal view"); assert!( terminal_view .as_ref(ctx) .is_initial_conversation_details_panel_auto_open_suppressed_for_test(), "remote child panes opened from the parent orchestration UI should not auto-open \ details when the ambient session becomes ready" ); }); }); } /// Fix B: when task data for a restored remote child is NOT yet cached at /// `create_hidden_child_agent_pane` time, the placeholder must still be /// registered in `child_agent_panes` keyed by its local AIConversationId, /// attached to the live ambient session (preserving today's behavior so /// streaming runs continue to attach), AND deferred via /// `pending_remote_child_hydrations` so the subscription handler can retry /// when `TasksUpdated` / `ConversationsLoaded` fires. The pane group must /// never produce a worse state than the pre-Fix-B fallback. #[test] fn test_restored_remote_hidden_child_pane_fallback_when_task_data_unavailable() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let task_id = new_ambient_agent_task_id(); // Deliberately do NOT inject a task into AgentConversationsModel. // `get_or_async_fetch_task_data` will return `None`, which forces // the hydration to: // 1. enter the existing ambient session in place (live-attach // preserved per Fix B contract); // 2. register a pending hydration entry so the subscription // handler can retry once task data lands. let mut child_conversation = AIConversation::new(false, false); child_conversation.set_parent_conversation_id(parent_conversation_id); child_conversation.set_task_id(task_id); child_conversation.mark_as_remote_child(); let child_conversation_id = child_conversation.id(); panes.create_hidden_child_agent_pane(child_conversation, parent_pane_id, ctx); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect("remote child pane must be registered even when task data unavailable"); // The placeholder local AIConversationId remains the canonical key. assert!( panes.child_agent_panes.contains_key(&child_conversation_id), "placeholder AIConversationId must stay the child_agent_panes key in fallback path", ); // Live-attach preserved: the ambient agent view model is // configured to view the existing session for `task_id`. This // matches the pre-Fix-B behavior so streaming runs continue to // attach. let (ambient_task_id, _is_agent_running, active_conversation_id) = ambient_child_session_state(panes, child_pane_id, ctx); assert_eq!( ambient_task_id, Some(task_id), "fallback path must still call enter_viewing_existing_session on the placeholder", ); assert_eq!(active_conversation_id, Some(child_conversation_id)); // Fix B: pending hydration is recorded so the subscription handler // can re-run hydration when task data lands. The map value is the // placeholder's local AIConversationId, which must match the // conversation we just restored. let pending_placeholder_id = panes.pending_remote_child_hydrations.get(&task_id).copied().expect( "task-data-unavailable hydration must register a pending entry keyed by task id", ); assert_eq!( pending_placeholder_id, child_conversation_id, "pending hydration must record the placeholder's local AIConversationId", ); }); }); } /// Phase 1 integration coverage: validates that after `BlocklistAIHistoryModel` /// restoration (the same code path the disk-load Fix C unblocks), the /// orchestration topology is fully wired BEFORE the parent's fullscreen /// agent view is entered, AND that entering fullscreen lazily materializes /// the hidden child pane keyed by the placeholder local AIConversationId. /// /// This is the integration boundary the user-visible bug lives at: /// * pill bar / transcript name resolution must succeed before the /// parent fullscreen entry (Fix C eagerly hydrates `conversations_by_id` /// so this works on disk-load; this test exercises the equivalent /// restore-into-history-model + lazy pane materialization flow). /// * the hidden child pane must materialize in `child_agent_panes` keyed /// by the placeholder conversation id after parent fullscreen. /// /// The disk-load construction path (`BlocklistAIHistoryModel::new(_, &conversations)` /// invoking `initialize_historical_conversations`) is covered by /// `test_initialize_historical_conversations_eagerly_hydrates_orchestration_children` /// in `app/src/ai/blocklist/history_model_tests.rs`. `agent_display_name_from_id` /// resolution for restored children is covered by /// `participant_for_restored_child_run_id_resolves_to_agent_name` in /// `app/src/ai/blocklist/block/view_impl/orchestration_tests.rs`. The pill /// bar data-layer coverage is in /// `pill_bar_data_layer_finds_restored_children_before_pane_creation` in /// `app/src/ai/blocklist/agent_view/orchestration_pill_bar_tests.rs`. This /// test ties those three boundaries together at the PaneGroup integration /// layer. #[test] fn test_pane_group_restore_loop_keeps_orchestration_topology_and_materializes_child_pane() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); let ( parent_pane_id, parent_conversation_id, parent_run_id, child_conversation_id, child_run_id, child_agent_name, ) = pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_terminal_view_id = panes .terminal_view_from_pane_id(parent_pane_id, ctx) .expect("parent pane should have a terminal view") .id(); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let parent_run_id = new_ambient_agent_task_id().to_string(); let child_run_id = new_ambient_agent_task_id().to_string(); let child_agent_name = "Agent 1".to_string(); // Restore a child conversation into the parent's terminal view. This // is the same code path `RestoredAgentConversations::take_conversations` // feeds into during pane restoration. Fix C ensures the equivalent // wiring happens earlier (at history-model construction) so the data // is also available before any terminal view materializes the parent. let mut child_conversation = AIConversation::new(false, false); child_conversation.set_parent_conversation_id(parent_conversation_id); child_conversation.set_agent_name(child_agent_name.clone()); let child_conversation_id = child_conversation.id(); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.restore_conversations( parent_terminal_view_id, vec![child_conversation], ctx, ); // Stamp run_ids so orchestration agent_id lookups resolve. history.assign_run_id_for_conversation( parent_conversation_id, parent_run_id.clone(), None, parent_terminal_view_id, ctx, ); history.assign_run_id_for_conversation( child_conversation_id, child_run_id.clone(), None, parent_terminal_view_id, ctx, ); }); ( parent_pane_id, parent_conversation_id, parent_run_id, child_conversation_id, child_run_id, child_agent_name, ) }); // BEFORE the parent's fullscreen agent view is entered, the // orchestration data layer must already know: // (a) the parent → child topology (pill bar source), // (b) the child's local conversation (with agent name set), and // (c) the child's run_id → conversation id (transcript name // resolution source via `conversation_id_for_agent_id`). pane_group.read(&app, |panes, ctx| { let history = BlocklistAIHistoryModel::as_ref(ctx); // (a) Topology — direct children index and the transitive walker // used by `OrchestrationPillBar::pill_specs` must both find the // child immediately, even though the hidden child pane has not // been created yet. assert_eq!( history.child_conversation_ids_of(&parent_conversation_id), &[child_conversation_id], "orchestration topology must list the restored child under its parent before any pane materializes", ); assert_eq!( descendant_conversation_ids_in_spawn_order(history, parent_conversation_id), vec![child_conversation_id], "pill bar pre-order walker must reach the restored child before any pane materializes", ); // (b) The child must be hydrated into `conversations_by_id` // with its agent name preserved — this is the data Fix C // eagerly populates on disk-load so the transcript name // resolver finds the display name instead of falling back to // "Unknown agent". let child_conversation = history .conversation(&child_conversation_id) .expect("restored child must be in conversations_by_id before parent fullscreen"); assert_eq!( child_conversation.agent_name(), Some(child_agent_name.as_str()), "restored child must retain its display name for transcript / pill bar rendering", ); // (c) Run-id → conversation lookups (used by `participant_for_agent_id`). assert_eq!( history.conversation_id_for_agent_id(&child_run_id), Some(child_conversation_id), "child run_id must resolve to the restored child conversation", ); assert_eq!( history.conversation_id_for_agent_id(&parent_run_id), Some(parent_conversation_id), "parent run_id must resolve to the parent conversation", ); // Hidden child pane must NOT exist yet — restoration is lazy and // only materializes when the parent's agent view is entered. assert!( !panes.child_agent_panes.contains_key(&child_conversation_id), "hidden child pane must not exist before parent fullscreen entry", ); }); // Enter the parent's fullscreen agent view. This is the trigger for // `restore_missing_child_agent_panes_for_parent`, which is the // PaneGroup-side of the user-visible restart-loop bug. pane_group.update(&mut app, |panes, ctx| { enter_agent_view_for_conversation(panes, parent_pane_id, parent_conversation_id, ctx); }); // AFTER fullscreen entry, the hidden child pane must materialize in // `child_agent_panes` keyed by the placeholder local AIConversationId. pane_group.read(&app, |panes, _ctx| { let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect("parent fullscreen entry must materialize the hidden child pane"); assert!( panes.has_pane_id(child_pane_id), "materialized child pane must be tracked by the pane group", ); assert!( !panes.panes.is_pane_in_tree(child_pane_id), "materialized child pane must remain off-tree (hidden)", ); }); }); } #[test] fn test_create_missing_child_agent_panes_restores_remote_child_from_history_model() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let child_conversation_id = AIConversationId::new(); let task_id = new_ambient_agent_task_id(); assert!( !panes.child_agent_panes.contains_key(&child_conversation_id), "child pane should not exist before startup restoration runs", ); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| { history_model .set_parent_for_conversation(child_conversation_id, parent_conversation_id); }); RestoredAgentConversations::handle(ctx).update(ctx, |store, _| { *store = RestoredAgentConversations::new(vec![persisted_remote_child_conversation( child_conversation_id, Some(parent_conversation_id), None, task_id, )]); }); panes.restore_missing_child_agent_panes_for_parent( parent_conversation_id, parent_pane_id, ctx, ); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect("startup restoration should recreate the remote child pane"); let (ambient_task_id, is_agent_running, active_conversation_id) = ambient_child_session_state(panes, child_pane_id, ctx); assert_eq!(ambient_task_id, Some(task_id)); assert!( is_agent_running, "restored remote child pane should reconnect to the ambient session", ); assert_eq!(active_conversation_id, Some(child_conversation_id)); assert_eq!(panes.focused_pane_id(ctx), parent_pane_id); }); }); } #[test] fn test_ambient_transcript_restore_creates_cloud_mode_pane_when_handoff_enabled() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true); let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(true); let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); let task_id = new_ambient_agent_task_id(); pane_group.update(&mut app, |panes, ctx| { AgentConversationsModel::handle(ctx).update(ctx, |model, _| { model.insert_task_for_test(ambient_agent_task_for_current_user(task_id)); }); panes.load_data_into_conversation_transcript_viewer( cloud_conversation_with_ambient_task(task_id), Some(task_id), ctx, ); }); pane_group.read(&app, |panes, ctx| { let terminal_view = panes .active_session_view(ctx) .expect("restored pane should have an active terminal view"); let view = terminal_view.as_ref(ctx); let ambient_model = view .ambient_agent_view_model() .expect("ambient restore should create a Cloud Mode view") .as_ref(ctx); assert_eq!(ambient_model.task_id(), Some(task_id)); assert!(ambient_model.is_agent_running()); assert_eq!( view.ambient_agent_task_id_for_details_panel(ctx), Some(task_id) ); assert!(view.active_conversation_id(ctx).is_some()); let model = view.model.lock(); assert!(!model.is_conversation_transcript_viewer()); assert!(!model.is_read_only()); assert!(matches!( model.shared_session_status(), SharedSessionStatus::NotShared )); }); }); } #[test] fn test_ambient_transcript_restore_uses_generic_viewer_when_handoff_disabled() { let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(false); let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); let task_id = new_ambient_agent_task_id(); pane_group.update(&mut app, |panes, ctx| { panes.load_data_into_conversation_transcript_viewer( cloud_conversation_with_ambient_task(task_id), Some(task_id), ctx, ); }); pane_group.read(&app, |panes, ctx| { let terminal_view = panes .active_session_view(ctx) .expect("fallback viewer should have an active terminal view"); let view = terminal_view.as_ref(ctx); assert!(view.ambient_agent_view_model().is_none()); let model = view.model.lock(); assert!(model.is_conversation_transcript_viewer()); assert!(model.is_read_only()); assert_eq!( model.conversation_transcript_viewer_status(), Some(&ConversationTranscriptViewerStatus::ViewingAmbientConversation(task_id)) ); }); }); } /// Pins the contract that cloud-mode shared-session viewers (the local pane /// of a remote orchestration parent) get an `ambient_agent_view_model` so /// the snapshot path in `TerminalPane::snapshot` can emit /// `LeafContents::AmbientAgent` with the task id preserved. Without this, /// the snapshot falls through to an empty `LeafContents::Terminal` and the /// pane restores as a stray local terminal on the next launch. #[test] fn create_shared_session_viewer_with_cloud_mode_populates_ambient_agent_view_model() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let resources = TerminalViewResources { tips_completed: panes.tips_completed.clone(), server_api: panes.server_api.clone(), model_event_sender: panes.model_event_sender.clone(), }; let (terminal_view, _terminal_manager) = PaneGroup::create_shared_session_viewer( SessionId::new(), resources, Vector2F::new(800., 600.), false, // enable_orchestration_polling true, // is_cloud_mode ctx, ); assert!( terminal_view.as_ref(ctx).ambient_agent_view_model().is_some(), "cloud-mode shared-session viewer must construct an ambient_agent_view_model so the snapshot path emits LeafContents::AmbientAgent on restart", ); }); }); } /// Pins the existing behavior of the non-cloud-mode branch so callers that /// rely on it (e.g. `new_for_shared_session_viewer`, the per-child viewer /// path) keep getting a `TerminalView` without an `ambient_agent_view_model`. /// Future changes that would flip this default are loud. #[test] fn create_shared_session_viewer_without_cloud_mode_does_not_populate_ambient_agent_view_model() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let resources = TerminalViewResources { tips_completed: panes.tips_completed.clone(), server_api: panes.server_api.clone(), model_event_sender: panes.model_event_sender.clone(), }; let (terminal_view, _terminal_manager) = PaneGroup::create_shared_session_viewer( SessionId::new(), resources, Vector2F::new(800., 600.), false, // enable_orchestration_polling false, // is_cloud_mode ctx, ); assert!( terminal_view.as_ref(ctx).ambient_agent_view_model().is_none(), "non-cloud-mode shared-session viewer must not construct an ambient_agent_view_model; existing callers depend on this", ); }); }); } #[test] fn test_entering_parent_agent_view_lazily_restores_hidden_child_pane() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); let (child_conversation_id, initial_pane_count, initial_visible_pane_count) = pane_group .update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let child_conversation_id = restore_child_conversation(panes, parent_pane_id, parent_conversation_id, ctx); let initial_pane_count = panes.pane_count(); let initial_visible_pane_count = panes.visible_pane_count(); assert!(!panes.child_agent_panes.contains_key(&child_conversation_id)); enter_agent_view_for_conversation( panes, parent_pane_id, parent_conversation_id, ctx, ); ( child_conversation_id, initial_pane_count, initial_visible_pane_count, ) }); pane_group.update(&mut app, |panes, _ctx| { let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect("parent fullscreen restore should materialize the missing child pane"); assert!(panes.has_pane_id(child_pane_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert_eq!(panes.visible_pane_count(), initial_visible_pane_count); assert!(!panes.panes.is_pane_in_tree(child_pane_id)); }); }); } #[test] fn test_entering_remote_parent_agent_view_lazily_restores_local_hidden_child_pane() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); let ( parent_pane_id, local_child_conversation_id, local_child_task_id, initial_pane_count, initial_visible_pane_count, ) = pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let root_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let remote_parent_task_id = new_ambient_agent_task_id(); let remote_parent_conversation_id = restore_remote_child_conversation( panes, parent_pane_id, root_conversation_id, remote_parent_task_id, ctx, ); let local_child_task_id = new_ambient_agent_task_id(); let local_child_conversation_id = restore_child_conversation_with_task_context( panes, parent_pane_id, remote_parent_conversation_id, local_child_task_id, ctx, ); let initial_pane_count = panes.pane_count(); let initial_visible_pane_count = panes.visible_pane_count(); assert!(!panes .child_agent_panes .contains_key(&local_child_conversation_id)); enter_agent_view_for_conversation( panes, parent_pane_id, remote_parent_conversation_id, ctx, ); ( parent_pane_id, local_child_conversation_id, local_child_task_id, initial_pane_count, initial_visible_pane_count, ) }); pane_group.update(&mut app, |panes, ctx| { let child_pane_id = panes .child_agent_panes .get(&local_child_conversation_id) .copied() .expect( "remote parent fullscreen restore should materialize the missing local child pane", ); assert!(panes.has_pane_id(child_pane_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert_eq!(panes.visible_pane_count(), initial_visible_pane_count); assert!(!panes.panes.is_pane_in_tree(child_pane_id)); assert_eq!(panes.focused_pane_id(ctx), parent_pane_id); assert_eq!( request_ambient_agent_task_id_for_hidden_child( panes, child_pane_id, ctx, ), Some(local_child_task_id) ); }); }); } #[test] fn test_entering_remote_parent_agent_view_lazily_restores_remote_hidden_child_pane() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); let ( parent_pane_id, remote_child_conversation_id, remote_child_task_id, initial_pane_count, initial_visible_pane_count, ) = pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let root_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let remote_parent_task_id = new_ambient_agent_task_id(); let remote_parent_conversation_id = restore_remote_child_conversation( panes, parent_pane_id, root_conversation_id, remote_parent_task_id, ctx, ); let remote_child_task_id = new_ambient_agent_task_id(); let remote_child_conversation_id = restore_remote_child_conversation( panes, parent_pane_id, remote_parent_conversation_id, remote_child_task_id, ctx, ); let initial_pane_count = panes.pane_count(); let initial_visible_pane_count = panes.visible_pane_count(); assert!(!panes .child_agent_panes .contains_key(&remote_child_conversation_id)); enter_agent_view_for_conversation( panes, parent_pane_id, remote_parent_conversation_id, ctx, ); ( parent_pane_id, remote_child_conversation_id, remote_child_task_id, initial_pane_count, initial_visible_pane_count, ) }); pane_group.update(&mut app, |panes, ctx| { let child_pane_id = panes .child_agent_panes .get(&remote_child_conversation_id) .copied() .expect( "remote parent fullscreen restore should materialize the missing remote child pane", ); let (ambient_task_id, is_agent_running, active_conversation_id) = ambient_child_session_state(panes, child_pane_id, ctx); assert!(panes.has_pane_id(child_pane_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert_eq!(panes.visible_pane_count(), initial_visible_pane_count); assert!(!panes.panes.is_pane_in_tree(child_pane_id)); assert_eq!(panes.focused_pane_id(ctx), parent_pane_id); assert_eq!(ambient_task_id, Some(remote_child_task_id)); assert!( is_agent_running, "remote child restore should reconnect to the existing ambient session", ); assert_eq!(active_conversation_id, Some(remote_child_conversation_id)); }); }); } #[test] fn test_add_pane_restores_hidden_child_when_parent_is_already_fullscreen() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let initial_pane_count = panes.pane_count(); let initial_visible_pane_count = panes.visible_pane_count(); let (pane_data, parent_pane_id, child_conversation_id) = create_already_fullscreen_parent_pane_data(panes, ctx); panes.add_pane_with_direction(Direction::Right, pane_data, true, ctx); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect("adding an already-fullscreen parent should materialize the child pane"); assert!(panes.has_pane_id(parent_pane_id)); assert!(panes.has_pane_id(child_pane_id)); assert_eq!(panes.pane_count(), initial_pane_count + 1); assert_eq!(panes.visible_pane_count(), initial_visible_pane_count + 1); assert!(!panes.panes.is_pane_in_tree(child_pane_id)); assert_eq!(panes.focused_pane_id(ctx), parent_pane_id); assert_eq!( panes.pane_id_for_owned_conversation(child_conversation_id, ctx), Some(child_pane_id) ); }); }); } #[test] fn test_reattach_panes_restores_hidden_child_when_parent_is_already_fullscreen() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let child_conversation_id = restore_child_conversation(panes, parent_pane_id, parent_conversation_id, ctx); let initial_pane_count = panes.pane_count(); let initial_visible_pane_count = panes.visible_pane_count(); panes.detach_panes(ctx); enter_agent_view_for_conversation(panes, parent_pane_id, parent_conversation_id, ctx); assert!(!panes.child_agent_panes.contains_key(&child_conversation_id)); panes.reattach_panes(ctx); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect( "reattaching an already-fullscreen parent should materialize the child pane", ); assert!(panes.has_pane_id(child_pane_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert_eq!(panes.visible_pane_count(), initial_visible_pane_count); assert!(!panes.panes.is_pane_in_tree(child_pane_id)); assert_eq!( panes.pane_id_for_owned_conversation(child_conversation_id, ctx), Some(child_pane_id) ); }); }); } #[test] fn test_restore_closed_pane_restores_hidden_child_when_parent_is_already_fullscreen() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); let _undo_closed_panes = FeatureFlag::UndoClosedPanes.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); panes.add_pane_with_direction( Direction::Right, NotebookPane::new(new_notebook(ctx), ctx), false, ctx, ); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let child_conversation_id = restore_child_conversation(panes, parent_pane_id, parent_conversation_id, ctx); let initial_pane_count = panes.pane_count(); let initial_visible_pane_count = panes.visible_pane_count(); panes.close_pane(parent_pane_id, ctx); assert!(panes.is_pane_hidden_for_close(parent_pane_id)); enter_agent_view_for_conversation(panes, parent_pane_id, parent_conversation_id, ctx); assert!(!panes.child_agent_panes.contains_key(&child_conversation_id)); assert!(panes.restore_closed_pane(parent_pane_id, ctx)); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect( "restoring an already-fullscreen closed parent should materialize the child pane", ); assert!(panes.has_pane_id(child_pane_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert_eq!(panes.visible_pane_count(), initial_visible_pane_count); assert!(!panes.panes.is_pane_in_tree(child_pane_id)); assert_eq!(panes.focused_pane_id(ctx), parent_pane_id); assert_eq!( panes.pane_id_for_owned_conversation(child_conversation_id, ctx), Some(child_pane_id) ); }); }); } #[test] fn test_replace_pane_restores_hidden_child_when_replacement_is_already_fullscreen() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let original_pane_id = get_newly_created_pane_id(panes, &[]); let initial_pane_count = panes.pane_count(); let initial_visible_pane_count = panes.visible_pane_count(); let (replacement_pane, replacement_pane_id, child_conversation_id) = create_already_fullscreen_parent_pane_data(panes, ctx); assert!(panes.replace_pane(original_pane_id, replacement_pane, false, ctx)); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect( "replacing with an already-fullscreen parent should materialize the child pane", ); assert!(!panes.has_pane_id(original_pane_id)); assert!(panes.has_pane_id(replacement_pane_id)); assert!(panes.has_pane_id(child_pane_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert_eq!(panes.visible_pane_count(), initial_visible_pane_count); assert!(!panes.panes.is_pane_in_tree(child_pane_id)); assert_eq!(panes.focused_pane_id(ctx), replacement_pane_id); assert_eq!( panes.pane_id_for_owned_conversation(child_conversation_id, ctx), Some(child_pane_id) ); }); }); } #[test] fn test_ensure_hidden_child_agent_pane_materializes_missing_child_pane() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let child_conversation_id = restore_child_conversation(panes, parent_pane_id, parent_conversation_id, ctx); let initial_pane_count = panes.pane_count(); assert!(!panes.child_agent_panes.contains_key(&child_conversation_id)); assert!( panes.ensure_hidden_child_agent_pane_for_conversation(child_conversation_id, ctx), "navigation fallback should materialize the missing child pane on demand" ); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect("on-demand ensure should track the restored child pane"); assert!(panes.has_pane_id(child_pane_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert!(!panes.panes.is_pane_in_tree(child_pane_id)); }); }); } #[test] fn test_ensure_hidden_child_agent_pane_materializes_restored_remote_child_linked_by_parent_agent_id( ) { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_terminal_view_id = panes .terminal_view_from_pane_id(parent_pane_id, ctx) .expect("parent pane should have a terminal view") .id(); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let child_conversation_id = AIConversationId::new(); let parent_run_id = new_ambient_agent_task_id().to_string(); let task_id = new_ambient_agent_task_id(); let initial_pane_count = panes.pane_count(); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { history_model.assign_run_id_for_conversation( parent_conversation_id, parent_run_id.clone(), None, parent_terminal_view_id, ctx, ); history_model .set_parent_for_conversation(child_conversation_id, parent_conversation_id); }); RestoredAgentConversations::handle(ctx).update(ctx, |store, _| { *store = RestoredAgentConversations::new(vec![persisted_remote_child_conversation( child_conversation_id, None, Some(parent_run_id), task_id, )]); }); assert!(!panes.child_agent_panes.contains_key(&child_conversation_id)); assert!( panes.ensure_hidden_child_agent_pane_for_conversation(child_conversation_id, ctx), "navigation fallback should restore a parent_agent_id-linked remote child pane", ); let child_pane_id = panes .child_agent_panes .get(&child_conversation_id) .copied() .expect("parent_agent_id-linked child pane should be tracked after restoration"); let (ambient_task_id, is_agent_running, active_conversation_id) = ambient_child_session_state(panes, child_pane_id, ctx); assert!(panes.has_pane_id(child_pane_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert!(!panes.panes.is_pane_in_tree(child_pane_id)); assert_eq!(ambient_task_id, Some(task_id)); assert!( is_agent_running, "restored remote child pane should reconnect to the ambient session", ); assert_eq!(active_conversation_id, Some(child_conversation_id)); }); }); } #[test] fn test_entering_parent_agent_view_skips_child_owned_by_another_pane() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); let (child_conversation_id, initial_pane_count) = pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); panes.add_terminal_pane(Direction::Right, None, ctx); let sibling_pane_id = get_newly_created_pane_id(panes, &[parent_pane_id]); let child_conversation_id = restore_child_conversation(panes, sibling_pane_id, parent_conversation_id, ctx); let initial_pane_count = panes.pane_count(); enter_agent_view_for_conversation( panes, sibling_pane_id, child_conversation_id, ctx, ); assert_eq!( panes.pane_id_for_owned_conversation(child_conversation_id, ctx), Some(sibling_pane_id) ); enter_agent_view_for_conversation( panes, parent_pane_id, parent_conversation_id, ctx, ); (child_conversation_id, initial_pane_count) }); pane_group.update(&mut app, |panes, _ctx| { assert!(!panes.child_agent_panes.contains_key(&child_conversation_id)); assert_eq!(panes.pane_count(), initial_pane_count); }); }); } #[test] fn test_ensure_hidden_child_agent_pane_skips_child_owned_by_another_pane_group() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let parent_pane_group = mock_pane_group(&mut app, Default::default()); let other_pane_group = mock_pane_group(&mut app, Default::default()); let parent_conversation_id = parent_pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); start_parent_conversation(panes, parent_pane_id, ctx) }); let (child_conversation_id, child_owner_terminal_view_id) = other_pane_group.update(&mut app, |panes, ctx| { let child_pane_id = get_newly_created_pane_id(panes, &[]); let child_conversation_id = restore_child_conversation(panes, child_pane_id, parent_conversation_id, ctx); let initial_owner_terminal_view_id = panes .terminal_view_from_pane_id(child_pane_id, ctx) .expect("child pane should have a terminal view") .id(); enter_agent_view_for_conversation(panes, child_pane_id, child_conversation_id, ctx); (child_conversation_id, initial_owner_terminal_view_id) }); parent_pane_group.update(&mut app, |panes, ctx| { let initial_pane_count = panes.pane_count(); assert!( panes.ensure_hidden_child_agent_pane_for_conversation(child_conversation_id, ctx), "cross-tab child ownership should be treated as already reachable" ); assert!(!panes.child_agent_panes.contains_key(&child_conversation_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert_eq!( BlocklistAIHistoryModel::as_ref(ctx) .terminal_surface_id_for_conversation(&child_conversation_id), Some(child_owner_terminal_view_id) ); }); }); } #[test] fn test_entering_parent_agent_view_skips_child_owned_by_another_pane_group() { let _agent_view = FeatureFlag::AgentView.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let parent_pane_group = mock_pane_group(&mut app, Default::default()); let other_pane_group = mock_pane_group(&mut app, Default::default()); let (parent_conversation_id, parent_pane_id) = parent_pane_group.update(&mut app, |panes, ctx| { let parent_pane_id = get_newly_created_pane_id(panes, &[]); let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); (parent_conversation_id, parent_pane_id) }); let (child_conversation_id, child_owner_terminal_view_id) = other_pane_group.update(&mut app, |panes, ctx| { let child_pane_id = get_newly_created_pane_id(panes, &[]); let child_conversation_id = restore_child_conversation(panes, child_pane_id, parent_conversation_id, ctx); let initial_owner_terminal_view_id = panes .terminal_view_from_pane_id(child_pane_id, ctx) .expect("child pane should have a terminal view") .id(); enter_agent_view_for_conversation(panes, child_pane_id, child_conversation_id, ctx); (child_conversation_id, initial_owner_terminal_view_id) }); let initial_pane_count = parent_pane_group.update(&mut app, |panes, ctx| { let initial_pane_count = panes.pane_count(); enter_agent_view_for_conversation(panes, parent_pane_id, parent_conversation_id, ctx); initial_pane_count }); parent_pane_group.update(&mut app, |panes, ctx| { assert!(!panes.child_agent_panes.contains_key(&child_conversation_id)); assert_eq!(panes.pane_count(), initial_pane_count); assert_eq!( BlocklistAIHistoryModel::as_ref(ctx) .terminal_surface_id_for_conversation(&child_conversation_id), Some(child_owner_terminal_view_id) ); }); }); } #[test] fn test_active_session_id_reset_on_last_pane_close() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let terminal_id = get_newly_created_pane_id(panes, &[]); assert_eq!( panes.active_session_id(ctx), terminal_id.as_terminal_pane_id() ); // Add a non-terminal pane (Notebook) so the pane group remains alive when terminal is closed. panes.add_pane_with_direction( Direction::Right, NotebookPane::new(new_notebook(ctx), ctx), false, /* focus_new_pane */ ctx, ); // Close the terminal. panes.close_pane(terminal_id, ctx); // active_session_id should be None after closing the last pane. assert_eq!( panes.active_session_id(ctx), None, "active_session_id should be None after closing the last pane" ); }); }); } #[test] fn test_add_pane_aborts_cleanly_when_pre_attach_returns_false() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let before_snapshot = panes.snapshot(ctx); let before_count = panes.pane_count(); panes.add_pane_with_direction( Direction::Right, PreAttachReturnsFalsePane::new(ctx), true, /* focus_new_pane */ ctx, ); assert_eq!(panes.pane_count(), before_count); assert_eq!(panes.snapshot(ctx), before_snapshot); }); }); } #[test] fn test_focus_notebook() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let first_terminal_id = get_newly_created_pane_id(panes, &[]); // Add a notebook to the left. panes.add_pane_with_direction( Direction::Left, NotebookPane::new(new_notebook(ctx), ctx), true, /* focus_new_pane */ ctx, ); let notebook_id = get_newly_created_pane_id(panes, &[first_terminal_id]); // The new pane should be focused, but the terminal is still the active session. assert_eq!(panes.focused_pane_id(ctx), notebook_id); assert_eq!( panes.active_session_id(ctx).map(Into::into), Some(first_terminal_id) ); assert_eq!( split_pane_state(panes, first_terminal_id, ctx), SplitPaneState::InSplitPane(PaneState::Unfocused) ); assert!(is_active_session(panes, first_terminal_id, ctx)); assert_eq!( split_pane_state(panes, notebook_id, ctx), SplitPaneState::InSplitPane(PaneState::Focused) ); // Add a terminal below. panes.add_terminal_pane(Direction::Down, None, ctx); let second_terminal_id = get_newly_created_pane_id(panes, &[first_terminal_id, notebook_id]); // The new terminal should be both focused and the active session. assert_eq!(panes.focused_pane_id(ctx), second_terminal_id); assert_eq!( panes.active_session_id(ctx).map(Into::into), Some(second_terminal_id) ); assert_eq!( split_pane_state(panes, first_terminal_id, ctx), SplitPaneState::InSplitPane(PaneState::Unfocused) ); assert!(!is_active_session(panes, first_terminal_id, ctx)); assert_eq!( split_pane_state(panes, second_terminal_id, ctx), SplitPaneState::InSplitPane(PaneState::Focused) ); assert!(is_active_session(panes, second_terminal_id, ctx)); assert_eq!( split_pane_state(panes, notebook_id, ctx), SplitPaneState::InSplitPane(PaneState::Unfocused) ); // Close the new terminal. Focus should switch to the notebook, and the first terminal // session will activate. panes.close_pane(second_terminal_id, ctx); assert_eq!(panes.focused_pane_id(ctx), notebook_id); assert_eq!( panes.active_session_id(ctx).map(Into::into), Some(first_terminal_id) ); assert_eq!( split_pane_state(panes, first_terminal_id, ctx), SplitPaneState::InSplitPane(PaneState::Unfocused) ); assert_eq!( split_pane_state(panes, notebook_id, ctx), SplitPaneState::InSplitPane(PaneState::Focused) ); assert!(is_active_session(panes, first_terminal_id, ctx)); }) }); } #[test] fn test_group_without_terminals() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let terminal_id = get_newly_created_pane_id(panes, &[]); // Add a notebook to the left. panes.add_pane_with_direction( Direction::Left, NotebookPane::new(new_notebook(ctx), ctx), true, /* focus_new_pane */ ctx, ); let notebook_id = get_newly_created_pane_id(panes, &[terminal_id]); // Close the terminal, which should leave the group without an active session. panes.close_pane(terminal_id, ctx); assert_eq!(panes.focused_pane_id(ctx), notebook_id); assert_eq!(panes.active_session_id(ctx), None); assert_eq!( split_pane_state(panes, notebook_id, ctx), SplitPaneState::NotInSplitPane ); }); }); } #[test] fn test_close_active_session() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { // Add two terminal sessions. let first_terminal_id = get_newly_created_pane_id(panes, &[]); panes.add_terminal_pane(Direction::Up, None, ctx); let second_terminal_id = get_newly_created_pane_id(panes, &[first_terminal_id]); // Add a notebook to the left. panes.add_pane_with_direction( Direction::Left, NotebookPane::new(new_notebook(ctx), ctx), true, /* focus_new_pane */ ctx, ); let notebook_id = get_newly_created_pane_id(panes, &[first_terminal_id, second_terminal_id]); assert_eq!(panes.focused_pane_id(ctx), notebook_id); assert_eq!( panes.active_session_id(ctx).map(Into::into), Some(second_terminal_id) ); // Close the active session, which should leave the notebook focused and activate the // remaining session. panes.close_pane(second_terminal_id, ctx); assert_eq!(panes.focused_pane_id(ctx), notebook_id); assert_eq!( panes.active_session_id(ctx).map(Into::into), Some(first_terminal_id) ); assert_eq!( split_pane_state(panes, first_terminal_id, ctx), SplitPaneState::InSplitPane(PaneState::Unfocused) ); assert!(is_active_session(panes, first_terminal_id, ctx)); // Now, focus the remaining session, which should keep it activated. panes.focus_pane_by_id(first_terminal_id, ctx); assert_eq!(panes.focused_pane_id(ctx), first_terminal_id); assert_eq!( panes.active_session_id(ctx).map(Into::into), Some(first_terminal_id) ); assert_eq!( split_pane_state(panes, first_terminal_id, ctx), SplitPaneState::InSplitPane(PaneState::Focused) ); assert_eq!( split_pane_state(panes, notebook_id, ctx), SplitPaneState::InSplitPane(PaneState::Unfocused) ); assert!(is_active_session(panes, first_terminal_id, ctx)); }); }); } #[test] fn test_update_session_visibility() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { // Assert that there is no active window. WindowManager::handle(ctx).read(ctx, |state, _| { assert_eq!(state.stage(), ApplicationStage::Starting); assert!(state.active_window().is_none()); }); fn visibility_matches(panes: &PaneGroup, expected: bool, ctx: &ViewContext) { for data in panes.panes_of::() { let view = data.terminal_view(ctx).as_ref(ctx); assert_eq!( view.was_ever_visible(), expected, "View {} visibility was {}, expected {}", data.terminal_view(ctx).id(), view.was_ever_visible(), expected ); } } // Add pane Left. panes.add_terminal_pane(Direction::Left, None, ctx); // Assert that neither of the panes are marked as visible (due // to the fact that the window is not active). visibility_matches(panes, false, ctx); let window_id = ctx.window_id(); WindowManager::handle(ctx).update(ctx, |state, ctx| { state.overwrite_for_test(ApplicationStage::Active, Some(window_id)); ctx.notify(); }); // Assert that both of the panes are still not marked as // visible, given the fact that the pane group is not focused. visibility_matches(panes, false, ctx); panes.focus(ctx); // Assert that both of the panes are now visible. visibility_matches(panes, true, ctx); }) }); } #[test] fn test_initial_widths_are_computed_correctly() { use launch_config::PaneTemplateType::*; App::test((), |mut app| async move { initialize_app(&mut app); // Define a simple macro to help us create new leaf panes. macro_rules! leaf_pane { () => { PaneTemplate { is_focused: None, cwd: "".into(), commands: vec![], pane_mode: PaneMode::Terminal, shell: None, } }; } // Pick an arbitrary initial window that isn't the same as the // fallback value. let window_width = 864.; let window_height = 636.; assert_ne!(window_width, FALLBACK_INITIAL_WINDOW_SIZE.x()); assert_ne!(window_height, FALLBACK_INITIAL_WINDOW_SIZE.y()); // Create a template that looks like the following, with each pane // numbered by its index in the pane group: // // --------------------- // | 0 | // | __________________| // | 1 |____2____| // | ________|____3____| // | 4 | 5 | 6 | // | | | | // --------------------- let template = PaneBranchTemplate { split_direction: launch_config::SplitDirection::Vertical, panes: vec![ leaf_pane!(), PaneBranchTemplate { split_direction: launch_config::SplitDirection::Horizontal, panes: vec![ leaf_pane!(), PaneBranchTemplate { split_direction: launch_config::SplitDirection::Vertical, panes: vec![leaf_pane!(), leaf_pane!()], }, ], }, PaneBranchTemplate { split_direction: launch_config::SplitDirection::Horizontal, panes: vec![leaf_pane!(), leaf_pane!(), leaf_pane!()], }, ], }; let window_size = Vector2F::new(window_width, window_height); let pane_group = mock_pane_group( &mut app, MockOptions { layout: PanesLayout::Template(template), window_bounds: WindowBounds::ExactPosition(RectF::new( Vector2F::zero(), window_size, )), }, ); // Assert that the window created by the call to `mock_pane_group` // has the expected bounds. let window_id = app.read(|ctx| pane_group.window_id(ctx)); app.update(|ctx| { assert_eq!( Some(window_size), ctx.window_bounds(&window_id).map(|rect| rect.size()) ); }); let pane_group_width = window_width - 2.0 * workspace::WORKSPACE_PADDING; let pane_group_height = window_height - workspace::TOTAL_TAB_BAR_HEIGHT - 2.0 * workspace::WORKSPACE_PADDING; pane_group.read(&app, |pane_group, ctx| { // Make assertions about the expected widths of the various // panes. assert_eq!( pane_group .terminal_view_at_pane_index(0, ctx) .unwrap() .as_ref(ctx) .size_info() .pane_width_px() .as_f32(), pane_group_width, "Pane with index 0 had unexpected width!" ); let half_width = (pane_group_width - tree::get_divider_thickness()) / 2.; for i in 1..=3 { assert_eq!( pane_group .terminal_view_at_pane_index(i, ctx) .unwrap() .as_ref(ctx) .size_info() .pane_width_px() .as_f32(), half_width, "Pane with index {i} had unexpected width!" ); } let one_third_width = (pane_group_width - (2. * tree::get_divider_thickness())) / 3.; for i in 4..=6 { assert_eq!( pane_group .terminal_view_at_pane_index(i, ctx) .unwrap() .as_ref(ctx) .size_info() .pane_width_px() .as_f32(), one_third_width, "Pane with index {i} had unexpected width!" ); } // Make assertions about the expected heights of the various // panes. let one_third_height = (pane_group_height - (2. * tree::get_divider_thickness())) / 3.; for i in (0..=1).chain(4..=6) { assert_eq!( pane_group .terminal_view_at_pane_index(i, ctx) .unwrap() .as_ref(ctx) .size_info() .pane_height_px() .as_f32(), one_third_height, "Pane with index {i} had unexpected height!" ); } let one_sixth_height = (pane_group_height - (5. * tree::get_divider_thickness())) / 6.; for i in 2..=3 { assert_eq!( pane_group .terminal_view_at_pane_index(i, ctx) .unwrap() .as_ref(ctx) .size_info() .pane_height_px() .as_f32(), one_sixth_height, "Pane with index {i} had unexpected height!" ); } }); }); } #[test] fn test_is_terminal_pane_being_shared() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { assert!(!panes.is_terminal_pane_being_shared(ctx)); // Add another pane; the pane group should still be "unshared". panes.add_terminal_pane(Direction::Left, None, ctx); assert!(!panes.is_terminal_pane_being_shared(ctx)); // Make one of the terminal panes shared. There is now at least one terminal pane being shared. panes .terminal_session_by_pane_index(0) .expect("terminal pane exists") .terminal_manager(ctx) .as_ref(ctx) .model() .lock() .set_shared_session_status(SharedSessionStatus::ActiveSharer); assert!(panes.is_terminal_pane_being_shared(ctx)); }); }); } #[test] fn test_number_of_shared_panes() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { // We have two terminal sessions. Neither is shared let first_pane_id = get_newly_created_pane_id(panes, &[]); panes.add_terminal_pane(Direction::Up, None, ctx); assert_eq!(panes.number_of_shared_sessions(ctx), 0); // Make one pane shared panes .terminal_manager(0, ctx) .unwrap() .as_ref(ctx) .model() .lock() .set_shared_session_status(SharedSessionStatus::ActiveSharer); assert_eq!(panes.number_of_shared_sessions(ctx), 1); // Make both panes shared panes .terminal_manager(1, ctx) .unwrap() .as_ref(ctx) .model() .lock() .set_shared_session_status(SharedSessionStatus::ActiveSharer); assert_eq!(panes.number_of_shared_sessions(ctx), 2); // Close a pane panes.close_pane(first_pane_id, ctx); assert_eq!(panes.number_of_shared_sessions(ctx), 1); }); }); } #[test] fn test_start_shared_session_from_modal() { let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |pane_group, ctx| { let terminal_pane = pane_group.terminal_session_by_pane_index(0).unwrap(); let terminal_pane_id = terminal_pane.terminal_pane_id(); let terminal_model = terminal_pane.terminal_manager(ctx).as_ref(ctx).model(); assert!(matches!( terminal_model.lock().shared_session_status(), SharedSessionStatus::NotShared )); pane_group.open_share_session_modal( terminal_pane_id, SharedSessionActionSource::PaneHeader, ctx, ); assert!(pane_group.terminal_with_open_share_session_modal.is_some()); assert_eq!( pane_group .share_session_modal .as_ref(ctx) .terminal_pane_id(), Some(terminal_pane_id) ); pane_group.handle_share_session_modal_event( &ShareSessionModalEvent::StartSharing { terminal_pane_id, scrollback_type: SharedSessionScrollbackType::None, source: SharedSessionActionSource::PaneHeader, }, ctx, ); assert!(pane_group.terminal_with_open_share_session_modal.is_none()); assert!(matches!( terminal_model.lock().shared_session_status(), SharedSessionStatus::SharePending )); }); // Wait for one tick of the event loop for the share to be started. pane_group.read(&app, |pane_group, ctx| { let terminal_view = pane_group .terminal_view_at_pane_index(0, ctx) .unwrap() .to_owned(); let model = terminal_view.as_ref(ctx).model.lock(); assert!(matches!( model.shared_session_status(), SharedSessionStatus::ActiveSharer )); let manager = shared_session::manager::Manager::as_ref(ctx); let shared_views = manager.shared_views(ctx).collect_vec(); assert_eq!(shared_views.len(), 1); assert_eq!(shared_views[0].id(), terminal_view.id()); let terminal_pane = pane_group.terminal_session_by_pane_index(0).unwrap(); assert!(terminal_pane .pane_view() .as_ref(ctx) .header() .as_ref(ctx) .has_shareable_object(ctx)); }); }); } /// TODO: look into moving this test somewhere more suitable. /// Currently, the pane group is responsible for creating and owning /// the terminal manager, which in turn owns the Network model for the share. #[test] fn test_stop_shared_session() { let _guard = FeatureFlag::CreatingSharedSessions.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); // Start the shared session. pane_group.update(&mut app, |pane_group, ctx| { let terminal_pane = pane_group.terminal_session_by_pane_index(0).unwrap(); let terminal_view = terminal_pane.terminal_view(ctx); terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.attempt_to_share_session( SharedSessionScrollbackType::None, None, SharedSessionSource::user(None), false, ctx, ); }); }); // Wait for one tick of the event loop for the share to be started. pane_group.read(&app, |pane_group, ctx| { let terminal_model = pane_group .terminal_session_by_pane_index(0) .unwrap() .to_owned() .terminal_manager(ctx) .as_ref(ctx) .model(); assert!(matches!( terminal_model.lock().shared_session_status(), SharedSessionStatus::ActiveSharer )); }); // Stop the shared session. pane_group.update(&mut app, |pane_group, ctx| { let terminal_pane = pane_group.terminal_session_by_pane_index(0).unwrap(); let terminal_view = terminal_pane.terminal_view(ctx); terminal_view.update(ctx, |terminal_view, ctx| { terminal_view.stop_sharing_session(SharedSessionActionSource::PaneHeader, ctx); }); }); // Ensure the state is correct after stopping. pane_group.update(&mut app, |pane_group, ctx| { let terminal_pane = pane_group.terminal_session_by_pane_index(0).unwrap(); let terminal_manager = terminal_pane .terminal_manager(ctx) .as_ref(ctx) .as_any() .downcast_ref::>() .unwrap(); let terminal_model = terminal_pane.terminal_manager(ctx).as_ref(ctx).model(); assert!(terminal_manager.session_sharer().borrow().is_none()); assert!(matches!( terminal_model.lock().shared_session_status(), SharedSessionStatus::NotShared )); let manager = shared_session::manager::Manager::as_ref(ctx); let shared_views = manager.shared_views(ctx).collect_vec(); assert!(shared_views.is_empty()); assert!(!terminal_pane .pane_view() .as_ref(ctx) .header() .as_ref(ctx) .has_shareable_object(ctx)); }); }); } #[test] fn test_navigation_skips_hidden_closed_panes() { let _guard = FeatureFlag::UndoClosedPanes.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { // Add second terminal to the right to create a horizontal pair panes.add_terminal_pane(Direction::Right, None, ctx); // Add third terminal; place it to the right of current focus panes.add_terminal_pane(Direction::Right, None, ctx); // Determine ordered visible panes by index 0..2 let a = panes.pane_id_by_index(0).expect("pane 0 exists"); let b = panes.pane_id_by_index(1).expect("pane 1 exists"); let c = panes.pane_id_by_index(2).expect("pane 2 exists"); // Focus C and confirm prev would be B when all are visible panes.focus_pane_by_id(c, ctx); assert_eq!(panes.prev_pane_id_navigation(c), Some(b)); // Close B (it will be hidden for undo and excluded from visible navigation) panes.close_pane(b, ctx); // Now prev from C should skip B and go to A assert_eq!(panes.prev_pane_id_navigation(c), Some(a)); // And next from A should skip B and go to C assert_eq!(panes.next_pane_id(a), Some(c)); }) }); } /// Regression test: closing a host pane on the non-undo `close_pane` branch /// must clear its entry from `transitively_shared_child_panes`. The undo /// branch relies on `cleanup_closed_pane` to call /// `forget_transitively_shared_pane`, but the non-undo branch destroys the /// pane directly and previously skipped that cleanup, leaking stale entries. #[test] fn test_close_pane_clears_transitively_shared_child_entry_on_non_undo_branch() { let _undo_closed_panes = FeatureFlag::UndoClosedPanes.override_enabled(false); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); pane_group.update(&mut app, |panes, ctx| { let host_pane_id = get_newly_created_pane_id(panes, &[]); // Add a sibling terminal so the host close does not trip the // `pane_count() == 1` early return in `close_pane`'s non-undo // branch. panes.add_terminal_pane(Direction::Right, None, ctx); // Cascade an off-tree transitively-shared child onto the host // pane id; this populates `transitively_shared_child_panes`. let child_pane_id = panes.insert_terminal_pane_hidden_for_child_agent( host_pane_id, HashMap::new(), IsSharedSessionCreator::Yes { source: SharedSessionSource::user(Some("host-task".to_string())), }, ctx, ); assert!( panes .transitively_shared_child_panes .get(&host_pane_id) .is_some_and(|children| children.contains(&child_pane_id.into())), "setup precondition: host should track its transitively-shared child" ); // Close the host via the non-undo branch. panes.close_pane(host_pane_id, ctx); assert!( !panes .transitively_shared_child_panes .contains_key(&host_pane_id), "host entry must be cleared after close_pane on the non-undo branch" ); }) }); } // Ensures that we always show the pane header for terminal panes, regardless of split state. #[test] fn test_terminal_pane_headers() { App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); // There should be a single terminal pane to start and the pane header should not be shown. pane_group.read(&app, |pane_group, ctx| { assert_eq!(pane_group.pane_contents.len(), 1); let terminal_panes = pane_group.panes_of::().collect_vec(); assert_eq!(terminal_panes.len(), 1); let pane_view = terminal_panes[0].pane_view(); let header_visible = pane_view .as_ref(ctx) .header() .as_ref(ctx) .is_visible_in_pane_group(); assert!(header_visible); }); // Create a terminal split pane. pane_group.update(&mut app, |pane_group, ctx| { pane_group.add_terminal_pane(Direction::Left, None, ctx); }); // There should be two terminal panes and they should both have the pane header. pane_group.read(&app, |pane_group, ctx| { assert_eq!(pane_group.pane_contents.len(), 2); let terminal_panes = pane_group.panes_of::().collect_vec(); assert_eq!(terminal_panes.len(), 2); for terminal_pane in terminal_panes { let pane_view = terminal_pane.pane_view(); assert!(pane_view .as_ref(ctx) .header() .as_ref(ctx) .is_visible_in_pane_group()); } }); // Close one of the panes; the remaining pane should still have a header. pane_group.update(&mut app, |pane_group, ctx| { pane_group.close_pane(pane_group.focused_pane_id(ctx), ctx); }); pane_group.read(&app, |pane_group, ctx| { assert_eq!(pane_group.pane_contents.len(), 1); let terminal_panes = pane_group.panes_of::().collect_vec(); assert_eq!(terminal_panes.len(), 1); let pane_view = terminal_panes[0].pane_view(); assert!(pane_view .as_ref(ctx) .header() .as_ref(ctx) .is_visible_in_pane_group()); }); // Create a non-terminal split pane. Terminal pane header remains visible. pane_group.update(&mut app, |pane_group, ctx| { pane_group.add_pane_with_direction( Direction::Left, NotebookPane::new(new_notebook(ctx), ctx), true, /* focus_new_pane */ ctx, ); }); pane_group.read(&app, |pane_group, ctx| { assert_eq!(pane_group.pane_contents.len(), 2); let terminal_panes = pane_group.panes_of::().collect_vec(); assert_eq!(terminal_panes.len(), 1); let pane_view = terminal_panes[0].pane_view(); assert!(pane_view .as_ref(ctx) .header() .as_ref(ctx) .is_visible_in_pane_group()); }); }); } /// Tests that focusing two different panes in quick succession does not cause /// an infinite loop of focus changes, as outlined in this PR's description: /// https://github.com/warpdotdev/warp-internal/pull/8990 #[cfg_attr(windows, ignore = "TODO(CORE-3626)")] #[test] fn test_pane_focus_does_not_have_an_infinite_event_loop() { App::test((), |mut app| async move { initialize_app(&mut app); // Create a pane group with two terminal panes that will fight for // focus. let mock_options = MockOptions { layout: PanesLayout::Template(PaneTemplateType::PaneBranchTemplate { split_direction: crate::launch_configs::launch_config::SplitDirection::Horizontal, panes: vec![ PaneTemplateType::PaneTemplate { is_focused: Some(true), cwd: "/".into(), commands: vec![], pane_mode: PaneMode::Terminal, shell: None, }, PaneTemplateType::PaneTemplate { is_focused: None, cwd: "/".into(), commands: vec![], pane_mode: PaneMode::Terminal, shell: None, }, ], }), ..Default::default() }; let pane_group = mock_pane_group(&mut app, mock_options); // The cycle requires that we are constantly trying to focus the input. // An active and long-running block causes focus to move to the // terminal instead of the input, so we need to wait until we've // finished bootstrapping to ensure no such block will exist. loop { let mut all_terminals_bootstrapped = true; pane_group.update(&mut app, |pane_group, ctx| { pane_group.for_all_terminal_panes(|terminal_view, _ctx| { let model = terminal_view.model.lock(); let active_block = model.block_list().active_block(); if active_block.bootstrap_stage() != crate::terminal::model::bootstrap::BootstrapStage::PostBootstrapPrecmd || active_block.is_active_and_long_running() { all_terminals_bootstrapped = false; } }, ctx); }); if all_terminals_bootstrapped { break; } // Return control back to the executor briefly so we can make // progress. futures_lite::future::yield_now().await; } pane_group.update(&mut app, |pane_group, ctx| { // Switch panes twice in quick succession. We want to make // sure the test terminates and doesn't get into an infinite // loop. pane_group.navigate_next_pane(ctx); pane_group.navigate_next_pane(ctx); }); }); } /// A view to help us react to focus changes and know that they were processed /// synchronously, not asynchronously (via an Effect::Event). struct FocusDetectionView { pane_group: ViewHandle, new_focused_pane_id: Option, } impl FocusDetectionView { fn new(pane_group: ViewHandle, ctx: &mut ViewContext) -> Self { ctx.subscribe_to_view(&pane_group, |me, pane_group, event, ctx| { let Event::OpenPromptEditor = event else { return; }; // This event is enqueued by us after the `Focus` effect, and so // by the time we receive it, application focus will have been // moved to the second pane, and (crucially) the pane group should // have updated its internal state accordingly (which is what we're // asserting here). let new_focused_pane_id = me .new_focused_pane_id .expect("should have set this already"); pane_group.read(ctx, |pane_group, ctx| { assert_eq!(pane_group.focused_pane_id(ctx), new_focused_pane_id); assert_eq!( pane_group.active_session_id(ctx), new_focused_pane_id.as_terminal_pane_id() ); }); }); Self { pane_group, new_focused_pane_id: None, } } } impl Entity for FocusDetectionView { type Event = (); } impl View for FocusDetectionView { fn ui_name() -> &'static str { "FocusDetectionView" } fn render(&self, _app: &AppContext) -> Box { ChildView::new(&self.pane_group).finish() } } impl TypedActionView for FocusDetectionView { type Action = (); } /// This test ensures that a change in application focus causes the pane group /// focused pane to update synchronously, without needing to wait for effect /// flushing to occur. /// /// The goal is to avoid situations where a delayed response to application /// focus changes leads to an infinite loop of focusing and re-focusing two /// different panes. #[test] fn test_focused_pane_is_synchronized_with_application_focus() { App::test((), |mut app| async move { initialize_app(&mut app); // Create a pane group with two terminal panes, so that we can move // focus and observe the effects. let panes_layout = PanesLayout::Template(PaneTemplateType::PaneBranchTemplate { split_direction: crate::launch_configs::launch_config::SplitDirection::Horizontal, panes: vec![ PaneTemplateType::PaneTemplate { is_focused: Some(true), cwd: "/".into(), commands: vec![], pane_mode: PaneMode::Terminal, shell: None, }, PaneTemplateType::PaneTemplate { is_focused: None, cwd: "/".into(), commands: vec![], pane_mode: PaneMode::Terminal, shell: None, }, ], }); let tips_model = app.add_model(|_| TipsCompleted::default()); let (_, root_view) = app.add_window_with_bounds(WindowStyle::NotStealFocus, WindowBounds::Default, |ctx| { let user_default_shell_changed_banner_dismissal_model_handle = ctx.add_model(|_| BannerState::default()); let block_lists = Arc::new(HashMap::new()); let pane_group = ctx.add_typed_action_view(|ctx| { PaneGroup::new_with_panes_layout( tips_model, user_default_shell_changed_banner_dismissal_model_handle, ServerApiProvider::as_ref(ctx).get(), panes_layout, block_lists, None, ctx, ) }); FocusDetectionView::new(pane_group, ctx) }); let pane_group = root_view.read(&app, |root_view, _ctx| root_view.pane_group.clone()); let (focused_pane_id, active_session_id) = pane_group.read(&app, |pane_group, ctx| { ( pane_group.focused_pane_id(ctx), pane_group.active_session_id(ctx), ) }); let second_pane_id = pane_group.read(&app, |pane_group, _ctx| { pane_group .pane_ids() .find(|pane_id| *pane_id != focused_pane_id) .expect("should have more than one pane") }); // Verify that the "second" pane is not focused or active. assert_ne!(focused_pane_id, second_pane_id); assert_ne!(active_session_id, second_pane_id.as_terminal_pane_id()); root_view.update(&mut app, |root_view, _ctx| { root_view.new_focused_pane_id = Some(second_pane_id); }); pane_group.update(&mut app, |pane_group, ctx| { // First, request a change of application focus to the second // pane's terminal view. pane_group .terminal_view_from_pane_id(second_pane_id, ctx) .expect("second pane is a terminal pane") .update(ctx, |_terminal_view, ctx| { ctx.focus_self(); }); // Second, emit an event on the pane group to trigger assertion // logic in the FocusDetectionView. This event effect is enqueued after // the focus effect but before the focus effect is processed, meaning // it will observe any changes that occurred synchronously as part // of the focus effect but will _not_ observe any changes that result // from events dispatched during focus handling. // // We use `OpenPromptEditor` because we can be confident that // nothing else above may have emitted this event. // // IMPORTANT: This MUST be emitted in the same pane group update // during which we focus the terminal view, to ensure that the // effect queue doesn't get processed or further modified before we // enqueue this event on the effect queue. ctx.emit(Event::OpenPromptEditor); }); }); } /// Builds an [`AmbientAgentTask`] tailored for unit-testing /// [`decide_remote_child_hydration_action`]. /// /// `state`, `is_sandbox_running`, and `session_id` combine to determine the /// task's [`AmbientAgentLiveSessionState`]: /// - `state == InProgress`, `is_sandbox_running == true`, and a parseable /// UUID-shaped `session_id` resolve to /// [`AmbientAgentLiveSessionState::Attachable`]. /// - `state == InProgress`, `is_sandbox_running == true`, and an /// unparseable `session_id` resolve to /// [`AmbientAgentLiveSessionState::ActiveUnattachable`]. /// - Any other shape resolves to [`AmbientAgentLiveSessionState::Inactive`]. /// /// `conversation_id` populates the server conversation token used by the /// `LoadTranscript` branch; pass `None` to exercise `Fallback`. /// /// Note: `session_link` is unconditionally set to `None` in this helper. /// `AmbientAgentTask::active_live_session_state` falls back to parsing the /// session id out of `session_link` when `session_id` is absent, so a test /// that exercises that branch would need a different helper. fn hydration_decision_task( state: AmbientAgentTaskState, is_sandbox_running: bool, session_id: Option<&str>, conversation_id: Option<&str>, ) -> AmbientAgentTask { let mut task = ambient_agent_task_for_current_user(new_ambient_agent_task_id()); task.state = state; task.is_sandbox_running = is_sandbox_running; task.session_id = session_id.map(str::to_string); task.session_link = None; task.conversation_id = conversation_id.map(str::to_string); task } #[test] fn decide_remote_child_hydration_attachable_live_session_chooses_live_attach() { // InProgress + sandbox running + parseable session id -> Attachable. let task = hydration_decision_task( AmbientAgentTaskState::InProgress, true, Some("11111111-1111-1111-1111-111111111111"), Some("server-token-irrelevant-for-attach"), ); assert_eq!( task.active_live_session_state(), AmbientAgentLiveSessionState::Attachable { session_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(), }, ); assert_eq!( decide_remote_child_hydration_action(&task), RemoteChildHydrationAction::LiveAttach, ); } #[test] fn decide_remote_child_hydration_inactive_with_token_loads_transcript() { // Terminal state -> Inactive, server token present -> LoadTranscript. let task = hydration_decision_task( AmbientAgentTaskState::Succeeded, false, None, Some("my-server-token"), ); assert_eq!( task.active_live_session_state(), AmbientAgentLiveSessionState::Inactive, ); assert_eq!( decide_remote_child_hydration_action(&task), RemoteChildHydrationAction::LoadTranscript { server_token: ServerConversationToken::new("my-server-token".to_string()), task_is_terminal: true, }, ); } #[test] fn decide_remote_child_hydration_active_unattachable_with_token_loads_transcript() { // InProgress + sandbox running + unparseable session id -> // ActiveUnattachable. With a server token we still prefer LoadTranscript // over Fallback so the user sees the merged transcript instead of a bare // tombstone. let task = hydration_decision_task( AmbientAgentTaskState::InProgress, true, Some("not-a-valid-uuid"), Some("unattachable-server-token"), ); assert_eq!( task.active_live_session_state(), AmbientAgentLiveSessionState::ActiveUnattachable, ); assert_eq!( decide_remote_child_hydration_action(&task), RemoteChildHydrationAction::LoadTranscript { server_token: ServerConversationToken::new("unattachable-server-token".to_string()), task_is_terminal: false, }, ); } #[test] fn decide_remote_child_hydration_inactive_without_token_falls_back() { // Terminal state, no server token -> nothing to attach to and nothing to // load. Terminal => tombstone is appropriate. let task = hydration_decision_task(AmbientAgentTaskState::Succeeded, false, None, None); assert_eq!( task.active_live_session_state(), AmbientAgentLiveSessionState::Inactive, ); assert_eq!( decide_remote_child_hydration_action(&task), RemoteChildHydrationAction::Fallback { task_is_terminal: true, }, ); } /// `ActiveUnattachable` + no server token: the run is still in progress but /// the client can't attach and has nothing to load. Fallback must carry /// `task_is_terminal: false` so the dispatch arm skips the /// conversation-ended tombstone. #[test] fn decide_remote_child_hydration_active_unattachable_without_token_falls_back_non_terminal() { let task = hydration_decision_task( AmbientAgentTaskState::InProgress, true, Some("not-a-valid-uuid"), None, ); assert_eq!( task.active_live_session_state(), AmbientAgentLiveSessionState::ActiveUnattachable, ); assert_eq!( decide_remote_child_hydration_action(&task), RemoteChildHydrationAction::Fallback { task_is_terminal: false, }, ); } /// An `AmbientAgentTask` whose `conversation_id` is `Some("")` (or /// whitespace-only) is treated the same as `None`: the dispatch must not /// route to a no-op cloud fetch wrapped in a misleading tombstone. The /// `Fallback` arm handles "nothing to attach to, nothing to load" /// correctly. Terminal here => tombstone is appropriate. #[test] fn decide_remote_child_hydration_empty_token_falls_back() { for empty_token in [Some(""), Some(" "), Some("\t\n")] { let task = hydration_decision_task(AmbientAgentTaskState::Succeeded, false, None, empty_token); assert_eq!( task.active_live_session_state(), AmbientAgentLiveSessionState::Inactive, "empty/whitespace token={empty_token:?} should still resolve to Inactive", ); assert_eq!( decide_remote_child_hydration_action(&task), RemoteChildHydrationAction::Fallback { task_is_terminal: true, }, "empty/whitespace token={empty_token:?} must fall through to Fallback", ); } }