3324 lines
132 KiB
Rust
3324 lines
132 KiB
Rust
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::<String>))),
|
|
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<PaneGroup> {
|
|
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<PaneGroup>) -> ViewHandle<NotebookView> {
|
|
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<AmbientAgentTaskId>,
|
|
) -> 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<AIConversationId>,
|
|
parent_agent_id: Option<String>,
|
|
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<PaneGroup>,
|
|
) -> 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<PaneGroup>,
|
|
) -> 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<PaneGroup>,
|
|
) -> 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<PaneGroup>,
|
|
) -> 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<PaneGroup>,
|
|
) -> 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<PaneGroup>,
|
|
) -> 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<PaneGroup>,
|
|
) -> 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<PaneGroup>,
|
|
) -> 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<PaneGroup>,
|
|
) -> 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<PaneGroup>,
|
|
) {
|
|
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<PaneGroup>,
|
|
) -> (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<PaneGroup>,
|
|
) -> Option<AmbientAgentTaskId> {
|
|
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<PaneGroup>,
|
|
) -> (Option<AmbientAgentTaskId>, bool, Option<AIConversationId>) {
|
|
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<PaneConfiguration>,
|
|
}
|
|
|
|
impl PreAttachReturnsFalsePane {
|
|
fn new(ctx: &mut ViewContext<PaneGroup>) -> 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<PaneGroup>) -> bool {
|
|
false
|
|
}
|
|
|
|
fn attach(
|
|
&self,
|
|
_group: &PaneGroup,
|
|
_focus_handle: focus_state::PaneFocusHandle,
|
|
_ctx: &mut ViewContext<PaneGroup>,
|
|
) {
|
|
}
|
|
|
|
fn detach(
|
|
&self,
|
|
_group: &PaneGroup,
|
|
_detach_type: pane::DetachType,
|
|
_ctx: &mut ViewContext<PaneGroup>,
|
|
) {
|
|
}
|
|
|
|
fn snapshot(&self, _app: &AppContext) -> LeafContents {
|
|
LeafContents::GetStarted
|
|
}
|
|
|
|
fn has_application_focus(&self, _ctx: &mut ViewContext<PaneGroup>) -> bool {
|
|
false
|
|
}
|
|
|
|
fn focus(&self, _ctx: &mut ViewContext<PaneGroup>) {}
|
|
|
|
fn shareable_link(
|
|
&self,
|
|
_ctx: &mut ViewContext<PaneGroup>,
|
|
) -> Result<pane::ShareableLink, pane::ShareableLinkError> {
|
|
Ok(pane::ShareableLink::Base)
|
|
}
|
|
|
|
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
|
|
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<EntityId> =
|
|
// 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<PaneGroup>) {
|
|
for data in panes.panes_of::<TerminalPane>() {
|
|
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::<TerminalManager<TerminalView>>()
|
|
.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::<TerminalPane>().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::<TerminalPane>().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::<TerminalPane>().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::<TerminalPane>().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<PaneGroup>,
|
|
new_focused_pane_id: Option<PaneId>,
|
|
}
|
|
|
|
impl FocusDetectionView {
|
|
fn new(pane_group: ViewHandle<PaneGroup>, ctx: &mut ViewContext<Self>) -> 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<dyn Element> {
|
|
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",
|
|
);
|
|
}
|
|
}
|