From 83ea458953e8a612fb479a56cfbc1b49b1e6dde7 Mon Sep 17 00:00:00 2001 From: cephalonaut Date: Thu, 21 May 2026 21:54:01 -0400 Subject: [PATCH 1/4] QUALITY-726: extend shared-session orchestration discovery across local and remote topologies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net effect of the QUALITY-726 integration branch, squashed. - Threads a `source_task_id` orchestrator-task-id sidecar through the shared-session protocol (`InitPayload`, `JoinedSuccessfully`, `SessionManifest`) and through the warp client so: - Manually-shared local orchestrator panes carry a task id when one is available, enabling orchestration discovery for their viewers. - Local-spawned children inherit the host's share with the child's task id stamped on the sidecar. - Bundles `source_type` + `source_task_id` into a single `SharedSessionSource` carried on `IsSharedSessionCreator::Yes`, `SharedSessionStatus::SharePendingPreBootstrap`, and `TerminalModel`. - Adds a catch-up cascade so child agent panes that existed before the parent started sharing also get auto-shared, via `BlocklistAIHistoryEvent::LocalSharedSessionEstablished` and `transitively_share_existing_local_children`. Stops auto-shared children when the host stops sharing. - Adds a driver-side session-id link: `LocalSharedSessionLinkModel` pings `update_agent_task` with the new `session_id` for local orchestrator conversations so the warp-server can surface the joinable link. - Drops a per-conversation dedupe `HashSet` from `LocalSharedSessionLinkModel` (server is now the source of truth). - Pins `session-sharing-protocol` to the merged main rev 5a0ad61 (PR #15) — removed the temporary `[patch]` override to the sibling worktree. - Hoists `host_terminal_shared_session_source_type` and `inherit_share_for_local_child` to a top-of-file cfg-gated `use` in `pane_group/mod.rs` and removes a dead `orchestration_topology` import + suppression line. Adds `specs/QUALITY-726/PRODUCT.md` and `TECH.md` documenting the design and parallelization approach (Threads A/B/C/D ran as parallel sub-agents after the protocol crate landed). - Pre-StreamInit viewer-join edge case: `source_type` upgrade on the host only mutates the local `TerminalModel`; viewers that joined before `StreamInit` stay on `User { task_id: None }` until reconnect. Tracked as a follow-up. - `updateAgentConversationIDQuery` still gates strictly on `state=RUNNING` server-side; same-class latent bug as the session-link case. To be addressed by the planned follow-up that starts local executions in RUNNING. Co-Authored-By: Oz --- Cargo.lock | 6 +- Cargo.toml | 2 +- app/src/ai/agent_conversations_model.rs | 3 +- app/src/ai/agent_sdk/driver.rs | 3 +- app/src/ai/agent_sdk/driver/terminal.rs | 7 +- .../action_model/execute/start_agent.rs | 3 +- app/src/ai/blocklist/history_model.rs | 9 + .../local_shared_session_link_model.rs | 102 +++ .../local_shared_session_link_model_tests.rs | 264 ++++++ app/src/ai/blocklist/mod.rs | 1 + .../blocklist/orchestration_event_streamer.rs | 3 +- app/src/lib.rs | 3 + app/src/pane_group/mod.rs | 349 +++++-- app/src/pane_group/mod_tests.rs | 57 +- app/src/pane_group/pane/terminal_pane.rs | 234 ++--- .../pane_group/pane/terminal_pane_tests.rs | 112 +++ .../terminal/local_tty/terminal_manager.rs | 238 ++--- app/src/terminal/model/terminal_model.rs | 166 ++-- app/src/terminal/shared_session/mod.rs | 51 +- .../terminal/shared_session/sharer/network.rs | 11 +- .../terminal/shared_session/viewer/network.rs | 90 +- .../viewer/orchestration_viewer_model.rs | 144 ++- .../orchestration_viewer_model_tests.rs | 508 ++++++++++- .../shared_session/viewer/terminal_manager.rs | 58 +- app/src/terminal/view.rs | 863 ++++++++++-------- .../view/shared_session/test_utils.rs | 2 +- .../terminal/view/shared_session/view_impl.rs | 17 +- .../view/shared_session/view_impl_tests.rs | 94 +- app/src/terminal/view/use_agent_footer/mod.rs | 93 +- .../view/use_agent_footer/mod_tests.rs | 51 +- app/src/terminal/view_tests.rs | 73 +- app/src/workspace/view_tests.rs | 101 +- specs/QUALITY-726/PRODUCT.md | 76 ++ specs/QUALITY-726/TECH.md | 276 ++++++ 34 files changed, 2977 insertions(+), 1093 deletions(-) create mode 100644 app/src/ai/blocklist/local_shared_session_link_model.rs create mode 100644 app/src/ai/blocklist/local_shared_session_link_model_tests.rs create mode 100644 app/src/pane_group/pane/terminal_pane_tests.rs create mode 100644 specs/QUALITY-726/PRODUCT.md create mode 100644 specs/QUALITY-726/TECH.md diff --git a/Cargo.lock b/Cargo.lock index 30abbdf374..2b8b210934 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6330,7 +6330,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -10000,7 +10000,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -11918,7 +11918,7 @@ dependencies = [ [[package]] name = "session-sharing-protocol" version = "0.0.0" -source = "git+https://github.com/warpdotdev/session-sharing-protocol.git?rev=3a12b871dfd1019a66057e4d9b7d5c812b73ee8c#3a12b871dfd1019a66057e4d9b7d5c812b73ee8c" +source = "git+https://github.com/warpdotdev/session-sharing-protocol.git?rev=5a0ad6135809feee9da2e9efae8bd6b54b89172e#5a0ad6135809feee9da2e9efae8bd6b54b89172e" dependencies = [ "byte-unit", "serde", diff --git a/Cargo.toml b/Cargo.toml index ce18e810ab..8e328423cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -248,7 +248,7 @@ serde_json = { version = "1.0", features = ["raw_value"] } serde_urlencoded = "0.7" serde_with = "2.0.1" serde_yaml = "0.8" -session-sharing-protocol = { git = "https://github.com/warpdotdev/session-sharing-protocol.git", rev = "3a12b871dfd1019a66057e4d9b7d5c812b73ee8c" } +session-sharing-protocol = { git = "https://github.com/warpdotdev/session-sharing-protocol.git", rev = "5a0ad6135809feee9da2e9efae8bd6b54b89172e" } similar = { version = "2.7", features = ["inline"] } simplelog = "0.12.2" smallvec = "1.6.1" diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index 47e546cdff..89d8a04438 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -1477,7 +1477,8 @@ impl AgentConversationsModel { | BlocklistAIHistoryEvent::ConversationOwnershipTransferred { .. } | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } - | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => {} + | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } + | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => {} BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. } => { ctx.emit(AgentConversationsModelEvent::ConversationUpdated { diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index 4b8d6d5bd6..9b0acc7792 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -2591,7 +2591,8 @@ impl AgentDriver { | BlocklistAIHistoryEvent::ConversationOwnershipTransferred { .. } | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } - | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => (), + | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } + | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => (), } }); diff --git a/app/src/ai/agent_sdk/driver/terminal.rs b/app/src/ai/agent_sdk/driver/terminal.rs index 85d9c33b4f..2d2bfba541 100644 --- a/app/src/ai/agent_sdk/driver/terminal.rs +++ b/app/src/ai/agent_sdk/driver/terminal.rs @@ -9,7 +9,6 @@ use std::time::Duration; use futures::channel::oneshot; use session_sharing_protocol::common::{Role, SessionId}; -use session_sharing_protocol::sharer::SessionSourceType; use warp_cli::share::{ShareAccessLevel, ShareRequest, ShareSubject}; use warp_completer::completer::CommandOutput; use warp_core::command::ExitCode; @@ -31,7 +30,7 @@ use crate::terminal::model::grid::RespectDisplayedOutput; use crate::terminal::model::index::Point; use crate::terminal::model::session::ExecuteCommandOptions; use crate::terminal::model::RespectObfuscatedSecrets; -use crate::terminal::shared_session::{self, IsSharedSessionCreator}; +use crate::terminal::shared_session::{self, IsSharedSessionCreator, SharedSessionSource}; use crate::terminal::shell::ShellType; use crate::terminal::view::ConversationRestorationInNewPaneType; use crate::terminal::TerminalView; @@ -120,9 +119,7 @@ fn create_terminal_view( ) -> Result, AgentDriverError> { let is_shared_session_creator = if options.should_share { IsSharedSessionCreator::Yes { - source_type: SessionSourceType::AmbientAgent { - task_id: options.task_id.map(|t| t.to_string()), - }, + source: SharedSessionSource::ambient_agent(options.task_id.map(|t| t.to_string())), } } else { IsSharedSessionCreator::No diff --git a/app/src/ai/blocklist/action_model/execute/start_agent.rs b/app/src/ai/blocklist/action_model/execute/start_agent.rs index 17ed977924..345232ea2a 100644 --- a/app/src/ai/blocklist/action_model/execute/start_agent.rs +++ b/app/src/ai/blocklist/action_model/execute/start_agent.rs @@ -271,7 +271,8 @@ impl StartAgentExecutor { | BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } | BlocklistAIHistoryEvent::ConversationOwnershipTransferred { .. } => {} BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } - | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => {} + | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } + | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => {} } } diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index fa9bfd17fd..c96a6796e0 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -2572,6 +2572,13 @@ pub enum BlocklistAIHistoryEvent { ConversationUsageMetadataUpdated { conversation_id: AIConversationId, }, + + /// Emitted when a sharer-owned conversation establishes a local + /// shared session. + LocalSharedSessionEstablished { + conversation_id: AIConversationId, + session_id: session_sharing_protocol::common::SessionId, + }, } impl BlocklistAIHistoryEvent { @@ -2653,6 +2660,8 @@ impl BlocklistAIHistoryEvent { // orchestrator footer reading descendant credits) can't be // disambiguated by a single owner pane. BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => None, + // Conversation-scoped; subscribers resolve the owning view via conversation_id. + BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => None, } } } diff --git a/app/src/ai/blocklist/local_shared_session_link_model.rs b/app/src/ai/blocklist/local_shared_session_link_model.rs new file mode 100644 index 0000000000..75f0d80bfc --- /dev/null +++ b/app/src/ai/blocklist/local_shared_session_link_model.rs @@ -0,0 +1,102 @@ +use super::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; +use crate::ai::agent::conversation::AIConversationId; +use crate::server::server_api::ai::AIClient; +use crate::server::server_api::ServerApiProvider; +use session_sharing_protocol::common::SessionId; +use std::sync::Arc; +use warpui::{Entity, ModelContext, SingletonEntity}; + +/// Ensures that session ID for locally owned shared conversations is linked +/// to their `ai_tasks` row in the DB. This enables viewers to reconstruct +/// the conversation's orchestration state. +pub struct LocalSharedSessionLinkModel { + ai_client: Arc, +} + +pub enum LocalSharedSessionLinkModelEvent {} + +impl LocalSharedSessionLinkModel { + pub fn new(ctx: &mut ModelContext) -> Self { + let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); + Self::new_with_ai_client(ai_client, ctx) + } + + /// Test-friendly constructor. + fn new_with_ai_client(ai_client: Arc, ctx: &mut ModelContext) -> Self { + let history_model = BlocklistAIHistoryModel::handle(ctx); + ctx.subscribe_to_model(&history_model, |me, event, ctx| { + me.handle_history_event(event, ctx); + }); + + Self { ai_client } + } + + /// Test-only constructor that lets tests inject a mock `AIClient`. + #[cfg(test)] + pub(super) fn new_with_ai_client_for_test( + ai_client: Arc, + ctx: &mut ModelContext, + ) -> Self { + Self::new_with_ai_client(ai_client, ctx) + } + + fn handle_history_event(&self, event: &BlocklistAIHistoryEvent, ctx: &mut ModelContext) { + if let BlocklistAIHistoryEvent::LocalSharedSessionEstablished { + conversation_id, + session_id, + } = event + { + self.on_local_shared_session_established(*conversation_id, *session_id, ctx); + } + } + + /// Links the conversation's `task_id` to `session_id` on the server. + /// Skips viewers, remote-child placeholders, and conversations without + /// a `task_id` (pre-StreamInit). + fn on_local_shared_session_established( + &self, + conversation_id: AIConversationId, + session_id: SessionId, + ctx: &mut ModelContext, + ) { + let Some(conversation) = + BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id) + else { + return; + }; + if conversation.is_viewing_shared_session() { + return; + } + if conversation.is_remote_child() { + return; + } + let Some(task_id) = conversation.task_id() else { + return; + }; + + let ai_client = self.ai_client.clone(); + ctx.spawn( + async move { + if let Err(err) = ai_client + .update_agent_task(task_id, None, Some(session_id), None, None) + .await + { + log::warn!( + "LocalSharedSessionLinkModel: failed to link task {task_id} to shared session {session_id}: {err:#}" + ); + } + }, + |_, _, _| {}, + ); + } +} + +impl Entity for LocalSharedSessionLinkModel { + type Event = LocalSharedSessionLinkModelEvent; +} + +impl SingletonEntity for LocalSharedSessionLinkModel {} + +#[cfg(test)] +#[path = "local_shared_session_link_model_tests.rs"] +mod tests; diff --git a/app/src/ai/blocklist/local_shared_session_link_model_tests.rs b/app/src/ai/blocklist/local_shared_session_link_model_tests.rs new file mode 100644 index 0000000000..0e70cadc03 --- /dev/null +++ b/app/src/ai/blocklist/local_shared_session_link_model_tests.rs @@ -0,0 +1,264 @@ +use super::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel, LocalSharedSessionLinkModel}; +use crate::ai::agent::conversation::{AIConversation, AIConversationId}; +use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::server::server_api::ai::{AIClient, MockAIClient}; +use session_sharing_protocol::common::SessionId; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use warpui::App; + +/// Parses a fixed UUID into an `AmbientAgentTaskId`. Using a constant uuid +/// makes test failures easier to read than `Uuid::new_v4()`. +fn fixed_task_id() -> AmbientAgentTaskId { + "550e8400-e29b-41d4-a716-446655440a00" + .parse() + .expect("valid task id") +} + +fn fixed_session_id() -> SessionId { + "550e8400-e29b-41d4-a716-446655440a01" + .parse() + .expect("valid session id") +} + +/// Yields back to the executor a few times so any `ctx.spawn`-scheduled +/// fire-and-forget tasks can drive their underlying mock RPC. A short +/// timer (smaller than the test budget) is enough; we just need the +/// background poll to happen at least once. +async fn pump_spawned_tasks() { + for _ in 0..5 { + warpui::r#async::Timer::after(Duration::from_millis(2)).await; + } +} + +fn install_model_with_call_counter( + app: &mut App, +) -> ( + warpui::ModelHandle, + Arc, +) { + let counter = Arc::new(AtomicUsize::new(0)); + let counter_for_mock = counter.clone(); + let mut mock = MockAIClient::new(); + mock.expect_update_agent_task() + .returning(move |_, _, _, _, _| { + counter_for_mock.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + let ai_client: Arc = Arc::new(mock); + let model = app.add_singleton_model(|ctx| { + LocalSharedSessionLinkModel::new_with_ai_client_for_test(ai_client, ctx) + }); + (model, counter) +} + +#[test] +fn local_shared_session_established_fires_update_agent_task_with_session_id() { + App::test((), |mut app| async move { + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + + // A local orchestrator conversation owned by this client: not a + // viewer, not a remote-child placeholder, and has a `task_id`. + let mut conversation = AIConversation::new(false, false); + let task_id = fixed_task_id(); + conversation.set_run_id(task_id.to_string()); + let conversation_id = conversation.id(); + let terminal_view_id = warpui::EntityId::new(); + history_model.update(&mut app, |model, ctx| { + model.restore_conversations(terminal_view_id, vec![conversation], ctx); + }); + + let (_model, counter) = install_model_with_call_counter(&mut app); + let session_id = fixed_session_id(); + + history_model.update(&mut app, |_, ctx| { + ctx.emit(BlocklistAIHistoryEvent::LocalSharedSessionEstablished { + conversation_id, + session_id, + }); + }); + + pump_spawned_tasks().await; + + assert_eq!( + counter.load(Ordering::SeqCst), + 1, + "update_agent_task must be invoked exactly once for the new (task_id, session_id) pair" + ); + }); +} + +#[test] +fn local_shared_session_established_uses_correct_argument_order() { + App::test((), |mut app| async move { + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + + let mut conversation = AIConversation::new(false, false); + let task_id = fixed_task_id(); + conversation.set_run_id(task_id.to_string()); + let conversation_id = conversation.id(); + let terminal_view_id = warpui::EntityId::new(); + history_model.update(&mut app, |model, ctx| { + model.restore_conversations(terminal_view_id, vec![conversation], ctx); + }); + + // Verify the exact argument shape we send to the server: + // update_agent_task(task_id, None, Some(session_id), None, None) + let session_id = fixed_session_id(); + let mut mock = MockAIClient::new(); + mock.expect_update_agent_task() + .withf( + move |arg_task_id, task_state, arg_session_id, conv_id, status_msg| { + *arg_task_id == task_id + && task_state.is_none() + && *arg_session_id == Some(session_id) + && conv_id.is_none() + && status_msg.is_none() + }, + ) + .times(1) + .returning(|_, _, _, _, _| Ok(())); + let ai_client: Arc = Arc::new(mock); + let _model = app.add_singleton_model(|ctx| { + LocalSharedSessionLinkModel::new_with_ai_client_for_test(ai_client, ctx) + }); + + history_model.update(&mut app, |_, ctx| { + ctx.emit(BlocklistAIHistoryEvent::LocalSharedSessionEstablished { + conversation_id, + session_id, + }); + }); + + pump_spawned_tasks().await; + // Mock drop verifies `.times(1)` and `.withf` predicate. + }); +} + +#[test] +fn local_shared_session_established_skips_viewer_conversations() { + App::test((), |mut app| async move { + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + + // A viewer-side conversation: even if it carries a task_id, this + // client does not own the task and must not link. + let mut conversation = + AIConversation::new(/* is_viewing_shared_session */ true, false); + conversation.set_run_id(fixed_task_id().to_string()); + let conversation_id = conversation.id(); + let terminal_view_id = warpui::EntityId::new(); + history_model.update(&mut app, |model, ctx| { + model.restore_conversations(terminal_view_id, vec![conversation], ctx); + }); + + let (_model, counter) = install_model_with_call_counter(&mut app); + + history_model.update(&mut app, |_, ctx| { + ctx.emit(BlocklistAIHistoryEvent::LocalSharedSessionEstablished { + conversation_id, + session_id: fixed_session_id(), + }); + }); + + pump_spawned_tasks().await; + + assert_eq!( + counter.load(Ordering::SeqCst), + 0, + "viewer guard must skip the RPC" + ); + }); +} + +#[test] +fn local_shared_session_established_skips_remote_child_conversations() { + App::test((), |mut app| async move { + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + + let mut conversation = AIConversation::new(false, false); + conversation.set_run_id(fixed_task_id().to_string()); + conversation.mark_as_remote_child(); + let conversation_id = conversation.id(); + let terminal_view_id = warpui::EntityId::new(); + history_model.update(&mut app, |model, ctx| { + model.restore_conversations(terminal_view_id, vec![conversation], ctx); + }); + + let (_model, counter) = install_model_with_call_counter(&mut app); + + history_model.update(&mut app, |_, ctx| { + ctx.emit(BlocklistAIHistoryEvent::LocalSharedSessionEstablished { + conversation_id, + session_id: fixed_session_id(), + }); + }); + + pump_spawned_tasks().await; + + assert_eq!( + counter.load(Ordering::SeqCst), + 0, + "remote-child guard must skip the RPC" + ); + }); +} + +#[test] +fn local_shared_session_established_skips_when_task_id_missing() { + App::test((), |mut app| async move { + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + + // No set_run_id call: the conversation has no task_id yet. + let conversation = AIConversation::new(false, false); + let conversation_id = conversation.id(); + let terminal_view_id = warpui::EntityId::new(); + history_model.update(&mut app, |model, ctx| { + model.restore_conversations(terminal_view_id, vec![conversation], ctx); + }); + + let (_model, counter) = install_model_with_call_counter(&mut app); + + history_model.update(&mut app, |_, ctx| { + ctx.emit(BlocklistAIHistoryEvent::LocalSharedSessionEstablished { + conversation_id, + session_id: fixed_session_id(), + }); + }); + + pump_spawned_tasks().await; + + assert_eq!( + counter.load(Ordering::SeqCst), + 0, + "missing task_id must skip the RPC" + ); + }); +} + +#[test] +fn local_shared_session_established_skips_unknown_conversation() { + App::test((), |mut app| async move { + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); + + let (_model, counter) = install_model_with_call_counter(&mut app); + + // Emit for a conversation that was never registered: the subscriber + // must early-return without firing an RPC. + let bogus_conversation_id = AIConversationId::new(); + history_model.update(&mut app, |_, ctx| { + ctx.emit(BlocklistAIHistoryEvent::LocalSharedSessionEstablished { + conversation_id: bogus_conversation_id, + session_id: fixed_session_id(), + }); + }); + + pump_spawned_tasks().await; + + assert_eq!( + counter.load(Ordering::SeqCst), + 0, + "unknown conversation must skip the RPC" + ); + }); +} diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index 58476fd0c7..5144753f05 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -7,6 +7,7 @@ mod context_model; mod controller; pub(crate) mod handoff; +pub(crate) mod local_shared_session_link_model; pub(crate) mod orchestration_event_streamer; pub(crate) mod orchestration_events; pub(crate) mod orchestration_topology; diff --git a/app/src/ai/blocklist/orchestration_event_streamer.rs b/app/src/ai/blocklist/orchestration_event_streamer.rs index b758aaa22f..19393862c7 100644 --- a/app/src/ai/blocklist/orchestration_event_streamer.rs +++ b/app/src/ai/blocklist/orchestration_event_streamer.rs @@ -471,7 +471,8 @@ impl OrchestrationEventStreamer { | BlocklistAIHistoryEvent::ConversationOwnershipTransferred { .. } | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } - | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => {} + | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } + | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => {} } } diff --git a/app/src/lib.rs b/app/src/lib.rs index 4882d9daad..6c6172b665 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1727,6 +1727,9 @@ pub(crate) fn initialize_app( ctx.add_singleton_model(BlocklistAIPermissions::new); ctx.add_singleton_model(ai::blocklist::orchestration_events::OrchestrationEventService::new); ctx.add_singleton_model(ai::blocklist::task_status_sync_model::TaskStatusSyncModel::new); + ctx.add_singleton_model( + ai::blocklist::local_shared_session_link_model::LocalSharedSessionLinkModel::new, + ); if warp_core::features::FeatureFlag::OrchestrationV2.is_enabled() { ctx.add_singleton_model( ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer::new, diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 8c9827d744..cf016d49f1 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -1,14 +1,77 @@ +use crate::ai::active_agent_views_model::ActiveAgentViewsModel; +use crate::ai::agent::api::ServerConversationToken; +use crate::ai::agent::conversation::{AIAgentHarness, AIConversation, AIConversationId}; +use crate::ai::agent_conversations_model::{ + AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel, + AgentConversationsModelEvent, +}; +use crate::ai::ai_document_view::AIDocumentView; +use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; +use crate::ai::blocklist::history_model::CloudConversationData; +use crate::ai::blocklist::inline_action::code_diff_view::CodeDiffView; +use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId; +use crate::ai::blocklist::suggested_rule_modal::SuggestedRuleAndId; +use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel, InputConfig}; +use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; +use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}; +use crate::ai::llms::LLMId; +use crate::ai::restored_conversations::RestoredAgentConversations; +use crate::auth::auth_manager::AuthManager; +use crate::auth::auth_view_modal::AuthViewVariant; +use crate::auth::AuthStateProvider; +use crate::cloud_object::Space; +use crate::code::buffer_location::LocalOrRemotePath; +#[cfg(feature = "local_fs")] +use crate::code::editor_management::CodeSource; +use crate::code::view::CodeViewAction; +use crate::code_review::comments::{AttachedReviewComment, PendingImportedReviewComment}; +use crate::code_review::diff_state::DiffMode; +use crate::env_vars::EnvVarCollectionType; +use crate::notebooks::file::FileNotebookView; +use crate::pane_group::focus_state::PaneGroupFocusEvent; +use crate::pane_group::pane::get_started_pane::GetStartedPane; +#[cfg(not(target_family = "wasm"))] +use crate::pane_group::pane::terminal_pane::{ + host_terminal_shared_session_source_type, inherit_share_for_local_child, +}; +use crate::pane_group::pane::welcome_pane::WelcomePane; +use crate::pane_group::pane::ActionOrigin; +use crate::quit_warning::UnsavedStateSummary; +#[cfg(target_family = "wasm")] +use crate::server::cloud_objects::update_manager::UpdateManager; +use crate::server::server_api::ServerApiProvider; +use crate::settings::{AISettings, DefaultSessionMode, PaneSettings}; +use crate::settings_view::SettingsSection; +use crate::shell_indicator::ShellIndicatorType; +use crate::terminal::available_shells::{AvailableShell, AvailableShells}; +#[cfg(not(target_family = "wasm"))] +use crate::terminal::cli_agent_sessions::plugin_manager::PluginModalKind; +use crate::terminal::view::inline_banner::{ + ZeroStatePromptSuggestionTriggeredFrom, ZeroStatePromptSuggestionType, +}; +use crate::terminal::view::load_ai_conversation::RestoredAIConversation; +use crate::undo_close::UndoCloseStack; +use crate::undo_close::UndoCloseStackEvent; +#[cfg(target_family = "wasm")] +use crate::uri::browser_url_handler::update_browser_url; +#[cfg(feature = "local_fs")] +use crate::util::openable_file_type::FileTarget; +use crate::view_components::ToastFlavor; +use crate::workflows::workflow::Workflow; +use warp_terminal::shell::{ShellName, ShellType}; + use std::any::Any; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::path::PathBuf; use std::rc::Rc; -use std::sync::mpsc::SyncSender; -use std::sync::Arc; +use std::sync::{mpsc::SyncSender, Arc}; use itertools::Itertools; use lazy_static::lazy_static; + use markdown_parser::FormattedTextFragment; use parking_lot::FairMutex; use pathfinder_geometry::rect::RectF; @@ -17,8 +80,6 @@ use serde::{Deserialize, Serialize}; use session_sharing_protocol::common::{ ParticipantId, Role, RoleRequestId, RoleRequestRejectedReason, RoleRequestResponse, SessionId, }; -use session_sharing_protocol::sharer::SessionSourceType; -use settings::Setting as _; use tree::DEFAULT_FLEX_VALUE; use typed_path::TypedPath; use url::Url; @@ -26,42 +87,26 @@ use uuid::Uuid; use warp_cli::agent::Harness; use warp_core::command::ExitCode; use warp_core::context_flag::ContextFlag; -use warp_terminal::shell::{ShellName, ShellType}; use warp_util::path::convert_wsl_to_windows_host_path; #[cfg(feature = "local_fs")] use warp_util::path::LineAndColumnArg; use warp_util::remote_path::RemotePath; use warpui::elements::{ - ChildView, Clipped, CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex, - MainAxisSize, ParentElement, Shrinkable, Stack, + Clipped, CrossAxisAlignment, DispatchEventResult, EventHandler, Flex, MainAxisSize, Shrinkable, + Stack, }; use warpui::keymap::{Context, EditableBinding, FixedBinding}; use warpui::notification::NotificationSendError; + use warpui::windowing::WindowManager; use warpui::{ - AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, - ViewHandle, WeakViewHandle, WindowId, + elements::{ChildView, Element, ParentElement}, + AppContext, Entity, EntityId, ModelHandle, TypedActionView, View, ViewHandle, WeakViewHandle, + WindowId, }; +use warpui::{SingletonEntity, ViewContext}; -use crate::ai::active_agent_views_model::ActiveAgentViewsModel; -use crate::ai::agent::api::ServerConversationToken; -use crate::ai::agent::conversation::{AIAgentHarness, AIConversation, AIConversationId}; -use crate::ai::agent_conversations_model::{ - AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel, - AgentConversationsModelEvent, -}; -use crate::ai::ai_document_view::AIDocumentView; -use crate::ai::ambient_agents::AmbientAgentTaskId; -use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; -use crate::ai::blocklist::history_model::CloudConversationData; -use crate::ai::blocklist::inline_action::code_diff_view::CodeDiffView; -use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId; -use crate::ai::blocklist::suggested_rule_modal::SuggestedRuleAndId; -use crate::ai::blocklist::{BlocklistAIHistoryModel, InputConfig, SerializedBlockListItem}; -use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; -use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}; -use crate::ai::llms::LLMId; -use crate::ai::restored_conversations::RestoredAgentConversations; +use crate::ai::blocklist::SerializedBlockListItem; use crate::ai_assistant::AskAIType; #[cfg(feature = "local_fs")] use crate::app_state::CodePaneSnapShot; @@ -71,66 +116,38 @@ use crate::app_state::{ TerminalPaneSnapshot, WorkflowPaneSnapshot, }; use crate::appearance::Appearance; -use crate::auth::auth_manager::AuthManager; -use crate::auth::auth_view_modal::AuthViewVariant; -use crate::auth::AuthStateProvider; use crate::banner::{Banner, BannerEvent, BannerState, BannerTextContent, DismissalType}; use crate::channel::{Channel, ChannelState}; -use crate::cloud_object::Space; -use crate::code::active_file::ActiveFileModel; -use crate::code::buffer_location::LocalOrRemotePath; -#[cfg(feature = "local_fs")] -use crate::code::editor_management::CodeSource; -use crate::code::view::{CodeView, CodeViewAction}; -use crate::code_review::comments::{AttachedReviewComment, PendingImportedReviewComment}; -use crate::code_review::diff_state::DiffMode; +use crate::code::view::CodeView; use crate::drive::items::WarpDriveItemId; use crate::drive::{CloudObjectTypeAndId, OpenWarpDriveObjectArgs}; -use crate::env_vars::EnvVarCollectionType; use crate::features::FeatureFlag; use crate::launch_configs::launch_config::{self, PaneMode, PaneTemplateType}; -use crate::notebooks::file::FileNotebookView; -use crate::palette::PaletteMode; -use crate::pane_group::focus_state::PaneGroupFocusEvent; -use crate::pane_group::pane::get_started_pane::GetStartedPane; -use crate::pane_group::pane::welcome_pane::WelcomePane; -use crate::pane_group::pane::ActionOrigin; use crate::persistence::ModelEvent; -use crate::quit_warning::UnsavedStateSummary; +use crate::report_if_error; use crate::resource_center::{ mark_feature_used_and_write_to_user_defaults, Tip, TipAction, TipsCompleted, }; -#[cfg(target_family = "wasm")] -use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::ids::{ObjectUid, SyncId}; -use crate::server::server_api::{ServerApi, ServerApiProvider}; use crate::server::telemetry::{ AnonymousUserSignupEntrypoint, PaletteSource, SharingDialogSource, TelemetryEvent, }; use crate::session_management::SessionNavigationData; -use crate::settings::{AISettings, DefaultSessionMode, PaneSettings}; use crate::settings_view::mcp_servers_page::MCPServersSettingsPage; -use crate::settings_view::SettingsSection; -use crate::shell_indicator::ShellIndicatorType; -use crate::terminal::available_shells::{AvailableShell, AvailableShells}; -#[cfg(not(target_family = "wasm"))] -use crate::terminal::cli_agent_sessions::plugin_manager::PluginModalKind; use crate::terminal::general_settings::{GeneralSettings, GeneralSettingsChangedEvent}; #[cfg(feature = "local_tty")] use crate::terminal::local_tty; use crate::terminal::model::session::Session; -use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; -use crate::terminal::session_settings::{NewSessionSource, SessionSettings}; +use crate::terminal::session_settings::NewSessionSource; +use crate::terminal::session_settings::SessionSettings; use crate::terminal::shared_session::render_util::ParticipantAvatarParams; use crate::terminal::shared_session::role_change_modal::{ RoleChangeCloseSource, RoleChangeModal, RoleChangeModalEvent, }; use crate::terminal::shared_session::share_modal::{ShareSessionModal, ShareSessionModalEvent}; -use crate::terminal::shared_session::{self, IsSharedSessionCreator, SharedSessionActionSource}; -use crate::terminal::view::inline_banner::{ - ZeroStatePromptSuggestionTriggeredFrom, ZeroStatePromptSuggestionType, +use crate::terminal::shared_session::{ + self, IsSharedSessionCreator, SharedSessionActionSource, SharedSessionSource, }; -use crate::terminal::view::load_ai_conversation::RestoredAIConversation; use crate::terminal::view::ssh_file_upload::FileUploadId; use crate::terminal::view::{ BlockNotification, ConversationRestorationInNewPaneType, ExecuteCommandEvent, @@ -138,21 +155,23 @@ use crate::terminal::view::{ }; use crate::terminal::{ MockTerminalManager, ShareBlockModal, ShareBlockModalEvent, ShellLaunchData, ShellLaunchState, - TerminalManager, TerminalModel, TerminalView, }; -use crate::undo_close::{UndoCloseStack, UndoCloseStackEvent}; -#[cfg(target_family = "wasm")] -use crate::uri::browser_url_handler::update_browser_url; +use crate::{cmd_or_ctrl_shift, send_telemetry_from_ctx}; +use settings::Setting as _; + +use crate::code::active_file::ActiveFileModel; use crate::util::bindings::{is_binding_pty_compliant, CustomAction}; -#[cfg(feature = "local_fs")] -use crate::util::openable_file_type::FileTarget; -use crate::view_components::ToastFlavor; -use crate::workflows::workflow::Workflow; use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType}; + +use crate::palette::PaletteMode; +use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; use crate::workspace::{ self, CommandSearchOptions, PaneViewLocator, TabBarLocation, WorkspaceAction, }; -use crate::{cmd_or_ctrl_shift, report_if_error, send_telemetry_from_ctx}; +use crate::{ + server::server_api::ServerApi, + terminal::{TerminalManager, TerminalModel, TerminalView}, +}; mod child_agent; pub mod focus_state; @@ -160,12 +179,14 @@ pub mod pane; pub mod tree; pub mod working_directories; use child_agent::{apply_hidden_child_agent_task_context, HiddenChildAgentTaskContext}; + use focus_state::PaneGroupFocusState; #[cfg(test)] #[path = "mod_tests.rs"] mod tests; +pub use crate::code_review::CodeReviewPanelArg; pub use pane::ai_document_pane::AIDocumentPane; pub use pane::ai_fact_pane::AIFactPane; pub use pane::code_diff_pane::CodeDiffPane; @@ -179,15 +200,16 @@ pub use pane::notebook_pane::NotebookPane; pub use pane::settings_pane::SettingsPane; pub use pane::terminal_pane::TerminalPane; pub use pane::workflow_pane::WorkflowPane; +pub use pane::PaneHeaderAction; +pub use pane::PaneHeaderCustomAction; pub use pane::{ AnyPaneContent, BackingView, PaneConfiguration, PaneConfigurationEvent, PaneContent, PaneEvent, - PaneHeaderAction, PaneHeaderCustomAction, PaneId, PaneView, TerminalPaneId, + PaneId, PaneView, TerminalPaneId, }; pub use tree::{Direction, PaneData, PaneFlex, PaneNode, SplitDirection}; pub use working_directories::{WorkingDirectoriesEvent, WorkingDirectoriesModel}; use self::pane::{DetachType, PaneViewEvent}; -pub use crate::code_review::CodeReviewPanelArg; lazy_static! { // The value to use as the initial window bounds if we are unable to @@ -899,6 +921,12 @@ pub struct PaneGroup { /// be revealed from the parent's status card. child_agent_panes: HashMap, + /// Host pane id → child pane ids whose share was auto-created by + /// `inherit_share_for_local_child`. Used by `StopSharingCurrentSession` + /// so transitively-shared children don't outlive the host's share. + /// Excludes cloud-SDK-managed shares (`AmbientAgent` host path). + transitively_shared_child_panes: HashMap>, + /// Set when this pane group hosts a split-off child agent pane that /// should be re-adopted by its source group on tab close. child_agent_origin: Option, @@ -2604,10 +2632,13 @@ impl PaneGroup { }; terminal_view.update(ctx, |view, ctx| { + let share_source = SharedSessionSource::user( + view.active_conversation_task_id(ctx).map(|t| t.to_string()), + ); view.attempt_to_share_session( *scrollback_type, Some(*source), - SessionSourceType::default(), + share_source, false, ctx, ); @@ -3034,6 +3065,23 @@ impl PaneGroup { me.discard_pane(*pane_id, ctx); }); + // Catch-up share for children that existed before the parent + // started sharing — `inherit_share_for_local_child` only fires at + // child-pane creation time. + #[cfg(not(target_family = "wasm"))] + ctx.subscribe_to_model( + &BlocklistAIHistoryModel::handle(ctx), + |me, _, event, ctx| { + if let BlocklistAIHistoryEvent::LocalSharedSessionEstablished { + conversation_id, + .. + } = event + { + me.transitively_share_existing_local_children(*conversation_id, ctx); + } + }, + ); + let active_file_model = ctx.add_model(|_| ActiveFileModel::new()); let mut pane_group = Self { @@ -3062,6 +3110,7 @@ impl PaneGroup { is_right_panel_maximized: false, pending_ambient_agent_conversation_restorations: HashMap::new(), child_agent_panes: HashMap::new(), + transitively_shared_child_panes: HashMap::new(), child_agent_origin: None, custom_title: None, }; @@ -4400,6 +4449,10 @@ impl PaneGroup { /// `child_agent_panes`. The orchestration pill bar later inserts it into /// the tree on demand via `replace_pane` (in-place swap) or `panes.split` /// ("Open in new pane"). + /// + /// When `is_shared_session_creator` is `Yes`, the new pane is recorded + /// in `transitively_shared_child_panes` keyed by `base_pane_id` so the + /// host's `StopSharingCurrentSession` cleans it up. fn insert_terminal_pane_hidden_for_child_agent( &mut self, base_pane_id: PaneId, @@ -4411,6 +4464,10 @@ impl PaneGroup { .as_terminal_pane_id() .or(self.active_session_id(ctx)); let startup_directory = self.startup_path_for_new_session(base_session_id, ctx); + let is_transitively_shared = matches!( + &is_shared_session_creator, + IsSharedSessionCreator::Yes { .. } + ); let (pane_data, _view) = self.create_terminal_pane_data( startup_directory, env_vars, @@ -4420,10 +4477,145 @@ impl PaneGroup { ctx, ); let new_pane_id = pane_data.terminal_pane_id(); + if is_transitively_shared { + self.transitively_shared_child_panes + .entry(base_pane_id) + .or_default() + .insert(new_pane_id.into()); + } self.attach_child_pane_off_tree(Box::new(pane_data), ctx); new_pane_id } + /// Dispatches a share on every direct child agent pane in this group + /// that isn't already sharing, mirroring + /// `terminal_pane::inherit_share_for_local_child` for children that + /// existed before the host started sharing. + #[cfg(not(target_family = "wasm"))] + fn transitively_share_existing_local_children( + &mut self, + host_conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) { + let Some(host_pane_id) = self.pane_id_for_owned_conversation(host_conversation_id, ctx) + else { + return; + }; + let Some(host_terminal_view) = self.terminal_view_from_pane_id(host_pane_id, ctx) else { + return; + }; + let Some(host_source) = host_terminal_shared_session_source_type(&host_terminal_view, ctx) + else { + return; + }; + if host_source.orchestrator_task_id().is_none() { + return; + } + + let direct_child_ids: Vec = BlocklistAIHistoryModel::as_ref(ctx) + .child_conversation_ids_of(&host_conversation_id) + .to_vec(); + + let mut planned: Vec<(PaneId, AmbientAgentTaskId)> = Vec::new(); + for child_conversation_id in direct_child_ids { + let Some(child_pane_id) = self + .child_agent_panes + .get(&child_conversation_id) + .copied() + .filter(|pane_id| self.has_pane_id(*pane_id)) + else { + continue; + }; + let Some(child_task_id) = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&child_conversation_id) + .and_then(|c| c.task_id()) + else { + continue; + }; + planned.push((child_pane_id, child_task_id)); + } + + for (child_pane_id, child_task_id) in planned { + let Some(child_terminal_view) = self.terminal_view_from_pane_id(child_pane_id, ctx) + else { + continue; + }; + // Skip if the child is already sharing / pending / viewing. + let already_in_shared_state = child_terminal_view + .as_ref(ctx) + .model + .lock() + .shared_session_status() + .is_sharer_or_viewer(); + if already_in_shared_state { + continue; + } + + let creator = inherit_share_for_local_child(Some(&host_source), child_task_id); + let IsSharedSessionCreator::Yes { source } = creator else { + continue; + }; + + // Record in the host's transitive-share tracking set so the + // host's stop-share also stops this child. + self.transitively_shared_child_panes + .entry(host_pane_id) + .or_default() + .insert(child_pane_id); + + child_terminal_view.update(ctx, |view, ctx| { + view.attempt_to_share_session( + shared_session::SharedSessionScrollbackType::All, + None, + source, + /* bypass_conversation_guard = */ false, + ctx, + ); + }); + } + } + + /// Stop the shared session on every child pane that was transitively + /// shared from `host_pane_id`. + fn stop_transitively_shared_child_shares( + &mut self, + host_pane_id: PaneId, + ctx: &mut ViewContext, + ) { + let Some(child_pane_ids) = self.transitively_shared_child_panes.remove(&host_pane_id) + else { + return; + }; + for child_pane_id in child_pane_ids { + let Some(terminal_view) = self.terminal_view_from_pane_id(child_pane_id, ctx) else { + continue; + }; + let is_sharing = terminal_view + .as_ref(ctx) + .model + .lock() + .shared_session_status() + .is_sharer(); + if !is_sharing { + continue; + } + terminal_view.update(ctx, |view, ctx| { + view.stop_sharing_session(SharedSessionActionSource::NonUser, ctx); + }); + } + } + + /// Removes `pane_id` from the transitive-share tracking map. + fn forget_transitively_shared_pane(&mut self, pane_id: PaneId) { + // The pane may be a host (key) or a transitively-shared child (value). + self.transitively_shared_child_panes.remove(&pane_id); + self.transitively_shared_child_panes + .retain(|_host, children| { + children.remove(&pane_id); + !children.is_empty() + }); + } + /// Creates a cloud-mode pane that lives off-tree as a child agent pane. /// Unlike `create_ambient_agent_pane`, this leaves the new terminal view /// uninitialized so callers can create and select the child conversation @@ -5150,6 +5342,10 @@ impl PaneGroup { if !self.panes.remove(pane_id) { log::error!("Pane not found"); } + + // Mirror cleanup_closed_pane's transitive-share map cleanup so + // the non-undo close path doesn't leak stale entries. + self.forget_transitively_shared_pane(pane_id); } self.handle_pane_count_change(ctx); @@ -5764,6 +5960,9 @@ impl PaneGroup { log::warn!("Attempted to cleanup pane {pane_id} but it was not found in the tree"); } self.pane_contents.remove(&pane_id); + // Drop any transitive-share tracking entry for this pane so the + // map doesn't accumulate stale ids. + self.forget_transitively_shared_pane(pane_id); ctx.notify(); ctx.emit(Event::TerminalViewStateChanged); diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 7eab55147d..62c296f042 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -86,7 +86,7 @@ use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; use crate::terminal::resizable_data::ResizableData; use crate::terminal::shared_session::{ IsSharedSessionCreator, SharedSessionActionSource, SharedSessionScrollbackType, - SharedSessionStatus, + SharedSessionSource, SharedSessionStatus, }; use crate::test_util::settings::initialize_settings_for_tests; use crate::undo_close::UndoCloseStack; @@ -2277,7 +2277,7 @@ fn test_stop_shared_session() { terminal_view.attempt_to_share_session( SharedSessionScrollbackType::None, None, - SessionSourceType::default(), + SharedSessionSource::user(None), false, ctx, ); @@ -2380,6 +2380,59 @@ fn test_navigation_skips_hidden_closed_panes() { }); } +/// 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() { diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index fbef7cf802..d4a0dcd8fe 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1,67 +1,68 @@ //! Implementation of terminal panes. +use crate::code::buffer_location::LocalOrRemotePath; +#[cfg(feature = "local_fs")] +use crate::pane_group::CodeSource; #[cfg(not(target_family = "wasm"))] use std::collections::HashMap; use std::sync::mpsc::SyncSender; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use base64::Engine as _; -#[cfg(not(target_family = "wasm"))] -use session_sharing_protocol::sharer::SessionSourceType; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use url::Url; use warp_cli::agent::Harness; -use warp_core::execution_mode::AppExecutionMode; use warp_multi_agent_api as multi_agent_api; + use warpui::{ AppContext, EntityId, ModelHandle, SingletonEntity, ViewContext, ViewHandle, WindowId, }; -#[cfg(not(target_family = "wasm"))] -use super::local_harness_launch::{prepare_local_harness_child_launch, PreparedLocalHarnessLaunch}; -use super::{ - DetachType, PaneConfiguration, PaneContent, PaneId, PaneStackEvent, PaneView, ShareableLink, - ShareableLinkError, TerminalPaneId, -}; -use crate::ai::active_agent_views_model::ActiveAgentViewsModel; -use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; -use crate::ai::agent::StartAgentExecutionMode; -use crate::ai::ambient_agents::task::{normalize_orchestrator_agent_name, HarnessConfig}; -use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId}; -use crate::ai::blocklist::agent_view::{AgentViewControllerEvent, AgentViewEntryOrigin}; -use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; -use crate::ai::blocklist::orchestration_events::{OrchestrationEventService, SendEventResult}; -#[cfg(feature = "local_fs")] -use crate::ai::blocklist::BlocklistAIHistoryEvent; -use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequest}; -use crate::ai::conversation_utils; -use crate::ai::llms::LLMPreferences; -use crate::ai::skills::SkillManager; -use crate::app_state::{AmbientAgentPaneSnapshot, LeafContents, TerminalPaneSnapshot}; -use crate::code::buffer_location::LocalOrRemotePath; -use crate::features::FeatureFlag; -use crate::pane_group::child_agent::{ - create_error_child_agent_conversation, ErrorChildAgentConversationRequest, +use crate::{ + ai::{ + active_agent_views_model::ActiveAgentViewsModel, + agent::{ + conversation::{AIConversationId, ConversationStatus}, + StartAgentExecutionMode, + }, + ambient_agents::{ + task::{normalize_orchestrator_agent_name, HarnessConfig}, + AgentConfigSnapshot, AmbientAgentTaskId, + }, + blocklist::{ + agent_view::{AgentViewControllerEvent, AgentViewEntryOrigin}, + orchestration_event_streamer::OrchestrationEventStreamer, + orchestration_events::{OrchestrationEventService, SendEventResult}, + BlocklistAIHistoryModel, StartAgentRequest, + }, + conversation_utils, + llms::LLMPreferences, + skills::SkillManager, + }, + app_state::{AmbientAgentPaneSnapshot, LeafContents, TerminalPaneSnapshot}, + features::FeatureFlag, + pane_group::child_agent::{ + create_error_child_agent_conversation, ErrorChildAgentConversationRequest, + }, + pane_group::{self, Direction, Event::OpenConversationHistory, PaneGroup}, + persistence::{BlockCompleted, ModelEvent}, + server::server_api::ai::{SpawnAgentRequest, UserQueryMode}, + session_management::SessionNavigationData, + terminal::cli_agent_sessions::CLIAgentSessionsModel, + terminal::view::ambient_agent::should_disable_snapshot, + terminal::{ + general_settings::GeneralSettings, + shared_session::{ + join_link, + manager::{Manager, ManagerEvent}, + role_change_modal::RoleChangeOpenSource, + SharedSessionSource, SharedSessionStatus, + }, + view::Event, + TerminalManager, TerminalView, + }, + view_components::ToastFlavor, + workspace::{sync_inputs::SyncedInputState, PaneViewLocator, WorkspaceRegistry}, + AIExecutionProfilesModel, }; -#[cfg(feature = "local_fs")] -use crate::pane_group::CodeSource; -use crate::pane_group::Event::OpenConversationHistory; -use crate::pane_group::{self, Direction, PaneGroup}; -use crate::persistence::{BlockCompleted, ModelEvent}; -use crate::server::server_api::ai::{SpawnAgentRequest, UserQueryMode}; -#[cfg(not(target_family = "wasm"))] -use crate::server::server_api::ServerApiProvider; -use crate::session_management::SessionNavigationData; -use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; -use crate::terminal::general_settings::GeneralSettings; -use crate::terminal::shared_session::manager::{Manager, ManagerEvent}; -use crate::terminal::shared_session::role_change_modal::RoleChangeOpenSource; -use crate::terminal::shared_session::{join_link, SharedSessionStatus}; -use crate::terminal::view::ambient_agent::should_disable_snapshot; -use crate::terminal::view::Event; -use crate::terminal::{TerminalManager, TerminalView}; -use crate::view_components::ToastFlavor; -use crate::workspace::sync_inputs::SyncedInputState; -use crate::workspace::{PaneViewLocator, WorkspaceRegistry}; -use crate::AIExecutionProfilesModel; + // Imports below are only consumed by the non-wasm `launch_local_*_child` // dispatch helpers; gating them keeps the wasm build warning-clean. #[cfg(not(target_family = "wasm"))] @@ -74,6 +75,22 @@ use crate::{ terminal::shared_session::IsSharedSessionCreator, }; +#[cfg(feature = "local_fs")] +use crate::ai::blocklist::BlocklistAIHistoryEvent; +#[cfg(not(target_family = "wasm"))] +use crate::server::server_api::ServerApiProvider; + +#[cfg(not(target_family = "wasm"))] +use session_sharing_protocol::sharer::SessionSourceType; +use warp_core::execution_mode::AppExecutionMode; + +#[cfg(not(target_family = "wasm"))] +use super::local_harness_launch::{prepare_local_harness_child_launch, PreparedLocalHarnessLaunch}; +use super::{ + DetachType, PaneConfiguration, PaneContent, PaneId, PaneStackEvent, PaneView, ShareableLink, + ShareableLinkError, TerminalPaneId, +}; + pub type TerminalPaneView = PaneView; /// Data kept for terminal panes. @@ -164,71 +181,52 @@ fn register_legacy_local_lifecycle_subscription( } } -/// Returns the `SessionSourceType` the host terminal is sharing as, or -/// `None` if it is not currently a shared-session creator. Reads the -/// underlying `TerminalModel` directly via the host's `TerminalView` so the -/// dispatch helpers don't need to downcast a `dyn TerminalManager` trait -/// object to the concrete `local_tty::TerminalManager` just to call the -/// equivalent inherent method. +/// Returns the host terminal's `SharedSessionSource`, or `None` if it is +/// not currently a shared-session creator. Reads the underlying +/// `TerminalModel` directly via the host's `TerminalView`. #[cfg(not(target_family = "wasm"))] -fn host_terminal_shared_session_source_type( +pub(in crate::pane_group) fn host_terminal_shared_session_source_type( parent_terminal_view: &ViewHandle, ctx: &AppContext, -) -> Option { +) -> Option { let model = parent_terminal_view.as_ref(ctx).model.lock(); - // `start_sharing_session` sets this once the share is active. - if let Some(source_type) = model.shared_session_source_type() { - return Some(source_type); + if let Some(source) = model.shared_session_source() { + return Some(source.clone()); } - // Pre-bootstrap: the source type is carried inside - // `SharedSessionStatus::SharePendingPreBootstrap` (set by the terminal - // manager constructor when `IsSharedSessionCreator::Yes` is plumbed - // through). - if let SharedSessionStatus::SharePendingPreBootstrap { source_type } = - model.shared_session_status() + if let SharedSessionStatus::SharePendingPreBootstrap { source } = model.shared_session_status() { - return Some(source_type.clone()); + return Some(source.clone()); } None } -/// Builds the `IsSharedSessionCreator` value for a child pane being spawned -/// by `run_agents(local)`. Returns `Yes` (with the child's own `task_id` -/// stamped onto the source type) iff: -/// 1. `FeatureFlag::OrchestrationViewerPillBar` is enabled, and -/// 2. the host terminal is itself sharing as a -/// `SessionSourceType::AmbientAgent` (i.e. the host is a cloud -/// orchestrator worker or a desktop session that was already shared -/// under an ambient-agent task). -/// -/// Non-`AmbientAgent` host shares (e.g. a user manually sharing their own -/// terminal via the share modal) explicitly do NOT cascade to child panes: -/// the viewer-side pill bar in `shared_session/viewer/terminal_manager.rs` -/// only materializes for `AmbientAgent` host source types, so auto-sharing -/// children of a manual share would consume share-server capacity and -/// publish a child session transcript without producing any pill-bar UI -/// for the host's viewers to reach it — strictly worse than leaving -/// children unshared. +/// Builds the `IsSharedSessionCreator` for a child pane spawned by +/// `run_agents(local)`. Returns `Yes` (stamped with the child's `task_id`) +/// only when `OrchestrationViewerPillBar` is enabled and the host carries +/// an orchestrator `task_id`. The host's variant kind is preserved so +/// cloud-only UI stays gated on `AmbientAgent`. #[cfg(not(target_family = "wasm"))] -fn inherit_share_for_local_child( - host_source_type: Option<&SessionSourceType>, +pub(in crate::pane_group) fn inherit_share_for_local_child( + host_source: Option<&SharedSessionSource>, child_task_id: AmbientAgentTaskId, ) -> IsSharedSessionCreator { if !FeatureFlag::OrchestrationViewerPillBar.is_enabled() { return IsSharedSessionCreator::No; } - if matches!( - host_source_type, - Some(SessionSourceType::AmbientAgent { .. }) - ) { - IsSharedSessionCreator::Yes { - source_type: SessionSourceType::AmbientAgent { - task_id: Some(child_task_id.to_string()), - }, - } - } else { - IsSharedSessionCreator::No + let Some(host_source) = host_source else { + return IsSharedSessionCreator::No; + }; + if host_source.orchestrator_task_id().is_none() { + return IsSharedSessionCreator::No; } + let child_task_id_str = child_task_id.to_string(); + let source = match &host_source.source_type { + SessionSourceType::User => SharedSessionSource::user(Some(child_task_id_str)), + SessionSourceType::AmbientAgent { .. } => { + SharedSessionSource::ambient_agent(Some(child_task_id_str)) + } + }; + IsSharedSessionCreator::Yes { source } } impl TerminalPane { @@ -1228,6 +1226,15 @@ fn handle_terminal_view_event( Event::OpenShareSessionModal { open_source } => { group.open_share_session_modal(terminal_pane_id, *open_source, ctx) } + // When the host's manual share stops, also stop the share on + // any local children whose share was auto-created via + // `inherit_share_for_local_child`. Skipped on wasm because the + // transitive-share tracker is only populated on non-wasm + // dispatch paths. + #[cfg(not(target_family = "wasm"))] + Event::StopSharingCurrentSession { .. } => { + group.stop_transitively_shared_child_shares(pane_id, ctx); + } Event::OpenShareSessionDeniedModal => { group.open_share_session_denied_modal(terminal_pane_id, ctx); } @@ -1692,12 +1699,10 @@ fn launch_local_no_harness_child( let prompt = request.prompt.clone(); let lifecycle_subscription = request.lifecycle_subscription.clone(); - // Snapshot the host terminal's shared-session source type now (before - // the spawn) so we can stamp the resulting child task id onto the - // child's source type after the spawn returns. Gated on the viewer - // pill-bar flag is applied at the call site in - // `inherit_share_for_local_child`. - let host_source_type = group + // Snapshot the host terminal's shared-session source before the spawn + // so we can cascade it onto the child's source type once the spawn + // returns. + let host_source = group .terminal_view_from_pane_id(parent_pane_id, ctx) .and_then(|view| host_terminal_shared_session_source_type(&view, ctx)); @@ -1720,7 +1725,7 @@ fn launch_local_no_harness_child( move |group, result, ctx| match result { Ok(child_task_id) => { let is_shared_session_creator = - inherit_share_for_local_child(host_source_type.as_ref(), child_task_id); + inherit_share_for_local_child(host_source.as_ref(), child_task_id); if let Some(HiddenChildAgentConversation { terminal_view: new_terminal_view, @@ -1850,11 +1855,9 @@ fn launch_local_harness_child( .terminal_view_from_pane_id(parent_pane_id, ctx) .and_then(|terminal_view| terminal_view.as_ref(ctx).active_session_shell_type(ctx)); - // Snapshot the host terminal's shared-session source type now (before - // the spawn) so we can stamp the prepared child task id onto the - // child's source type after the spawn returns. Gated on the viewer - // pill-bar flag in `inherit_share_for_local_child`. - let host_source_type = group + // Snapshot the host's shared-session source before the spawn so we can + // cascade it onto the prepared child task. + let host_source = group .terminal_view_from_pane_id(parent_pane_id, ctx) .and_then(|view| host_terminal_shared_session_source_type(&view, ctx)); @@ -1883,7 +1886,7 @@ fn launch_local_harness_child( task_id, } = launch; let is_shared_session_creator = - inherit_share_for_local_child(host_source_type.as_ref(), task_id); + inherit_share_for_local_child(host_source.as_ref(), task_id); if let Some(HiddenChildAgentConversation { terminal_view: new_terminal_view, terminal_view_id, @@ -2325,6 +2328,11 @@ fn handle_ai_history_event( | BlocklistAIHistoryEvent::ConversationOwnershipTransferred { .. } | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } - | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => (), + | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } + | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => (), } } + +#[cfg(all(test, not(target_family = "wasm")))] +#[path = "terminal_pane_tests.rs"] +mod tests; diff --git a/app/src/pane_group/pane/terminal_pane_tests.rs b/app/src/pane_group/pane/terminal_pane_tests.rs new file mode 100644 index 0000000000..5824bb6989 --- /dev/null +++ b/app/src/pane_group/pane/terminal_pane_tests.rs @@ -0,0 +1,112 @@ +//! Tests for [`inherit_share_for_local_child`]. These verify the pure +//! branching independent of the PaneGroup dispatch code. The behavior +//! is gated by `FeatureFlag::OrchestrationViewerPillBar` so each case +//! must override it explicitly. + +use super::*; +use uuid::Uuid; + +fn new_task_id() -> AmbientAgentTaskId { + Uuid::new_v4().to_string().parse().unwrap() +} + +fn user_source(task_id: Option<&str>) -> SharedSessionSource { + SharedSessionSource::user(task_id.map(str::to_owned)) +} + +fn ambient_source(task_id: Option<&str>) -> SharedSessionSource { + SharedSessionSource::ambient_agent(task_id.map(str::to_owned)) +} + +#[test] +fn inherit_share_returns_no_when_feature_flag_disabled() { + let _guard = FeatureFlag::OrchestrationViewerPillBar.override_enabled(false); + let host = user_source(Some("host-task")); + let result = inherit_share_for_local_child(Some(&host), new_task_id()); + assert!(matches!(result, IsSharedSessionCreator::No)); +} + +#[test] +fn inherit_share_returns_no_when_host_is_not_sharing() { + let _guard = FeatureFlag::OrchestrationViewerPillBar.override_enabled(true); + let result = inherit_share_for_local_child(None, new_task_id()); + assert!(matches!(result, IsSharedSessionCreator::No)); +} + +#[test] +fn inherit_share_returns_no_when_host_user_share_has_no_task_id() { + let _guard = FeatureFlag::OrchestrationViewerPillBar.override_enabled(true); + let host = user_source(None); + let result = inherit_share_for_local_child(Some(&host), new_task_id()); + assert!( + matches!(result, IsSharedSessionCreator::No), + "hosts without a stamped task_id must NOT cascade; the viewer cannot enumerate \ + children via REST without a task_id" + ); +} + +#[test] +fn inherit_share_returns_no_when_host_ambient_share_has_no_task_id() { + let _guard = FeatureFlag::OrchestrationViewerPillBar.override_enabled(true); + let host = ambient_source(None); + let result = inherit_share_for_local_child(Some(&host), new_task_id()); + assert!(matches!(result, IsSharedSessionCreator::No)); +} + +#[test] +fn inherit_share_cascades_user_source_for_manually_shared_local_orchestrator() { + let _guard = FeatureFlag::OrchestrationViewerPillBar.override_enabled(true); + let host = user_source(Some("parent-task-id")); + let child_task_id = new_task_id(); + let expected_child_str = child_task_id.to_string(); + match inherit_share_for_local_child(Some(&host), child_task_id) { + IsSharedSessionCreator::Yes { + source: + SharedSessionSource { + source_type: SessionSourceType::User, + source_task_id: Some(task_id), + }, + } => { + assert_eq!( + task_id, expected_child_str, + "the cascaded child must carry its own task_id in the sidecar, not the host's" + ); + } + other => panic!( + "expected IsSharedSessionCreator::Yes with unit User variant carrying child task_id in \ + the sidecar, got {other:?}" + ), + } +} + +#[test] +fn inherit_share_cascades_ambient_source_for_cloud_orchestrator() { + let _guard = FeatureFlag::OrchestrationViewerPillBar.override_enabled(true); + let host = ambient_source(Some("parent-task-id")); + let child_task_id = new_task_id(); + let expected_child_str = child_task_id.to_string(); + match inherit_share_for_local_child(Some(&host), child_task_id) { + IsSharedSessionCreator::Yes { + source: + SharedSessionSource { + source_type: + SessionSourceType::AmbientAgent { + task_id: Some(task_id), + }, + source_task_id, + }, + } => { + assert_eq!(task_id, expected_child_str); + assert_eq!( + source_task_id.as_deref(), + Some(expected_child_str.as_str()), + "the sidecar must mirror the cascaded child's task_id so viewers can read one \ + field for both `User` and `AmbientAgent` shares" + ); + } + other => panic!( + "expected IsSharedSessionCreator::Yes with AmbientAgent variant carrying child \ + task_id, got {other:?}" + ), + } +} diff --git a/app/src/terminal/local_tty/terminal_manager.rs b/app/src/terminal/local_tty/terminal_manager.rs index 9361db6655..3a82415870 100644 --- a/app/src/terminal/local_tty/terminal_manager.rs +++ b/app/src/terminal/local_tty/terminal_manager.rs @@ -1,17 +1,46 @@ +use crate::ai::aws_credentials::AwsCredentialRefresher as _; +use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; +use crate::auth::auth_state::AuthState; +use crate::auth::AuthStateProvider; +use crate::terminal::model::terminal_model::ExitReason; +use crate::terminal::shared_session::replay_agent_conversations::reconstruct_response_events_from_conversations; +use crate::terminal::shared_session::shared_handlers::{ + apply_auto_approve_agent_actions_update, apply_cli_agent_state_update, apply_input_mode_update, + apply_selected_agent_model_update, apply_selected_conversation_update, + build_selected_conversation_update, RemoteUpdateGuard, +}; +use crate::terminal::shell::ShellName; +use crate::terminal::warpify::settings::WarpifySettings; +use crate::terminal::TerminalManager as _; +use anyhow::Context as _; +use async_broadcast::InactiveReceiver; use std::any::Any; use std::cell::RefCell; -use std::collections::HashMap; -use std::ffi::OsString; -use std::path::PathBuf; use std::rc::Rc; use std::sync::mpsc::{SendError, SyncSender}; -use std::sync::Arc; -use std::thread::JoinHandle; +use std::{collections::HashMap, ffi::OsString, path::PathBuf, sync::Arc, thread::JoinHandle}; + +use session_sharing_protocol::sharer::{ + AddGuestsResponse, FailedToInitializeSessionReason, Lifetime, LinkAccessLevelUpdateResponse, + QuotaType, RemoveGuestResponse, SessionEndedReason, SessionSourceType, + TeamAccessLevelUpdateResponse, UpdatePendingUserRoleResponse, +}; + +use crate::editor::CrdtOperation; +use crate::network::{NetworkStatusEvent, NetworkStatusKind}; +use crate::terminal::available_shells::{AvailableShell, AvailableShells}; +use crate::terminal::shared_session::permissions_manager::SessionPermissionsManager; +use crate::terminal::shared_session::presence_manager::PresenceManager; +use crate::terminal::ShellLaunchData; +use crate::terminal::ShellLaunchState; +use crate::view_components::ToastFlavor; -use anyhow::Context as _; -use async_broadcast::InactiveReceiver; use parking_lot::{FairMutex, Mutex}; use pathfinder_geometry::vector::Vector2F; + +use crate::terminal::cli_agent_sessions::{ + CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, +}; use session_sharing_protocol::common::{ ActivePrompt, AgentPromptFailureReason, CLIAgentSessionState, CommandExecutionFailureReason, ControlAction, ControlActionFailureReason, SelectedAgentModel, @@ -21,82 +50,51 @@ use session_sharing_protocol::common::{ use session_sharing_protocol::common::{ LongRunningCommandAgentInteractionState, SelectedConversation, UniversalDeveloperInputContext, }; -use session_sharing_protocol::sharer::{ - AddGuestsResponse, FailedToInitializeSessionReason, Lifetime, LinkAccessLevelUpdateResponse, - QuotaType, RemoveGuestResponse, SessionEndedReason, SessionSourceType, - TeamAccessLevelUpdateResponse, UpdatePendingUserRoleResponse, -}; use settings::Setting as _; -use warp_core::execution_mode::AppExecutionMode; -use warp_core::send_telemetry_from_ctx; use warpui::r#async::executor::Background; use warpui::{AppContext, ModelContext, ModelHandle, SingletonEntity, ViewHandle, WindowId}; -#[cfg(unix)] -use { - super::terminal_attributes::TerminalAttributesPoller, - crate::terminal::local_tty::terminal_attributes::Event as TerminalAttributesPollerEvent, - crate::terminal::model::terminal_model::BlockIndex, - crate::terminal::session_settings::NotificationsMode, nix::sys::termios::LocalFlags, -}; -use super::event_loop::EventLoop; -use super::shell::{ShellStarter, ShellStarterSource}; -use super::{mio_channel, recorder}; +use warp_core::execution_mode::AppExecutionMode; + use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::conversation::AIConversation; -use crate::ai::aws_credentials::AwsCredentialRefresher as _; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent}; use crate::ai::blocklist::{ BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIControllerEvent, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, InputConfig, SerializedBlockListItem, }; -use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; -use crate::auth::auth_state::AuthState; -use crate::auth::AuthStateProvider; +use crate::terminal::view::ConversationRestorationInNewPaneType; + use crate::banner::BannerState; use crate::context_chips::current_prompt::CurrentPrompt; use crate::context_chips::prompt_snapshot::PromptSnapshot; use crate::context_chips::prompt_type::PromptType; -use crate::editor::CrdtOperation; use crate::features::FeatureFlag; -use crate::network::{NetworkStatusEvent, NetworkStatusKind}; use crate::pane_group::TerminalViewResources; use crate::persistence::ModelEvent; -use crate::server::server_api::ServerApiProvider; + +use crate::send_telemetry_on_executor; use crate::server::telemetry::{TelemetryAgentViewEntryOrigin, TelemetryEvent}; -use crate::settings::{DebugSettings, PrivacySettings, SshSettings}; -use crate::terminal::available_shells::{AvailableShell, AvailableShells}; -use crate::terminal::cli_agent_sessions::{ - CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, -}; -use crate::terminal::event_listener::ChannelEventListener; -use crate::terminal::local_tty::{Pty, PtyOptions}; +use crate::settings::DebugSettings; +use crate::settings::{PrivacySettings, SshSettings}; +use warp_core::send_telemetry_from_ctx; + use crate::terminal::model::session::Sessions; -use crate::terminal::model::terminal_model::ExitReason; + use crate::terminal::model_events::ModelEventDispatcher; use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode; use crate::terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent}; use crate::terminal::shared_session::manager::Manager; -use crate::terminal::shared_session::permissions_manager::SessionPermissionsManager; -use crate::terminal::shared_session::presence_manager::PresenceManager; -use crate::terminal::shared_session::replay_agent_conversations::reconstruct_response_events_from_conversations; use crate::terminal::shared_session::settings::SharedSessionSettings; -use crate::terminal::shared_session::shared_handlers::{ - apply_auto_approve_agent_actions_update, apply_cli_agent_state_update, apply_input_mode_update, - apply_selected_agent_model_update, apply_selected_conversation_update, - build_selected_conversation_update, RemoteUpdateGuard, -}; use crate::terminal::shared_session::sharer::network::{ failed_to_add_guests_user_error, failed_to_initialize_session_user_error, session_terminated_reason_string, Network, NetworkEvent, }; use crate::terminal::shared_session::{ IsSharedSessionCreator, SharedSessionActionSource, SharedSessionScrollbackType, - SharedSessionStatus, + SharedSessionSource, SharedSessionStatus, }; -use crate::terminal::shell::ShellName; -use crate::terminal::view::{ConversationRestorationInNewPaneType, Event as TerminalViewEvent}; -use crate::terminal::warpify::settings::WarpifySettings; +use crate::terminal::view::Event as TerminalViewEvent; use crate::terminal::writeable_pty::pty_controller::{EventLoopSendError, EventLoopSender}; use crate::terminal::writeable_pty::terminal_manager_util::{ init_pty_controller_model, init_remote_server_controller, wire_up_pty_controller_with_view, @@ -104,11 +102,25 @@ use crate::terminal::writeable_pty::terminal_manager_util::{ }; use crate::terminal::writeable_pty::{self, Message}; use crate::terminal::{ - terminal_manager, ShellLaunchData, ShellLaunchState, TerminalManager as _, TerminalModel, - TerminalView, PTY_READS_BROADCAST_CHANNEL_SIZE, + event_listener::ChannelEventListener, + local_tty::{Pty, PtyOptions}, + TerminalModel, +}; +use crate::terminal::{terminal_manager, TerminalView, PTY_READS_BROADCAST_CHANNEL_SIZE}; +use crate::NetworkStatus; + +use super::mio_channel; +use super::recorder; +use super::shell::ShellStarter; +use super::{event_loop::EventLoop, shell::ShellStarterSource}; + +#[cfg(unix)] +use { + super::terminal_attributes::TerminalAttributesPoller, + crate::terminal::local_tty::terminal_attributes::Event as TerminalAttributesPollerEvent, + crate::terminal::model::terminal_model::BlockIndex, + crate::terminal::session_settings::NotificationsMode, nix::sys::termios::LocalFlags, }; -use crate::view_components::ToastFlavor; -use crate::{send_telemetry_on_executor, NetworkStatus}; type PtyController = writeable_pty::PtyController>; type RemoteServerController = @@ -322,11 +334,11 @@ impl TerminalManager { // shared-session state before we construct the view, so that bootstrap // events can observe the correct pending status and source type. match is_shared_session_creator { - IsSharedSessionCreator::Yes { source_type } + IsSharedSessionCreator::Yes { source } if FeatureFlag::CreatingSharedSessions.is_enabled() => { model.lock().set_shared_session_status( - SharedSessionStatus::SharePendingPreBootstrap { source_type }, + SharedSessionStatus::SharePendingPreBootstrap { source }, ); log::info!("Configured terminal to start sharing after bootstrap"); } @@ -445,7 +457,7 @@ impl TerminalManager { view.attempt_to_share_session( SharedSessionScrollbackType::All, None, - SessionSourceType::default(), + SharedSessionSource::user(None), false, ctx, ) @@ -737,6 +749,48 @@ impl TerminalManager { }); } } + // Upgrade a manual `User` share's sidecar `source_task_id` + // from `None` to `Some(_)` once the active conversation + // gets its `task_id`, so inherited child shares can + // discover the orchestrator task. Existing viewers stay + // on the old value (the protocol has no + // `UpdateSourceType` upstream message) until they + // reconnect. + BlocklistAIHistoryEvent::ConversationServerTokenAssigned { + terminal_view_id, + conversation_id, + } => { + if *terminal_view_id != view_id_for_stream_init { + return; + } + + let Some(view) = weak_view_for_stream_init.upgrade(ctx) else { + return; + }; + + let model = view.as_ref(ctx).model.clone(); + let needs_upgrade = { + let model_lock = model.lock(); + model_lock.shared_session_source().is_some_and(|s| { + matches!(s.source_type, SessionSourceType::User) + && s.source_task_id.is_none() + }) + }; + if !needs_upgrade { + return; + } + + let task_id = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(conversation_id) + .and_then(|c| c.task_id()); + let Some(task_id) = task_id else { + return; + }; + + model + .lock() + .set_shared_session_source_task_id(Some(task_id.to_string())); + } _ => {} } }, @@ -1296,7 +1350,7 @@ impl TerminalManager { shared_session_model: Rc>>>, scrollback_type: SharedSessionScrollbackType, lifetime: Lifetime, - source_type: SessionSourceType, + source: SharedSessionSource, model: Arc>, window_id: WindowId, sharer_remote_update_guard: RemoteUpdateGuard, @@ -1312,12 +1366,12 @@ impl TerminalManager { } log::info!("Starting shared session"); - // Record the source type on the model so we can distinguish ambient agent - // sessions from user-initiated shared sessions in the UI logic. - model - .lock() - .set_shared_session_source_type(source_type.clone()); - if matches!(source_type, SessionSourceType::AmbientAgent { .. }) { + // Record the source on the model so we can distinguish ambient agent + // sessions from user-initiated shared sessions in the UI logic, and so + // the orchestrator task id is discoverable regardless of which variant + // the share is. + model.lock().set_shared_session_source(source.clone()); + if matches!(source.source_type, SessionSourceType::AmbientAgent { .. }) { let terminal_view_id = terminal_view.id(); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _ctx| { history.mark_terminal_view_as_ambient_agent_session_view(terminal_view_id); @@ -1366,7 +1420,6 @@ impl TerminalManager { cfg_if::cfg_if! { if #[cfg(any(test, feature = "integration_tests"))] { let _ = lifetime; - let _ = source_type; let network = ctx.add_model(|ctx| Network::new_for_test( model.clone(), events_rx, @@ -1449,7 +1502,7 @@ impl TerminalManager { terminal_view.id(), universal_developer_input_context, lifetime, - source_type.clone(), + source.clone(), ctx, ) }); @@ -1486,7 +1539,7 @@ impl TerminalManager { *sharer_firebase_uid, scrollback_type, *session_id, - source_type.clone(), + source.source_type.clone(), ctx, ); @@ -1501,6 +1554,16 @@ impl TerminalManager { manager.started_share(terminal_view.downgrade(), *session_id, window_id, ctx); }); + // Lifecycle event for downstream subscribers. + if let Some(conversation_id) = selected_conversation_id { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |_, ctx| { + ctx.emit(BlocklistAIHistoryEvent::LocalSharedSessionEstablished { + conversation_id, + session_id: *session_id, + }); + }); + } + // Flush the initial input operations that the sharer performed // in the latest buffer before the share was started. let is_ambient = model.lock().is_shared_ambient_agent_session(); @@ -1526,39 +1589,8 @@ impl TerminalManager { Self::stream_historical_agent_conversations(&terminal_view, &model, ctx); } - let session_id_for_link = *session_id; - - // Read task_id lazily so we still pick up a server-assigned - // task_id that arrived after the user clicked share. - let task_id = selected_conversation_id.and_then(|conversation_id| { - BlocklistAIHistoryModel::as_ref(ctx) - .conversation(&conversation_id) - .and_then(|c| c.task_id()) - }); - - if let Some(task_id) = task_id { - let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client(); - terminal_view.update(ctx, |_view, ctx| { - ctx.spawn( - async move { - ai_client - .update_agent_task( - task_id, - None, - Some(session_id_for_link), - None, - None, - ) - .await - }, - move |_view, result, _ctx| { - if let Err(e) = result { - log::warn!("Failed to link shared session to Oz task: {e}"); - } - }, - ); - }); - } + // `LocalSharedSessionLinkModel` fires the (task_id, + // session_id) link in response to the event emitted above. } NetworkEvent::FailedToCreateSharedSession { reason, @@ -2251,7 +2283,7 @@ impl TerminalManager { ctx.subscribe_to_view(terminal_view, move |view, event, ctx| match event { TerminalViewEvent::StartSharingCurrentSession { scrollback_type, - source_type, + source, } if FeatureFlag::CreatingSharedSessions.is_enabled() => { Self::start_sharing_session( view.clone(), @@ -2259,7 +2291,7 @@ impl TerminalManager { session_sharer.clone(), *scrollback_type, session_lifetime, - source_type.clone(), + source.clone(), model.clone(), window_id, sharer_remote_update_guard.clone(), diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index 553df608d6..1e88872f61 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -1,37 +1,34 @@ -use std::cmp::{max, min}; -use std::collections::HashMap; -use std::num::ParseIntError; -use std::ops::{Range, RangeInclusive}; -use std::path::PathBuf; -use std::str::FromStr; -use std::sync::Arc; - -use async_channel::Sender; -use base64::Engine; -use hex::FromHexError; -use instant::Instant; -use itertools::{Either, Itertools}; -use serde::Serialize; -use session_sharing_protocol::common::{ - AICommandMetadata, OrderedTerminalEventType, ParticipantId, +use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::blocklist::SerializedBlockListItem; +use crate::terminal::available_shells::AvailableShell; +use crate::terminal::block_list_element::GridType; +use crate::terminal::event::{ + BootstrappedEvent, Event, ExecutedExecutorCommandEvent, InitSshEvent, InitSubshellEvent, + SourcedRcFileInSubshellEvent, SshLoginStatus, TerminalMode, }; -use session_sharing_protocol::sharer::SessionSourceType; -use warp_core::features::FeatureFlag; -use warp_core::report_error; -use warp_core::semantic_selection::SemanticSelection; -pub use warp_terminal::model::BlockIndex; -use warp_terminal::model::{KeyboardModes, KeyboardModesApplyBehavior}; -use warpui::assets::asset_cache::Asset; -use warpui::image_cache::ImageType; -use warpui::r#async::executor::Background; -#[cfg(not(target_family = "wasm"))] -use warpui::util::save_as_file; -use warpui::AppContext; +use crate::terminal::event_listener::ChannelEventListener; +use crate::terminal::model::ansi; +use crate::terminal::model::bootstrap::BootstrapStage; +use crate::terminal::model::completions::{ + ShellCompletion, ShellCompletionUpdate, ShellData as CompletionsShellData, +}; +use crate::terminal::model::escape_sequences::ModeProvider; +use crate::terminal::model::index::VisibleRow; +use crate::terminal::model::iterm_image::{ITermImage, ITermImageMetadata}; +use crate::terminal::shared_session::{ + ai_agent::encode_agent_response_event, SharedSessionSource, SharedSessionStatus, +}; +use crate::terminal::ssh::util::{InteractiveSshCommand, SshLoginState}; +use crate::terminal::{block_filter::BlockFilterQuery, model::ansi::Handler}; +use crate::terminal::{color, ssh, BlockPadding, ShellHost, SizeUpdate, SizeUpdateReason}; +use crate::terminal::{ShellLaunchData, ShellLaunchState}; +use crate::util::AsciiDebug; + +pub use crate::terminal::history::HistoryEntry; -use super::super::{AltScreen, BlockList}; use super::ansi::{ - BootstrappedValue, FinishUpdateValue, InputBufferValue, Mode, PendingHook, - TmuxInstallFailedInfo, WarpificationUnavailableReason, + FinishUpdateValue, InputBufferValue, Mode, PendingHook, TmuxInstallFailedInfo, + WarpificationUnavailableReason, }; use super::block::{ AgentInteractionMetadata, Block, BlockId, BlockMetadata, BlockSize, BlockState, @@ -51,43 +48,50 @@ use super::secrets::{RespectObfuscatedSecrets, SecretAndHandle}; use super::selection::ScrollDelta; use super::session::{BootstrapSessionType, InBandCommandOutputReceiver, SessionId}; use super::tmux::commands::TmuxCommand; -use super::{tmux, Secret, SecretHandle}; -use crate::ai::ambient_agents::AmbientAgentTaskId; -use crate::ai::blocklist::SerializedBlockListItem; -use crate::terminal::available_shells::AvailableShell; -use crate::terminal::block_filter::BlockFilterQuery; -use crate::terminal::block_list_element::GridType; -use crate::terminal::event::{ - BootstrappedEvent, Event, ExecutedExecutorCommandEvent, InitSshEvent, InitSubshellEvent, - SourcedRcFileInSubshellEvent, SshLoginStatus, TerminalMode, +use super::{ + super::{AltScreen, BlockList}, + ansi::BootstrappedValue, }; -use crate::terminal::event_listener::ChannelEventListener; -pub use crate::terminal::history::HistoryEntry; -use crate::terminal::model::ansi; +use super::{tmux, Secret, SecretHandle}; use crate::terminal::model::ansi::{ - ClearValue, CommandFinishedValue, ExitShellValue, Handler, InitShellValue, InitSshValue, + ClearValue, CommandFinishedValue, ExitShellValue, InitShellValue, InitSshValue, InitSubshellValue, PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, SSHValue, SourcedRcFileForWarpValue, }; -use crate::terminal::model::bootstrap::BootstrapStage; -use crate::terminal::model::completions::{ - ShellCompletion, ShellCompletionUpdate, ShellData as CompletionsShellData, -}; -use crate::terminal::model::escape_sequences::ModeProvider; use crate::terminal::model::grid::IndexRegion; -use crate::terminal::model::index::VisibleRow; -use crate::terminal::model::iterm_image::{ITermImage, ITermImageMetadata}; -use crate::terminal::model::secrets::ObfuscateSecrets; use crate::terminal::model::session::SessionInfo; -use crate::terminal::shared_session::ai_agent::encode_agent_response_event; -use crate::terminal::shared_session::SharedSessionStatus; use crate::terminal::shell::{ShellName, ShellType}; -use crate::terminal::ssh::util::{InteractiveSshCommand, SshLoginState}; -use crate::terminal::{ - color, ssh, BlockPadding, ShellHost, ShellLaunchData, ShellLaunchState, SizeUpdate, - SizeUpdateReason, + +use crate::terminal::model::secrets::ObfuscateSecrets; +use session_sharing_protocol::sharer::SessionSourceType; +use warp_core::report_error; +#[cfg(not(target_family = "wasm"))] +use warpui::util::save_as_file; + +use async_channel::Sender; +use base64::Engine; +use hex::FromHexError; +use instant::Instant; +use itertools::{Either, Itertools}; +use serde::Serialize; +use session_sharing_protocol::common::{ + AICommandMetadata, OrderedTerminalEventType, ParticipantId, }; -use crate::util::AsciiDebug; +use std::cmp::{max, min}; +use std::collections::HashMap; +use std::num::ParseIntError; +use std::ops::{Range, RangeInclusive}; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; +use warp_core::features::FeatureFlag; +use warp_core::semantic_selection::SemanticSelection; +pub use warp_terminal::model::BlockIndex; +use warp_terminal::model::{KeyboardModes, KeyboardModesApplyBehavior}; +use warpui::assets::asset_cache::Asset; +use warpui::image_cache::ImageType; +use warpui::r#async::executor::Background; +use warpui::AppContext; /// Max size of the window title stack. const TITLE_STACK_MAX_DEPTH: usize = 4096; @@ -563,9 +567,9 @@ pub struct TerminalModel { shared_session_status: SharedSessionStatus, - /// The source type of the shared session (if this is a shared session). - /// If it is not a shared session, this will be `None`. - shared_session_source_type: Option, + /// `SessionSourceType` paired with `source_task_id`, or `None` when + /// this is not a shared session. + shared_session_source: Option, /// Whether this terminal model was created as a cloud mode dummy session /// (no local shell process, deferred shared-session viewer backing). @@ -1153,7 +1157,7 @@ impl TerminalModel { shell_launch_state: shell_state, obfuscate_secrets, shared_session_status, - shared_session_source_type: None, + shared_session_source: None, is_dummy_cloud_mode_session, conversation_transcript_viewer_status: None, ordered_terminal_events_for_shared_session_tx: None, @@ -1421,15 +1425,24 @@ impl TerminalModel { self.is_receiving_agent_conversation_replay = value; } - pub fn set_shared_session_source_type( - &mut self, - set_shared_session_source_type: SessionSourceType, - ) { - self.shared_session_source_type = Some(set_shared_session_source_type); + pub fn set_shared_session_source(&mut self, source: SharedSessionSource) { + self.shared_session_source = Some(source); + } + + pub fn shared_session_source(&self) -> Option<&SharedSessionSource> { + self.shared_session_source.as_ref() } pub fn shared_session_source_type(&self) -> Option { - self.shared_session_source_type.clone() + self.shared_session_source + .as_ref() + .map(|s| s.source_type.clone()) + } + + pub fn set_shared_session_source_task_id(&mut self, task_id: Option) { + if let Some(source) = self.shared_session_source.as_mut() { + source.source_task_id = task_id; + } } pub fn is_dummy_cloud_mode_session(&self) -> bool { @@ -1443,26 +1456,21 @@ impl TerminalModel { pub fn is_shared_ambient_agent_session(&self) -> bool { matches!( - self.shared_session_source_type, + self.shared_session_source.as_ref().map(|s| &s.source_type), Some(SessionSourceType::AmbientAgent { .. }) ) } pub fn ambient_agent_task_id(&self) -> Option { - // Check if we're viewing an ambient agent conversation transcript if let Some(ConversationTranscriptViewerStatus::ViewingAmbientConversation(task_id)) = &self.conversation_transcript_viewer_status { return Some(*task_id); } - - // Otherwise, check if we're in a shared ambient agent session - if let Some(SessionSourceType::AmbientAgent { task_id }) = &self.shared_session_source_type - { - task_id.as_deref().and_then(|s| s.parse().ok()) - } else { - None - } + self.shared_session_source + .as_ref() + .and_then(|s| s.orchestrator_task_id()) + .and_then(|s| s.parse().ok()) } /// Loads the provided scrollback into the model. diff --git a/app/src/terminal/shared_session/mod.rs b/app/src/terminal/shared_session/mod.rs index ad72922223..309d34c311 100644 --- a/app/src/terminal/shared_session/mod.rs +++ b/app/src/terminal/shared_session/mod.rs @@ -41,6 +41,46 @@ pub const COPY_LINK_TEXT: &str = "Sharing link copied"; /// most up to date will always be sent after some delay) const SELECTION_THROTTLE_PERIOD: Duration = Duration::from_millis(20); +/// `SessionSourceType` paired with the orchestrator `task_id` that rides +/// on the `source_task_id` sidecar. +#[derive(Debug, Clone)] +pub struct SharedSessionSource { + pub source_type: SessionSourceType, + pub source_task_id: Option, +} + +impl SharedSessionSource { + pub fn user(source_task_id: Option) -> Self { + Self { + source_type: SessionSourceType::User, + source_task_id, + } + } + + pub fn ambient_agent(task_id: Option) -> Self { + Self { + source_type: SessionSourceType::AmbientAgent { + task_id: task_id.clone(), + }, + source_task_id: task_id, + } + } + + /// Sidecar first, then `AmbientAgent.task_id` for legacy producers. + pub fn orchestrator_task_id(&self) -> Option<&str> { + self.source_task_id.as_deref().or(match &self.source_type { + SessionSourceType::AmbientAgent { task_id } => task_id.as_deref(), + SessionSourceType::User => None, + }) + } +} + +impl Default for SharedSessionSource { + fn default() -> Self { + Self::user(None) + } +} + /// Whether or not a local session is also being shared. /// Since a shared session creator is also the creator of a local session, /// we make use of the local_tty::TerminalManager for shared session creators. @@ -48,9 +88,8 @@ const SELECTION_THROTTLE_PERIOD: Duration = Duration::from_millis(20); /// and a regular, purely local session. #[derive(Debug, Clone, Default)] pub enum IsSharedSessionCreator { - /// This session should be shared automatically once bootstrapped, using the - /// provided source type. - Yes { source_type: SessionSourceType }, + /// This session should be shared automatically once bootstrapped. + Yes { source: SharedSessionSource }, #[default] No, } @@ -75,9 +114,9 @@ pub enum SharedSessionStatus { FinishedViewer, /// We haven't yet attempted to share the session because it is not bootstrapped yet. - /// The `source_type` encodes what kind of shared session will be created once the - /// session finishes bootstrapping. - SharePendingPreBootstrap { source_type: SessionSourceType }, + /// The `source` encodes what kind of shared session will be created once + /// the session finishes bootstrapping. + SharePendingPreBootstrap { source: SharedSessionSource }, /// The session is bootstrapped and we're in the process of /// sharing the session but have not yet established the diff --git a/app/src/terminal/shared_session/sharer/network.rs b/app/src/terminal/shared_session/sharer/network.rs index c7d066b956..e4203fb426 100644 --- a/app/src/terminal/shared_session/sharer/network.rs +++ b/app/src/terminal/shared_session/sharer/network.rs @@ -36,9 +36,9 @@ use warpui::{Entity, ModelContext, ModelHandle, RequestState, RetryOption, Singl use websocket::{Message, Sink, Stream, WebSocket, WebsocketMessage as _}; #[cfg(not(any(test, feature = "integration_tests")))] use { + crate::terminal::shared_session::SharedSessionSource, crate::{report_error, server::telemetry::telemetry_context}, session_sharing_protocol::common::{Scrollback, TelemetryContext}, - session_sharing_protocol::sharer::SessionSourceType, session_sharing_protocol::sharer::{InitPayload, Lifetime}, }; @@ -224,7 +224,7 @@ impl Network { terminal_view_id: warpui::EntityId, universal_developer_input_context: UniversalDeveloperInputContext, lifetime: Lifetime, - source_type: SessionSourceType, + source: SharedSessionSource, ctx: &mut ModelContext, ) -> Self { let (ws_proxy_tx, ws_proxy_rx) = async_channel::unbounded(); @@ -291,7 +291,7 @@ impl Network { terminal_view_id, universal_developer_input_context, lifetime, - source_type, + source, ctx, ); } @@ -587,7 +587,7 @@ impl Network { terminal_view_id: warpui::EntityId, universal_developer_input_context: UniversalDeveloperInputContext, lifetime: Lifetime, - source_type: SessionSourceType, + source: SharedSessionSource, ctx: &mut ModelContext, ) { let auth_client = ServerApiProvider::as_ref(ctx).get_auth_client(); @@ -640,7 +640,8 @@ impl Network { ..universal_developer_input_context }), lifetime, - source_type, + source_type: source.source_type, + source_task_id: source.source_task_id, feature_support: FeatureSupport { supports_agent_view: FeatureFlag::AgentView.is_enabled(), supports_full_role: true, diff --git a/app/src/terminal/shared_session/viewer/network.rs b/app/src/terminal/shared_session/viewer/network.rs index e8de6f85a6..ba28712a4b 100644 --- a/app/src/terminal/shared_session/viewer/network.rs +++ b/app/src/terminal/shared_session/viewer/network.rs @@ -2,54 +2,61 @@ //! connect to and communicate with the shared session. //! Adheres to the [`session-sharing-protocol`]. -use std::pin::pin; -use std::sync::Arc; -use std::time::Duration; - use anyhow::bail; use async_channel::Receiver; -use futures_util::stream::AbortHandle; -use futures_util::{SinkExt, StreamExt}; use instant::Instant; +use std::{pin::pin, sync::Arc}; +use warpui::r#async::{SpawnedFutureHandle, Timer}; + +use futures_util::{stream::AbortHandle, SinkExt, StreamExt}; + use parking_lot::FairMutex; -use session_sharing_protocol::common::{ - ActivePrompt, ActivePromptUpdate, AddGuestsResponse, AgentAttachment, AgentPromptFailureReason, - AgentPromptRequest, AgentPromptRequestId, CommandExecutionFailureReason, ControlAction, - ControlActionFailureReason, FeatureSupport, InputOperationId, InputOperationSeqNo, InputUpdate, - LinkAccessLevelUpdateResponse, ParticipantId, ParticipantList, ParticipantPresenceUpdate, - RemoveGuestResponse, Role, RoleRequestId, RoleRequestResponse, Selection, SelectionUpdate, - ServerConversationToken, SessionId, TeamAccessLevelUpdateResponse, TeamAclData, - TelemetryContext, UniversalDeveloperInputContext, UniversalDeveloperInputContextUpdate, - UpdatePendingUserRoleResponse, UserID, WindowSize, WriteToPtyFailureReason, - WriteToPtyRequestId, WriteToPtySeqNo, -}; -use session_sharing_protocol::sharer::SessionSourceType; -use session_sharing_protocol::viewer::{ - DownstreamMessage, InitPayload, RoleUpdatedReason, SessionEndedReason, UpstreamMessage, - ViewerRemovedReason, +use session_sharing_protocol::{ + common::{ + ActivePrompt, ActivePromptUpdate, AddGuestsResponse, AgentAttachment, + AgentPromptFailureReason, AgentPromptRequest, AgentPromptRequestId, + CommandExecutionFailureReason, ControlAction, ControlActionFailureReason, FeatureSupport, + InputOperationId, InputOperationSeqNo, InputUpdate, LinkAccessLevelUpdateResponse, + ParticipantId, ParticipantList, ParticipantPresenceUpdate, RemoveGuestResponse, Role, + RoleRequestId, RoleRequestResponse, Selection, SelectionUpdate, ServerConversationToken, + SessionId, TeamAccessLevelUpdateResponse, TeamAclData, TelemetryContext, + UniversalDeveloperInputContext, UniversalDeveloperInputContextUpdate, + UpdatePendingUserRoleResponse, UserID, WindowSize, WriteToPtyFailureReason, + WriteToPtyRequestId, WriteToPtySeqNo, + }, + viewer::{ + DownstreamMessage, InitPayload, RoleUpdatedReason, SessionEndedReason, UpstreamMessage, + ViewerRemovedReason, + }, }; + +use std::time::Duration; use warp_core::features::FeatureFlag; -use warpui::r#async::{SpawnedFutureHandle, Timer}; use warpui::{ Entity, ModelContext, ModelHandle, RequestState, RetryOption, SingletonEntity, WeakViewHandle, }; use websocket::{Message, Sink, Stream, WebsocketMessage as _}; -use crate::auth::auth_state::AuthState; -use crate::auth::{AuthStateProvider, UserUid}; -use crate::editor::{CrdtOperation, ReplicaId}; -use crate::server::server_api::auth::AuthClient; -use crate::server::server_api::ServerApiProvider; -use crate::server::telemetry::telemetry_context; -use crate::terminal::event_listener::ChannelEventListener; -use crate::terminal::model::block::BlockId; -use crate::terminal::shared_session::network::heartbeat::{Event as HeartbeatEvent, Heartbeat}; -use crate::terminal::shared_session::viewer::event_loop::{ - EventLoop, SharedSessionInitialLoadMode, +use crate::{ + auth::{auth_state::AuthState, AuthStateProvider, UserUid}, + editor::{CrdtOperation, ReplicaId}, + server::{ + server_api::{auth::AuthClient, ServerApiProvider}, + telemetry::telemetry_context, + }, + terminal::{ + event_listener::ChannelEventListener, + model::block::BlockId, + shared_session::{ + connect_endpoint, + network::heartbeat::{Event as HeartbeatEvent, Heartbeat}, + viewer::event_loop::{EventLoop, SharedSessionInitialLoadMode}, + EventNumber, SharedSessionSource, SELECTION_THROTTLE_PERIOD, + }, + TerminalModel, TerminalView, + }, + throttle::throttle, }; -use crate::terminal::shared_session::{connect_endpoint, EventNumber, SELECTION_THROTTLE_PERIOD}; -use crate::terminal::{TerminalModel, TerminalView}; -use crate::throttle::throttle; /// The amount of time we will wait to batch consecutive write to pty requests before sending an event to the server. const PTY_WRITES_BATCH_THRESHOLD: Duration = if cfg!(test) { @@ -256,7 +263,7 @@ impl Network { participant_list: Default::default(), input_replica_id: ReplicaId::random(), universal_developer_input_context: None, - source_type: SessionSourceType::default(), + source: SharedSessionSource::default(), }); model.start_write_to_pty_events_listener(write_to_pty_events_rx, ctx); @@ -525,8 +532,13 @@ impl Network { // We use the more detailed source type here, // ignoring the legacy source_type field (which was kept around for backwards compatibility). detailed_source_type: source_type, + source_task_id, .. } => { + let source = SharedSessionSource { + source_type, + source_task_id, + }; if matches!(self.stage, Stage::JoinedSuccessfully) { log::warn!( "Received unexpected JoinedSuccessfully message when we've already joined" @@ -563,7 +575,7 @@ impl Network { participant_list: Box::new(*participant_list), input_replica_id: input_replica_id.into(), universal_developer_input_context, - source_type, + source, }); } DownstreamMessage::RejoinedSuccessfully { participant_list } => { @@ -1106,7 +1118,7 @@ pub enum NetworkEvent { participant_list: Box, input_replica_id: ReplicaId, universal_developer_input_context: Option, - source_type: SessionSourceType, + source: SharedSessionSource, }, FailedToJoin { reason: FailedToJoinReason, diff --git a/app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs b/app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs index 36addf1260..f73eccb49d 100644 --- a/app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs +++ b/app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs @@ -4,8 +4,8 @@ //! //! 1. Calls `GET /agent/runs?ancestor_run_id={task_id}` to discover child agents. //! 2. Creates a local conversation for each child via [`BlocklistAIHistoryModel`] -//! marked as `is_viewing_shared_session = true` so [`crate::ai::blocklist::task_status_sync_model::TaskStatusSyncModel`] -//! does not report viewer-side status transitions back to the server. +//! marked as `is_viewing_shared_session = true` so server-side status +//! reporters do not echo viewer-side state back to the server. //! 3. Polls the children list periodically (~5s) until all reach a terminal //! state, updating each child conversation's [`ConversationStatus`] when the //! server-side state changes. @@ -19,10 +19,6 @@ //! 5. Pill clicks navigate via `SwapPaneToConversation` (the existing //! local-orchestration mechanism), swapping the parent pane for the //! hidden child pane. -//! -//! The model itself owns no `Network`s and does not subscribe to history -//! events for navigation; it is purely a children poller + materialization -//! trigger. use std::collections::HashMap; use std::time::Duration; @@ -62,7 +58,8 @@ struct ChildAgentEntry { /// Owns child discovery + status polling for a shared session viewer of an /// orchestrated session. pub struct OrchestrationViewerModel { - /// Orchestrator run id; used as the `ancestor_run_id` fetch filter. + /// `ancestor_run_id` filter for REST fetches: the orchestrator's own + /// run id. parent_task_id: AmbientAgentTaskId, /// Owns the child conversations and anchors the orchestrator lookup. terminal_view_id: EntityId, @@ -76,6 +73,10 @@ pub struct OrchestrationViewerModel { /// Bumped before each fetch; stale responses (older generation) are /// dropped so a slow timer-fired fetch can't clobber a fresher kick. fetch_generation: u64, + /// Set when the most recent fetch returned no children; we wait for + /// an `AppendedExchange` on the orchestrator before polling again. + /// Distinct from the in-flight state (`polling_handle = None`). + idle_due_to_no_children: bool, } impl Entity for OrchestrationViewerModel { @@ -97,6 +98,7 @@ impl OrchestrationViewerModel { // 30s idle poll. ctx.subscribe_to_model(&BlocklistAIHistoryModel::handle(ctx), |me, event, ctx| { me.maybe_kick_polling(event, ctx); + me.maybe_backfill_parent_agent_ids(event, ctx); }); let mut model = Self { @@ -106,6 +108,7 @@ impl OrchestrationViewerModel { children: HashMap::new(), polling_handle: None, fetch_generation: 0, + idle_due_to_no_children: false, }; // Each fetch reschedules itself via its response callback. @@ -114,7 +117,9 @@ impl OrchestrationViewerModel { } /// Schedules the next poll: fast cadence while any child is - /// non-terminal, slow cadence once all are terminal. + /// non-terminal, slow once all are terminal. Skipped while + /// [`Self::idle_due_to_no_children`] is set; [`Self::maybe_kick_polling`] + /// resumes on the next orchestrator `AppendedExchange`. fn schedule_next_poll(&mut self, ctx: &mut ModelContext) { // `SpawnedFutureHandle` doesn't abort on drop, so abort // explicitly to avoid stacking parallel timer chains. @@ -122,6 +127,13 @@ impl OrchestrationViewerModel { prior.abort(); } + // Stay idle until an `AppendedExchange` on the orchestrator wakes + // us up. `apply_children_fetch` is responsible for setting this + // flag when an empty descendant list comes back. + if self.idle_due_to_no_children { + return; + } + let all_terminal = !self.children.is_empty() && self .children @@ -142,10 +154,11 @@ impl OrchestrationViewerModel { self.polling_handle = Some(handle); } - /// Tightens polling on `AppendedExchange` for a tracked conversation, - /// but only during the idle→active transition — while we're already - /// polling fast, every received exchange would otherwise add an - /// unnecessary REST request. + /// Tightens polling on `AppendedExchange` during the idle→active + /// transition, and resumes from `idle_due_to_no_children` on an + /// orchestrator-scoped exchange. The idle-resume check runs first + /// because it would otherwise be conflated with the + /// "fetch in flight" state by the `polling_handle.is_none()` guard. fn maybe_kick_polling( &mut self, event: &BlocklistAIHistoryEvent, @@ -157,6 +170,24 @@ impl OrchestrationViewerModel { else { return; }; + let conversation_id = *conversation_id; + let is_orchestrator = self.find_parent_conversation_id(ctx) == Some(conversation_id); + + // Resume from idle-due-to-no-children. Only orchestrator-scoped + // exchanges count: child events are ignored because we have no + // tracked children to update yet, and an unrelated conversation's + // exchange does not imply this orchestrator just spawned a child. + if self.idle_due_to_no_children { + if is_orchestrator { + self.idle_due_to_no_children = false; + if let Some(prior) = self.polling_handle.take() { + prior.abort(); + } + self.fetch_children(ctx); + } + return; + } + let all_terminal = !self.children.is_empty() && self .children @@ -165,13 +196,12 @@ impl OrchestrationViewerModel { if !all_terminal { return; } - // `polling_handle = None` means a kick fetch is already in flight; - // skipping here prevents pile-up when exchanges arrive in bursts. + // `polling_handle = None` here means a kick fetch is already in + // flight (the idle-due-to-no-children case is handled above); + // skipping prevents pile-up when exchanges arrive in bursts. if self.polling_handle.is_none() { return; } - let conversation_id = *conversation_id; - let is_orchestrator = self.find_parent_conversation_id(ctx) == Some(conversation_id); let is_tracked_child = self .children .values() @@ -185,6 +215,52 @@ impl OrchestrationViewerModel { self.fetch_children(ctx); } + /// Backfills `parent_agent_id` on viewer-created children once the + /// orchestrator receives its server token / run id. First-poll + /// children are created with `parent_agent_id = None` because the + /// orchestrator hasn't been identified yet; this fixes them up so + /// `parent_conversation_id` resolution works. + fn maybe_backfill_parent_agent_ids( + &mut self, + event: &BlocklistAIHistoryEvent, + ctx: &mut ModelContext, + ) { + let BlocklistAIHistoryEvent::ConversationServerTokenAssigned { + conversation_id, .. + } = event + else { + return; + }; + let conversation_id = *conversation_id; + if self.find_parent_conversation_id(ctx) != Some(conversation_id) { + return; + } + let history_handle = BlocklistAIHistoryModel::handle(ctx); + let parent_agent_id = history_handle + .as_ref(ctx) + .conversation(&conversation_id) + .and_then(|c| c.orchestration_agent_id()); + let Some(parent_agent_id) = parent_agent_id else { + return; + }; + let child_conversation_ids: Vec = self + .children + .values() + .map(|child| child.conversation_id) + .collect(); + history_handle.update(ctx, |history, _ctx| { + for child_id in child_conversation_ids { + let Some(child) = history.conversation_mut(&child_id) else { + continue; + }; + if child.parent_agent_id().is_some() { + continue; + } + child.set_parent_agent_id(parent_agent_id.clone()); + } + }); + } + /// Issues a `GET /agent/runs?ancestor_run_id={parent_task_id}` request /// and routes the response into [`Self::apply_children_fetch`]. Errors /// are logged and ignored; the next poll retries. @@ -228,10 +304,10 @@ impl OrchestrationViewerModel { ); } - /// Consumes a children list response, creating new local conversations - /// for previously-unseen children and updating statuses + session ids - /// on existing ones. Requests pane materialization for any child whose - /// `session_id` is freshly known. + /// Consumes a children-list response: creates conversations for new + /// children, updates status / session_id on existing ones, and + /// requests pane materialization for any child whose `session_id` is + /// freshly known. fn apply_children_fetch(&mut self, tasks: Vec, ctx: &mut ModelContext) { let history_handle = BlocklistAIHistoryModel::handle(ctx); @@ -322,11 +398,23 @@ impl OrchestrationViewerModel { // disambiguates viewer-spawned children downstream. history.set_viewing_shared_session_for_conversation(conversation_id, true); if let Some(conversation) = history.conversation_mut(&conversation_id) { - conversation.set_task_id(task_id); if !fallback_title.is_empty() { conversation.set_fallback_display_title(fallback_title); } } + // Stamp the child's `run_id` / `task_id` and populate the + // `agent_id_to_conversation_id` index so transcript references + // (received-message, send-message, lifecycle blocks) resolve + // to this child via `conversation_id_for_agent_id`. Replaces + // the earlier `set_task_id` call, which set the conversation + // field but never updated the reverse index. + history.assign_run_id_for_conversation( + conversation_id, + task_id.to_string(), + Some(task_id), + terminal_view_id, + ctx, + ); history.update_conversation_status( terminal_view_id, conversation_id, @@ -351,6 +439,20 @@ impl OrchestrationViewerModel { ); } + // Polling-cost mitigation: if no children are tracked after this + // fetch, stop scheduling timers. The resume signal is an + // `AppendedExchange` on the orchestrator (see + // `maybe_kick_polling`). `schedule_next_poll` honours this flag + // and bails before spawning a new timer. + if self.children.is_empty() { + self.idle_due_to_no_children = true; + if let Some(prior) = self.polling_handle.take() { + prior.abort(); + } + } else { + self.idle_due_to_no_children = false; + } + // Dispatch materialization events outside the children-borrow. for (conversation_id, session_id) in to_materialize { self.request_child_pane_materialization(conversation_id, session_id, ctx); diff --git a/app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs b/app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs index c5e5c55a33..09d1d750ec 100644 --- a/app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs +++ b/app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs @@ -11,13 +11,16 @@ //! are not directly tested — they're thin wrappers that funnel responses //! through `apply_children_fetch`, which is what we cover here. +use super::*; + use chrono::Utc; +use warp_core::features::FeatureFlag; use warpui::{App, EntityId, SingletonEntity}; -use super::*; +use crate::ai::agent::task::TaskId; +use crate::ai::agent::AIAgentExchangeId; use crate::ai::ambient_agents::task::{AgentConfigSnapshot, AmbientAgentTask}; -use crate::test_util::add_window_with_terminal; -use crate::test_util::terminal::initialize_app_for_terminal_view; +use crate::test_util::{add_window_with_terminal, terminal::initialize_app_for_terminal_view}; // ---- Pure-function tests ---------------------------------------------------- @@ -169,6 +172,7 @@ fn setup_model( children: HashMap::new(), polling_handle: None, fetch_generation: 0, + idle_due_to_no_children: false, }; (terminal_view_id, parent_conversation_id, model) @@ -285,6 +289,7 @@ fn skips_child_when_no_active_parent_conversation() { children: HashMap::new(), polling_handle: None, fetch_generation: 0, + idle_due_to_no_children: false, }; let model_handle = app.add_model(|_| model); @@ -682,3 +687,500 @@ fn registers_multiple_children() { }); }); } + +// ---- agent_id_to_conversation_id population -------------------------------- + +#[test] +fn b1_populates_agent_id_to_conversation_id_for_new_child() { + // After `apply_children_fetch` registers a new viewer-created child, + // `BlocklistAIHistoryModel::conversation_id_for_agent_id` resolves the + // child's `run_id` back to the local child conversation so sibling + // references in transcript bodies render display names instead of + // "Unknown agent". + App::test((), |mut app| async move { + // `agent_id_key` reads `AIConversation::orchestration_agent_id`, + // which only returns the `run_id` when OrchestrationV2 is enabled. + // Without this override, the v1 fallback is `server_conversation_token`, + // which the test doesn't populate. + let _v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true); + let parent = task_id(PARENT_TASK_ID); + let (_, _, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + + model_handle.update(&mut app, |model, ctx| { + model.apply_children_fetch( + vec![make_task( + CHILD_A_TASK_ID, + AmbientAgentTaskState::InProgress, + "Worker", + None, + )], + ctx, + ); + }); + + let history = BlocklistAIHistoryModel::handle(&app); + let child_conversation_id = model_handle.read(&app, |model, _| { + model + .children + .get(&task_id(CHILD_A_TASK_ID)) + .expect("child registered") + .conversation_id + }); + history.read(&app, |history, _| { + // The child's run_id matches its task_id under v2 (and is the + // string form of the same AmbientAgentTaskId in either case). + let child_run_id = task_id(CHILD_A_TASK_ID).to_string(); + assert_eq!( + history.conversation_id_for_agent_id(&child_run_id), + Some(child_conversation_id), + "sibling references via run_id must resolve to the child conversation", + ); + }); + }); +} + +// ---- parent_agent_id backfill ---------------------------------------------- + +#[test] +fn b2_backfills_parent_agent_id_on_orchestrator_token_assigned() { + // When the orchestrator's local conversation doesn't have an + // `orchestration_agent_id` yet at child-creation time, the + // viewer-created child's `parent_agent_id` stays `None`. When the + // orchestrator subsequently receives its run id (via + // `assign_run_id_for_conversation`), the model should backfill + // `parent_agent_id` on every tracked child so + // `orchestration_conversation_links::parent_conversation_id` resolves + // back to the orchestrator. + App::test((), |mut app| async move { + let _v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true); + let parent = task_id(PARENT_TASK_ID); + let (terminal_view_id, parent_conv_id, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + + // Step 1: register a child while the parent has no orchestration + // agent id. The child's `parent_agent_id` must be `None`. + model_handle.update(&mut app, |model, ctx| { + model.apply_children_fetch( + vec![make_task( + CHILD_A_TASK_ID, + AmbientAgentTaskState::InProgress, + "Worker", + None, + )], + ctx, + ); + }); + let history = BlocklistAIHistoryModel::handle(&app); + let child_conversation_id = history.read(&app, |history, _| { + let child_ids = history.child_conversation_ids_of(&parent_conv_id); + assert_eq!(child_ids.len(), 1, "one child registered"); + let child = history + .conversation(&child_ids[0]) + .expect("child conversation exists"); + assert!( + child.parent_agent_id().is_none(), + "parent_agent_id should be unset before the orchestrator has a run id", + ); + child_ids[0] + }); + + // Step 2: assign the parent's run id. `assign_run_id_for_conversation` + // emits `ConversationServerTokenAssigned`, which fires the model's + // subscription. Since `setup_model` bypasses the constructor (and + // therefore the subscription wiring), call the handler directly. + let parent_run_id = parent.to_string(); + history.update(&mut app, |history, ctx| { + history.assign_run_id_for_conversation( + parent_conv_id, + parent_run_id.clone(), + Some(parent), + terminal_view_id, + ctx, + ); + }); + let synthetic_event = BlocklistAIHistoryEvent::ConversationServerTokenAssigned { + conversation_id: parent_conv_id, + terminal_view_id, + }; + model_handle.update(&mut app, |model, ctx| { + model.maybe_backfill_parent_agent_ids(&synthetic_event, ctx); + }); + + // Step 3: the child's `parent_agent_id` is now stamped with the + // orchestrator's run id, so `parent_agent_id`-based resolution can + // walk back up to the parent. + history.read(&app, |history, _| { + let child = history + .conversation(&child_conversation_id) + .expect("child conversation exists"); + assert_eq!( + child.parent_agent_id(), + Some(parent_run_id.as_str()), + "parent_agent_id should be backfilled to the orchestrator's run id", + ); + }); + }); +} + +#[test] +fn b2_does_not_overwrite_existing_parent_agent_id() { + // The backfill is a one-way upgrade. Children whose `parent_agent_id` + // is already set (e.g. created after the orchestrator already had a + // run id) must not be clobbered. + App::test((), |mut app| async move { + let _v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true); + let parent = task_id(PARENT_TASK_ID); + let (terminal_view_id, parent_conv_id, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + + // Pre-seed the orchestrator with a run id so the child created + // below picks it up immediately. + let original_parent_run_id = parent.to_string(); + let history = BlocklistAIHistoryModel::handle(&app); + history.update(&mut app, |history, ctx| { + history.assign_run_id_for_conversation( + parent_conv_id, + original_parent_run_id.clone(), + Some(parent), + terminal_view_id, + ctx, + ); + }); + model_handle.update(&mut app, |model, ctx| { + model.apply_children_fetch( + vec![make_task( + CHILD_A_TASK_ID, + AmbientAgentTaskState::InProgress, + "Worker", + None, + )], + ctx, + ); + }); + let child_conversation_id = history.read(&app, |history, _| { + let child_ids = history.child_conversation_ids_of(&parent_conv_id); + child_ids[0] + }); + + // Now fire a backfill: the existing `parent_agent_id` must stay. + let synthetic_event = BlocklistAIHistoryEvent::ConversationServerTokenAssigned { + conversation_id: parent_conv_id, + terminal_view_id, + }; + model_handle.update(&mut app, |model, ctx| { + model.maybe_backfill_parent_agent_ids(&synthetic_event, ctx); + }); + history.read(&app, |history, _| { + let child = history + .conversation(&child_conversation_id) + .expect("child conversation exists"); + assert_eq!( + child.parent_agent_id(), + Some(original_parent_run_id.as_str()), + ); + }); + }); +} + +#[test] +fn b2_ignores_token_assigned_for_unrelated_conversation() { + // Events for other conversations (e.g. the user's local conversation + // in another tab) must not trigger backfill on this model's children. + App::test((), |mut app| async move { + let _v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true); + let parent = task_id(PARENT_TASK_ID); + let (terminal_view_id, parent_conv_id, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + + model_handle.update(&mut app, |model, ctx| { + model.apply_children_fetch( + vec![make_task( + CHILD_A_TASK_ID, + AmbientAgentTaskState::InProgress, + "Worker", + None, + )], + ctx, + ); + }); + + // Synthesize an event for some unrelated conversation id; the + // backfill handler must short-circuit on the parent-mismatch check. + let unrelated_event = BlocklistAIHistoryEvent::ConversationServerTokenAssigned { + conversation_id: AIConversationId::new(), + terminal_view_id, + }; + model_handle.update(&mut app, |model, ctx| { + model.maybe_backfill_parent_agent_ids(&unrelated_event, ctx); + }); + + // Belt-and-braces: ensure the parent's lookup short-circuits when + // the orchestrator id is still unknown. + let still_no_parent_id = BlocklistAIHistoryEvent::ConversationServerTokenAssigned { + conversation_id: parent_conv_id, + terminal_view_id, + }; + model_handle.update(&mut app, |model, ctx| { + model.maybe_backfill_parent_agent_ids(&still_no_parent_id, ctx); + }); + + let history = BlocklistAIHistoryModel::handle(&app); + history.read(&app, |history, _| { + let child_ids = history.child_conversation_ids_of(&parent_conv_id); + let child = history.conversation(&child_ids[0]).unwrap(); + assert!( + child.parent_agent_id().is_none(), + "backfill must not run when orchestrator has no agent id yet", + ); + }); + }); +} + +// ---- child-link sibling preload -------------------------------------------- +// +// Removed: see specs/QUALITY-726/TECH.md §B4 for the deferral note. + +// ---- idle_due_to_no_children polling-cost mitigation ---------------------- + +/// Builds an [`AppendedExchange`] event for the given conversation, with +/// stub identifiers for the unrelated fields. Mirrors what the history +/// model would emit when a fresh exchange is appended; the model's +/// `maybe_kick_polling` handler only reads `conversation_id`. +fn make_appended_exchange_event( + conversation_id: AIConversationId, + terminal_view_id: EntityId, +) -> BlocklistAIHistoryEvent { + BlocklistAIHistoryEvent::AppendedExchange { + exchange_id: AIAgentExchangeId::new(), + task_id: TaskId::new("test-task".to_string()), + terminal_view_id, + conversation_id, + is_hidden: false, + response_stream_id: None, + } +} + +/// Spawns a long-lived no-op future and stores its handle on the model. +/// Used to populate `polling_handle` in tests so we can assert that the +/// polling-state machine aborts it when transitioning to the +/// idle-due-to-no-children state. +/// +/// `SpawnedFutureHandle::abort()` doesn't expose an observable side-effect +/// from outside the model, so the assertion target is +/// `model.polling_handle.is_none()` after the transition. The timer is +/// scheduled for an hour so it cannot fire during the test. +fn populate_polling_handle( + model: &mut OrchestrationViewerModel, + ctx: &mut ModelContext, +) { + let handle = ctx.spawn( + async { + Timer::after(Duration::from_secs(3600)).await; + }, + |_me, _, _ctx| {}, + ); + model.polling_handle = Some(handle); +} + +#[test] +fn empty_descendant_fetch_sets_idle_flag_and_aborts_polling() { + // When a non-orchestrator share's first descendant fetch returns no + // children, the viewer model sets `idle_due_to_no_children = true` + // and tears down its polling handle. `schedule_next_poll` later + // honours the flag and refuses to spawn another timer, so the model + // spends zero CPU / network until an `AppendedExchange` on the + // orchestrator wakes it up. + App::test((), |mut app| async move { + let parent = task_id(PARENT_TASK_ID); + let (_, _, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + + // Simulate an already-active polling cadence by pre-populating the + // handle. After the empty fetch this handle should be cleared. + model_handle.update(&mut app, |model, ctx| { + populate_polling_handle(model, ctx); + }); + model_handle.read(&app, |model, _| { + assert!( + model.polling_handle.is_some(), + "sanity: polling_handle populated for the test" + ); + assert!( + !model.idle_due_to_no_children, + "sanity: idle flag starts clear" + ); + }); + + // Server returns no descendants. `apply_children_fetch` is the + // sync portion of the fetch callback, so calling it directly + // exercises the polling-state transition without an HTTP round + // trip. + model_handle.update(&mut app, |model, ctx| { + model.apply_children_fetch(vec![], ctx); + }); + + model_handle.read(&app, |model, _| { + assert!( + model.idle_due_to_no_children, + "empty fetch must mark the model as idle-due-to-no-children" + ); + assert!( + model.polling_handle.is_none(), + "empty fetch must abort the prior polling handle and clear the field" + ); + assert!( + model.children.is_empty(), + "sanity: no children were registered" + ); + }); + }); +} + +#[test] +fn appended_exchange_on_orchestrator_resumes_from_idle() { + // Once the model has gone idle on an empty fetch, the next + // `AppendedExchange` on the orchestrator conversation must resume + // polling: clear the idle flag and call `fetch_children`. We observe + // `fetch_children` indirectly via `fetch_generation`, which is bumped + // synchronously at the head of the function before any spawn fires. + App::test((), |mut app| async move { + let parent = task_id(PARENT_TASK_ID); + let (terminal_view_id, parent_conv_id, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + + // Drive the model into the idle state via the same path the + // production fetch callback would: empty descendant list. + model_handle.update(&mut app, |model, ctx| { + model.apply_children_fetch(vec![], ctx); + }); + let generation_before_resume = model_handle.read(&app, |model, _| { + assert!( + model.idle_due_to_no_children, + "sanity: idle flag set after empty fetch" + ); + assert!( + model.polling_handle.is_none(), + "sanity: polling handle aborted" + ); + model.fetch_generation + }); + + // Fire an `AppendedExchange` against the orchestrator. The model + // resumes via the idle-due-to-no-children branch in + // `maybe_kick_polling`. + let event = make_appended_exchange_event(parent_conv_id, terminal_view_id); + model_handle.update(&mut app, |model, ctx| { + model.maybe_kick_polling(&event, ctx); + }); + + model_handle.read(&app, |model, _| { + assert!( + !model.idle_due_to_no_children, + "AppendedExchange on the orchestrator must clear the idle flag" + ); + assert_eq!( + model.fetch_generation, + generation_before_resume.wrapping_add(1), + "AppendedExchange on the orchestrator must call fetch_children, \ + which bumps fetch_generation by one", + ); + }); + }); +} + +#[test] +fn non_empty_fetch_clears_idle_flag_and_resumes_polling() { + // The complementary path: a fetch that *does* discover children + // clears the idle flag so subsequent `schedule_next_poll` calls go + // back to the active cadence. We then exercise `schedule_next_poll` + // directly to verify that, once the flag is clear, a new + // `polling_handle` is created. + App::test((), |mut app| async move { + let parent = task_id(PARENT_TASK_ID); + let (_, _, mut model) = setup_model(&mut app, parent); + // Manually start from the idle state so we can confirm the + // non-empty fetch clears it. + model.idle_due_to_no_children = true; + let model_handle = app.add_model(|_| model); + + model_handle.update(&mut app, |model, ctx| { + model.apply_children_fetch( + vec![make_task( + CHILD_A_TASK_ID, + AmbientAgentTaskState::InProgress, + "Worker", + None, + )], + ctx, + ); + }); + model_handle.read(&app, |model, _| { + assert!( + !model.idle_due_to_no_children, + "non-empty fetch must clear the idle flag" + ); + assert_eq!(model.children.len(), 1, "child was registered"); + }); + + // The fetch callback would normally call `schedule_next_poll` + // right after `apply_children_fetch`. Invoke it explicitly so we + // can assert that, with the flag now cleared, a new polling + // handle is installed. + model_handle.update(&mut app, |model, ctx| { + model.schedule_next_poll(ctx); + }); + model_handle.read(&app, |model, _| { + assert!( + model.polling_handle.is_some(), + "schedule_next_poll must spawn a new timer when not idle" + ); + }); + }); +} + +#[test] +fn appended_exchange_on_non_orchestrator_does_not_resume_idle() { + // Symmetric to the orchestrator-resume test: an exchange on an + // unrelated conversation (i.e. not the orchestrator tracked by this + // viewer) must not pull the model out of the idle-due-to-no-children + // state. The flag stays set and `fetch_children` is not invoked. + App::test((), |mut app| async move { + let parent = task_id(PARENT_TASK_ID); + let (terminal_view_id, _, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + + // Idle the model. + model_handle.update(&mut app, |model, ctx| { + model.apply_children_fetch(vec![], ctx); + }); + let generation_before_event = model_handle.read(&app, |model, _| { + assert!(model.idle_due_to_no_children, "sanity: model is idle"); + model.fetch_generation + }); + + // Fire an `AppendedExchange` for some unrelated conversation. The + // resume gate compares against the orchestrator id returned by + // `find_parent_conversation_id`, so a fresh id will not match. + let unrelated_conversation_id = AIConversationId::new(); + let event = make_appended_exchange_event(unrelated_conversation_id, terminal_view_id); + model_handle.update(&mut app, |model, ctx| { + model.maybe_kick_polling(&event, ctx); + }); + + model_handle.read(&app, |model, _| { + assert!( + model.idle_due_to_no_children, + "AppendedExchange on an unrelated conversation must NOT resume the model" + ); + assert_eq!( + model.fetch_generation, generation_before_event, + "fetch_children must not run when the resume gate doesn't match the orchestrator" + ); + assert!( + model.polling_handle.is_none(), + "polling handle must remain cleared while idle" + ); + }); + }); +} diff --git a/app/src/terminal/shared_session/viewer/terminal_manager.rs b/app/src/terminal/shared_session/viewer/terminal_manager.rs index dc9408cd18..cd477d20a0 100644 --- a/app/src/terminal/shared_session/viewer/terminal_manager.rs +++ b/app/src/terminal/shared_session/viewer/terminal_manager.rs @@ -728,11 +728,9 @@ impl TerminalManager { participant_list, input_replica_id, universal_developer_input_context, - source_type, + source, } => { - model - .lock() - .set_shared_session_source_type(source_type.clone()); + model.lock().set_shared_session_source(source.clone()); Self::handle_active_prompt_update( model.clone(), @@ -770,15 +768,12 @@ impl TerminalManager { return; }; - let ambient_task_id: Option = match &source_type { - SessionSourceType::AmbientAgent { task_id } => { - task_id.as_deref().and_then(|s| s.parse().ok()) - } - _ => None, - }; + let ambient_task_id: Option = source + .orchestrator_task_id() + .and_then(|s| s.parse().ok()); // Mark terminal view as a shared ambient agent session view. - if matches!(&source_type, SessionSourceType::AmbientAgent { .. }) { + if matches!(&source.source_type, SessionSourceType::AmbientAgent { .. }) { let terminal_view_id = view.id(); BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _ctx| { history.mark_terminal_view_as_ambient_agent_session_view(terminal_view_id); @@ -789,26 +784,27 @@ impl TerminalManager { ActiveAgentViewsModel::handle(ctx).update(ctx, |model, ctx| { model.register_ambient_session(terminal_view_id, task_id, ctx); }); + } + } - // Spin up the orchestration viewer model on first - // join (`is_none()` guards against reconnect dupes). - if enable_orchestration_polling - && FeatureFlag::OrchestrationViewerPillBar.is_enabled() - && orchestration_viewer_model.lock().is_none() - { - let weak_view_handle_for_orch = weak_view_handle.clone(); - let orchestration_viewer_model_slot = - orchestration_viewer_model.clone(); - let handle = ctx.add_model(|model_ctx| { - OrchestrationViewerModel::new( - task_id, - terminal_view_id, - weak_view_handle_for_orch, - model_ctx, - ) - }); - *orchestration_viewer_model_slot.lock() = Some(handle); - } + if enable_orchestration_polling + && FeatureFlag::OrchestrationViewerPillBar.is_enabled() + && orchestration_viewer_model.lock().is_none() + { + if let Some(task_id) = ambient_task_id { + let terminal_view_id = view.id(); + let weak_view_handle_for_orch = weak_view_handle.clone(); + let orchestration_viewer_model_slot = + orchestration_viewer_model.clone(); + let model = ctx.add_model(|model_ctx| { + OrchestrationViewerModel::new( + task_id, + terminal_view_id, + weak_view_handle_for_orch, + model_ctx, + ) + }); + *orchestration_viewer_model_slot.lock() = Some(model); } } @@ -834,7 +830,7 @@ impl TerminalManager { input_replica_id.clone(), participant_list.clone(), session_id, - source_type.clone(), + source.source_type.clone(), ctx, ); }); diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 26a1fa40b9..c75630dbd0 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -14,17 +14,16 @@ pub use load_ai_conversation::ConversationRestorationInNewPaneType; // TODO(advait): if we align on prompt suggestions banner in Input, move code out of inline_banner mod. pub(crate) mod init_environment; mod init_project; +use crate::ai::block_context::BlockContext; +#[cfg(feature = "local_fs")] +use crate::ai::skills::SkillOpenOrigin; +use crate::global_resource_handles::GlobalResourceHandlesProvider; pub use init_project::{ InitActionResult, InitProjectModel, InitProjectModelEvent, InitStepBlock, InitStepKind, ProjectScopedRulesResult, }; use onboarding::callout::{FinalState, OnboardingCalloutViewEvent, OnboardingQuery}; use onboarding::{OnboardingCalloutView, OnboardingKeybindings}; - -use crate::ai::block_context::BlockContext; -#[cfg(feature = "local_fs")] -use crate::ai::skills::SkillOpenOrigin; -use crate::global_resource_handles::GlobalResourceHandlesProvider; pub(crate) mod docker_sandbox; mod link_detection; mod open_in_warp; @@ -46,452 +45,471 @@ mod tooltips; pub mod use_agent_footer; mod zero_state_block; -use std::any::Any; -use std::borrow::Cow; -use std::cell::RefCell; -use std::collections::hash_map::Entry; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::fmt; -use std::hash::Hash; -use std::ops::{Deref as _, Range}; -use std::path::{Path, PathBuf}; -use std::rc::Rc; -use std::str::FromStr; -use std::sync::mpsc::SyncSender; -use std::sync::Arc; -use std::thread::JoinHandle; -use std::time::Duration; +use warpui::clipboard_utils::get_image_filepaths_from_paths; -use action::RememberForWarpification; +use std::ops::Deref as _; + +use crate::ai::blocklist::agent_view::fork_from_last_known_good_state_exchange_id; +use crate::ai::blocklist::agent_view::{ + agent_view_bg_fill, get_agent_view_entry_block_position_id, AgentViewController, + AgentViewControllerEvent, AgentViewDisplayMode, AgentViewEntryBlockParams, + AgentViewEntryOrigin, AgentViewHeaderDisabledTheme, AgentViewHeaderTheme, + AgentViewZeroStateBlock, AgentViewZeroStateEvent, EphemeralMessageModel, + ExitConfirmationTrigger, InlineAgentViewHeader, OrchestrationPillBar, + ENTER_OR_EXIT_CONFIRMATION_WINDOW, +}; +use crate::ai::conversation_utils; +use crate::ai::predict::prompt_suggestions::{ + has_pending_code_or_unit_test_prompt_suggestion, + is_accept_prompt_suggestion_bound_to_cmd_enter, + is_accept_prompt_suggestion_bound_to_ctrl_enter, +}; +use crate::search::slash_command_menu::static_commands::commands; +use crate::terminal::input::inline_menu::InlineMenuPositioner; +use crate::terminal::view::passive_suggestions::PromptSuggestionResolution; +pub use crate::terminal::view::rich_content::{ + AIBlockMetadata, AgentViewEntryMetadata, RichContent, RichContentInsertionPosition, + RichContentMetadata, +}; +use crate::terminal::view::zero_state_block::TerminalViewZeroStateBlock; +use crate::view_components::action_button::{ActionButton, ButtonSize, KeystrokeSource}; + +use use_agent_footer::UseAgentToolbar; + +use super::cli_agent; +use super::CLIAgent; +#[cfg(feature = "local_fs")] +use crate::ai::agent::{CurrentHead, DiffBase}; +use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent}; +use crate::ai::ambient_agents::{ + conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus, +}; +use crate::ai::blocklist::block::cli::{CLISubagentView, CLISubagentViewEvent}; +use crate::ai::blocklist::block::cli_controller::{ + CLISubagentController, CLISubagentEvent, UserTakeOverReason, +}; +use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBarEvent; +use crate::ai::blocklist::usage::conversation_usage_view::{ + ConversationUsageInfo, ConversationUsageView, TimingInfo, +}; +use crate::ai::blocklist::{block_context_from_terminal_model, SlashCommandRequest}; +use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; +use crate::ai::loading::shimmering_warp_loading_text; +#[cfg(feature = "local_fs")] +use crate::code_review::context::{ + convert_file_diffs_to_diffset_hunks, create_attachment_reference_and_key, + register_diffset_attachment, +}; +#[cfg(feature = "local_fs")] +use crate::code_review::DiffSetScope; +use crate::terminal::model::blocks::RemovableBlocklistItem; +#[cfg(feature = "local_fs")] +use crate::util::file::external_editor::{settings::EditorLayout, EditorSettings}; +use crate::util::truncation::truncate_from_end; + +use crate::ai::agent::api::ServerConversationToken; +use crate::ai::agent::redaction::redact_secrets; +use crate::ai::agent::todos::popup::{AgentTodosPopupEvent, AgentTodosPopupView}; +use crate::ai::agent::{ + AIAgentPtyWriteMode, AgentReviewCommentBatch, CancellationReason, PassiveSuggestionTrigger, + ServerOutputId, ShellCommandCompletedTrigger, +}; +use crate::ai::blocklist::block::{AIBlockAction, FinishReason}; +use crate::ai::blocklist::codebase_index_speedbump_banner::{ + CodebaseIndexSpeedbumpBannerAction, CodebaseIndexSpeedbumpBannerState, VisibilityState, +}; +use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper, AIBlockOutputStatus}; +#[cfg(feature = "local_fs")] +use crate::ai::persisted_workspace::PersistedWorkspace; +use crate::code_review::comments::{ + convert_insert_review_comments, AttachedReviewComment, PendingImportedReviewComment, +}; +#[cfg(feature = "local_fs")] +use crate::code_review::diff_state::LocalDiffStateModel; +use crate::code_review::diff_state::{DiffMode, GitDeltaPreference}; +#[cfg(feature = "local_fs")] +use crate::code_review::git_status_update::{ + GitRepoStatusModel, GitStatusMetadata, GitStatusUpdateModel, +}; +use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; +use crate::projects::ProjectManagementModel; +use crate::remote_server::manager::{ + RemoteServerInitPhase, RemoteServerManager, RemoteServerManagerEvent, +}; +use crate::settings::ai::FocusedTerminalInfo; +use crate::settings_view::mcp_servers_page::MCPServersSettingsPage; +use crate::terminal::cli_agent_sessions::event::{ + parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, + CLI_AGENT_NOTIFICATION_SENTINEL, +}; +use crate::terminal::cli_agent_sessions::listener::{ + agent_supports_rich_status, is_agent_supported, CLIAgentSessionListener, +}; +#[cfg(not(target_family = "wasm"))] +use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; +use crate::terminal::cli_agent_sessions::{ + CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, + CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, + CLIAgentSessionsModelEvent, +}; +use crate::terminal::view::init_environment::{ + mode_selector::{ + EnvironmentSetupMode, EnvironmentSetupModeSelector, EnvironmentSetupModeSelectorEvent, + }, + InitEnvironmentBlock, InitEnvironmentBlockEvent, +}; +use crate::terminal::view::ssh_remote_server_choice_view::{ + SshRemoteServerChoiceView, SshRemoteServerChoiceViewEvent, +}; +use crate::terminal::view::ssh_remote_server_failed_banner::{ + SshRemoteServerFailedBanner, SshRemoteServerFailedBannerEvent, +}; +use crate::terminal::view::telemetry::PromptSuggestionFallbackReason; +use crate::workspace::view::cloud_agent_capacity_modal::CloudAgentCapacityModalVariant; +use crate::workspaces::user_workspaces::UserWorkspacesEvent; + +pub use self::link_detection::GridHighlightedLink; +pub use self::link_detection::{RichContentLink, RichContentLinkTooltipInfo}; +use crate::ai::llms::{LLMId, LLMModelHost, LLMPreferences}; +use crate::settings::CodeSettings; +use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; pub use action::{AgentOnboardingVersion, OnboardingIntention, OnboardingVersion, TerminalAction}; use ai::api_keys::{ApiKeyManager, AwsCredentialsState}; use ai::index::full_source_code_embedding::manager::{BuildSource, CodebaseIndexManager}; -use async_channel::{Receiver, Sender}; -use block_banner::{render_warpification_banner, WarpificationMode, WarpifyBannerState}; pub use block_banner::{WithinBlockBanner, BLOCK_BANNER_HEIGHT}; use block_onboarding::onboarding_agentic_suggestions_block::{ OnboardingAgenticSuggestionsBlock, OnboardingAgenticSuggestionsBlockEvent, OnboardingChipType, }; use block_onboarding::onboarding_drive_sharing_block::OnboardingDriveSharingBlock; -use bookmarks::render_floating_block_snapshot; -use chrono::{DateTime, Local, NaiveDateTime}; -use command_corrections::rules::generic::history::History as CommandCorrectionsHistoryRule; -use command_corrections::rules::{Rule, RuleId as CommandCorrectionsRuleId}; -use command_corrections::{correct_command, Command, Correction, HistoryItem, SessionMetadata}; -use enclose::enclose; pub use init::{ init, CANCEL_COMMAND_KEYBINDING, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING, TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING, }; -use init::{INPUT_BOX_VISIBLE_KEY, TOGGLE_BLOCK_FILTER_KEYBINDING}; -use inline_banner::{ - render_alias_expansion_banner, render_aws_bedrock_login_banner, - render_aws_cli_not_installed_banner, render_inline_notifications_discovery_banner, - render_inline_notifications_error_banner, render_inline_shared_session_ended_banner, - render_inline_shared_session_started_banner, render_inline_ssh_wrapper_banner, - render_open_in_warp_banner, render_shell_process_terminated_banner, render_vim_mode_banner, - AliasExpansionBanner, AliasExpansionBannerAction, AnonymousUserAISignUpBannerState, - AnonymousUserLoginBannerAction, AwsBedrockLoginBannerAction, AwsBedrockLoginBannerState, - AwsCliNotInstalledBannerAction, AwsCliNotInstalledBannerState, ByoLlmAuthBannerSessionState, - OpenInWarpBannerState, SSHBannerAction, SSHBannerState, VimModeBannerAction, -}; pub use inline_banner::{NotificationsDiscoveryBannerAction, NotificationsErrorBannerAction}; -use instant::Instant; -use itertools::Itertools; -use lazy_static::lazy_static; -use markdown_parser::FormattedTextFragment; -use parking_lot::FairMutex; -use pathfinder_color::ColorU; -use regex::Regex; #[cfg(not(target_family = "wasm"))] use repo_metadata::repositories::DetectedRepositories; use repo_metadata::repositories::RepoDetectionSource; -use serde::Serialize; -use serde_json::json; -use session_sharing_protocol::common::{ - AgentAttachment, LongRunningCommandAgentInteractionState, ParticipantId, Role, RoleRequestId, - RoleRequestResponse, ServerConversationToken as SessionSharingServerConversationToken, - WindowSize as SessionSharingWindowSize, -}; -use session_sharing_protocol::sharer::{RoleUpdateReason, SessionEndedReason, SessionSourceType}; -use settings::{Setting, ToggleableSetting}; -use shared_session::cloud_conversation_continuation::CloudConversationContinuationUiState; -use shared_session::{SharedSessionAdapter, Viewer}; +use session_sharing_protocol::common::LongRunningCommandAgentInteractionState; +use session_sharing_protocol::sharer::{RoleUpdateReason, SessionEndedReason}; use ssh_file_upload::{FileUpload, FileUploadEvent}; -use sum_tree::SeekBias; -use use_agent_footer::UseAgentToolbar; use uuid::Uuid; -use vec1::vec1; use warp_core::channel::ChannelState; -use warp_core::command::ExitCode; -use warp_core::context_flag::ContextFlag; -use warp_core::semantic_selection::SemanticSelection; -use warp_core::user_preferences::GetUserPreferences as _; use warp_util::local_or_remote_path::LocalOrRemotePath; -#[cfg(feature = "local_fs")] -use warp_util::path::LineAndColumnArg; -use warp_util::path::ShellFamily; -use warpui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole}; -use warpui::assets::asset_cache::{AssetCache, AssetCacheEvent}; -use warpui::clipboard::ClipboardContent; -use warpui::clipboard_utils::get_image_filepaths_from_paths; -use warpui::elements::new_scrollable::{ - AxisConfiguration, ClippedAxisConfiguration, DualAxisConfig, NewScrollableElement, - ScrollableAppearance, SingleAxisConfig, -}; -use warpui::elements::shimmering_text::ShimmeringTextStateHandle; -use warpui::elements::{ - get_rich_content_position_id, Align, Border, ChildAnchor, ChildView, Clipped, - ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, - DispatchEventResult, DropTarget, DropTargetData, Empty, EventHandler, Expanded, Fill, Flex, - Hoverable, Icon, LiveElement, MouseStateHandle, NewScrollable, OffsetPositioning, ParentAnchor, - ParentElement, ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds, - Radius, Rect, SavePosition, ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, - Shrinkable, Stack, Text, -}; -use warpui::event::ModifiersState; -use warpui::fonts::{Cache as FontCache, FamilyId, Properties}; -use warpui::geometry::vector::{vec2f, Vector2F}; -use warpui::image_cache::ImageType; -use warpui::keymap::Keystroke; -use warpui::notification::{NotificationSendError, RequestPermissionsOutcome, UserNotification}; -use warpui::platform::{Cursor, OperatingSystem}; -use warpui::r#async::executor::Background; -use warpui::r#async::{SpawnedFutureHandle, Timer}; -use warpui::text::SelectionType; -use warpui::ui_components::components::UiComponent; -use warpui::units::{IntoLines, IntoPixels, Lines, Pixels}; -use warpui::windowing::WindowManager; -use warpui::{ - end_trace_after_next, record_trace_event, windowing, AccessibilityData, AppContext, - BlurContext, CursorInfo, Element, Entity, EntityId, EventContext, FocusContext, ModelAsRef, - ModelHandle, SingletonEntity, Tracked, TypedActionView, View, ViewAsRef, ViewContext, - ViewHandle, WeakModelHandle, WeakViewHandle, WindowId, -}; +use warpui::elements::{shimmering_text::ShimmeringTextStateHandle, Border, ChildView}; +use warpui::fonts::Properties; +use warpui::{ViewHandle, WeakModelHandle}; -use self::link_detection::HighlightedLinkOption; -pub use self::link_detection::{GridHighlightedLink, RichContentLink, RichContentLinkTooltipInfo}; -use super::available_shells::AvailableShell; -use super::block_list_viewport::FindMatchScrollLocation; -use super::event::SshLoginStatus; -use super::find::FindOptions; -use super::model::ansi::{SystemDetails, WarpificationUnavailableReason}; -use super::model::block::{ - BlockSection, BlocklistEnvVarMetadata, LONG_RUNNING_COMMAND_DURATION_MS, -}; -use super::model::blocks::RichContentItem; -use super::model::completions::ShellCompletion; -use super::model::rich_content::RichContentType; -use super::model::secrets::RichContentSecretTooltipInfo; -use super::model::selection::ExpandedSelectionRange; -use super::model::session::SessionBootstrappedEvent; -use super::settings::AltScreenPaddingMode; -use super::ssh::error::{SshErrorBlock, SshErrorBlockEvent, SSH_ERROR_BLOCK_VISIBLE_KEY}; -use super::ssh::install_tmux::{ - install_root_tmux_script, install_tmux_script, SshInstallTmuxBlock, SshInstallTmuxBlockEvent, - SshKeyEvent, TmuxInstallMethod, -}; -use super::ssh::root_access::RootAccess; -use super::ssh::ssh_detection::evaluate_warpify_ssh_host; -use super::ssh::util::{ - convert_script_to_one_line, parse_interactive_ssh_command, InteractiveSshCommand, - SshWarpifyCommand, -}; -use super::ssh::warpify::{ - begin_warpify_ssh_session_command, warpify_ssh_session_command, SshWarpifyBlock, - SshWarpifyBlockEvent, -}; -use super::ssh::SSH_WARPIFY_TIMEOUT_DURATION; -use super::warpify::success_block::{WarpifySuccessBlock, WarpifySuccessBlockEvent}; -use super::warpify::trigger_state::{SshBlockState, WarpifyState}; -use super::warpify::WarpificationSource; -use super::{cli_agent, CLIAgent, GridType, HistoryEvent}; -use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; -use crate::ai::agent::redaction::redact_secrets; -use crate::ai::agent::todos::popup::{AgentTodosPopupEvent, AgentTodosPopupView}; + #[cfg(any(test, feature = "integration_tests"))] use crate::ai::agent::UserQueryMode; use crate::ai::agent::{ - AIAgentActionId, AIAgentActionType, AIAgentCitation, AIAgentContext, AIAgentExchangeId, - AIAgentInput, AIAgentOutputStatus, AIAgentPtyWriteMode, AIAgentTextSection, - AgentReviewCommentBatch, CancellationReason, EntrypointType, FileLocations, - FinishedAIAgentOutput, PassiveCodeDiffEntry, PassiveSuggestionResultType, - PassiveSuggestionTrigger, RenderableAIError, ServerOutputId, ShellCommandCompletedTrigger, - StaticQueryType, -}; -#[cfg(feature = "local_fs")] -use crate::ai::agent::{CurrentHead, DiffBase}; -use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent}; -use crate::ai::ambient_agents::{ - conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus, + AIAgentActionType, AIAgentOutputStatus, AIAgentTextSection, EntrypointType, + FinishedAIAgentOutput, RenderableAIError, StaticQueryType, }; use crate::ai::blocklist::agent_view::agent_input_footer::toolbar_item::AgentToolbarItemKind; -use crate::ai::blocklist::agent_view::{ - agent_view_bg_fill, fork_from_last_known_good_state_exchange_id, - get_agent_view_entry_block_position_id, AgentViewController, AgentViewControllerEvent, - AgentViewDisplayMode, AgentViewEntryBlockParams, AgentViewEntryOrigin, - AgentViewHeaderDisabledTheme, AgentViewHeaderTheme, AgentViewZeroStateBlock, - AgentViewZeroStateEvent, EphemeralMessageModel, ExitConfirmationTrigger, InlineAgentViewHeader, - OrchestrationPillBar, ENTER_OR_EXIT_CONFIRMATION_WINDOW, -}; -use crate::ai::blocklist::block::cli::{CLISubagentView, CLISubagentViewEvent}; -use crate::ai::blocklist::block::cli_controller::{ - CLISubagentController, CLISubagentEvent, UserTakeOverReason, -}; -use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBarEvent; -use crate::ai::blocklist::block::{AIBlockAction, FinishReason}; -use crate::ai::blocklist::codebase_index_speedbump_banner::{ - CodebaseIndexSpeedbumpBannerAction, CodebaseIndexSpeedbumpBannerState, VisibilityState, -}; -use crate::ai::blocklist::inline_action::code_diff_view::{CodeDiffView, FileDiff}; -use crate::ai::blocklist::model::{ - AIBlockModel, AIBlockModelHelper, AIBlockModelImpl, AIBlockOutputStatus, -}; use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId; use crate::ai::blocklist::suggested_rule_modal::SuggestedRuleAndId; -use crate::ai::blocklist::summarization_cancel_dialog::SummarizationCancelDialog; -use crate::ai::blocklist::telemetry_banner::{should_collect_ai_ugc_telemetry, TelemetryBanner}; -use crate::ai::blocklist::usage::conversation_usage_view::{ - ConversationUsageInfo, ConversationUsageView, TimingInfo, -}; -use crate::ai::blocklist::{ - ai_brand_color, block_context_from_terminal_model, - get_ai_block_overflow_menu_element_position_id, get_attached_blocks_chip_element_position_id, - AIBlock, AIBlockEvent, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent, - BlocklistAIContextModel, BlocklistAIController, BlocklistAIControllerEvent, - BlocklistAIHistoryEvent, BlocklistAIHistoryModel, BlocklistAIInputEvent, BlocklistAIInputModel, - ClientIdentifiers, ConversationStatusUpdate, InputConfig, InputType, - LegacyPassiveSuggestionsEvent, LegacyPassiveSuggestionsModel, MaaPassiveSuggestionsEvent, - MaaPassiveSuggestionsModel, PassiveSuggestionsModels, PendingQueryState, - RequestFileEditsFormatKind, ShellCommandExecutor, ShellCommandExecutorEvent, - SlashCommandRequest, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, - ATTACH_AS_AGENT_MODE_CONTEXT_TEXT, PRE_REWIND_PREFIX, -}; -use crate::ai::conversation_utils; -use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; -use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}; -use crate::ai::get_relevant_files::controller::GetRelevantFilesController; -use crate::ai::llms::{LLMId, LLMModelHost, LLMPreferences}; -use crate::ai::loading::shimmering_warp_loading_text; -#[cfg(feature = "local_fs")] -use crate::ai::persisted_workspace::PersistedWorkspace; -use crate::ai::predict::prompt_suggestions::{ - has_pending_code_or_unit_test_prompt_suggestion, - is_accept_prompt_suggestion_bound_to_cmd_enter, - is_accept_prompt_suggestion_bound_to_ctrl_enter, +use crate::ai::blocklist::{model::AIBlockModelImpl, ClientIdentifiers}; +use crate::ai::{ + agent::{ + AIAgentActionId, AIAgentCitation, AIAgentContext, AIAgentExchangeId, AIAgentInput, + FileLocations, PassiveCodeDiffEntry, PassiveSuggestionResultType, + }, + blocklist::{ + ai_brand_color, get_ai_block_overflow_menu_element_position_id, + get_attached_blocks_chip_element_position_id, + inline_action::code_diff_view::{CodeDiffView, FileDiff}, + summarization_cancel_dialog::SummarizationCancelDialog, + telemetry_banner::{should_collect_ai_ugc_telemetry, TelemetryBanner}, + AIBlock, AIBlockEvent, BlocklistAIActionEvent, BlocklistAIActionModel, + BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIController, + BlocklistAIControllerEvent, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, + BlocklistAIInputEvent, BlocklistAIInputModel, ConversationStatusUpdate, InputConfig, + InputType, LegacyPassiveSuggestionsEvent, LegacyPassiveSuggestionsModel, + MaaPassiveSuggestionsEvent, MaaPassiveSuggestionsModel, PassiveSuggestionsModels, + PendingQueryState, RequestFileEditsFormatKind, ShellCommandExecutor, + ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, + ATTACH_AS_AGENT_MODE_CONTEXT_TEXT, PRE_REWIND_PREFIX, + }, + execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}, + get_relevant_files::controller::GetRelevantFilesController, }; -use crate::ai_assistant::{AskAIType, ASK_AI_ASSISTANT_TEXT}; -use crate::antivirus::AntivirusInfo; -use crate::appearance::{Appearance, AppearanceEvent}; use crate::auth::auth_manager::AuthManager; use crate::auth::auth_state::AuthState; use crate::auth::auth_view_modal::AuthViewVariant; use crate::auth::{AuthStateProvider, UserUid}; use crate::autoupdate::{self, get_update_state, AutoupdateStage}; -use crate::banner::{ - Banner, BannerAction, BannerEvent, BannerState, BannerTextButton, BannerTextContent, - DismissalType, -}; use crate::cloud_object::model::actions::ObjectActionType; use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{CloudObject, GenericStringObjectFormat, JsonObjectType}; #[cfg(feature = "local_fs")] use crate::code::editor_management::CodeSource; -use crate::code_review::comments::{ - convert_insert_review_comments, AttachedReviewComment, PendingImportedReviewComment, -}; -#[cfg(feature = "local_fs")] -use crate::code_review::context::{ - convert_file_diffs_to_diffset_hunks, create_attachment_reference_and_key, - register_diffset_attachment, -}; -#[cfg(feature = "local_fs")] -use crate::code_review::diff_state::LocalDiffStateModel; -use crate::code_review::diff_state::{DiffMode, GitDeltaPreference}; -#[cfg(feature = "local_fs")] -use crate::code_review::git_status_update::{ - GitRepoStatusModel, GitStatusMetadata, GitStatusUpdateModel, -}; -use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; -#[cfg(feature = "local_fs")] -use crate::code_review::DiffSetScope; use crate::context_chips::prompt::Prompt; use crate::context_chips::prompt_type::PromptType; use crate::context_chips::ContextChipKind; -use crate::debounce::debounce; use crate::drive::settings::WarpDriveSettings; use crate::drive::sharing::ShareableObject; use crate::drive::CloudObjectTypeAndId; -use crate::editor::{AutosuggestionType, CrdtOperation, EditorAction}; -use crate::env_vars::env_var_collection_block::{ - EnvVarCollectionBlock, EnvVarCollectionBlockEvent, +use crate::env_vars::{ + env_var_collection_block::{EnvVarCollectionBlock, EnvVarCollectionBlockEvent}, + CloudEnvVarCollection, EnvVar, }; -use crate::env_vars::{CloudEnvVarCollection, EnvVar}; -use crate::features::FeatureFlag; -use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::pane_group::focus_state::PaneFocusHandle; -use crate::pane_group::{ - CodeReviewPanelArg, PaneConfiguration, PaneEvent, PaneGroupAction, PaneHeaderAction, - SplitPaneState, TerminalViewResources, -}; use crate::persistence::{self, FinishedCommandMetadata}; -use crate::projects::ProjectManagementModel; -use crate::remote_server::manager::{ - RemoteServerInitPhase, RemoteServerManager, RemoteServerManagerEvent, -}; -use crate::resource_center::{ - mark_feature_used_and_write_to_user_defaults, Tip, TipHint, TipsCompleted, -}; -use crate::search::slash_command_menu::static_commands::commands; -use crate::server::cloud_objects::update_manager::UpdateManager; -use crate::server::ids::{ObjectUid, SyncId}; -use crate::server::server_api::ServerApi; -use crate::server::telemetry::{ - self, AgentModeAttachContextMethod, AgentModeEntrypoint, AgentModeRewindEntrypoint, - AnonymousUserSignupEntrypoint, BlockLatencyInfo, BootstrappingInfo, - CommandCorrectionAcceptedType, CommandCorrectionEvent, InteractionSource, LinkOpenMethod, - NotificationAgentVariant, NotificationsTurnedOnSource, PaletteSource, PromptSuggestionViewType, - SaveAsWorkflowModalSource, SecretInteraction, SharingDialogSource, SlowBootstrapInfo, - TelemetryEvent, ToggleBlockFilterSource, WorkflowTelemetryMetadata, -}; -use crate::session_management::{CommandContext, SessionNavigationPromptElements}; -use crate::settings::ai::FocusedTerminalInfo; +use crate::server::cloud_objects::update_manager::UpdateManager; +use crate::server::ids::{ObjectUid, SyncId}; +use crate::server::telemetry::SharingDialogSource; #[cfg(feature = "local_fs")] use crate::settings::import::model::ImportedConfigModel; use crate::settings::import::view::{SettingsImportEvent, SettingsImportView}; use crate::settings::{ AISettings, AISettingsChangedEvent, AliasExpansionSettings, AppEditorSettings, - BlockVisibilitySettings, BlockVisibilitySettingsChangedEvent, CodeSettings, DebugSettings, + BlockVisibilitySettings, BlockVisibilitySettingsChangedEvent, DebugSettings, DebugSettingsChangedEvent, EmacsBindingsSettings, FontSettings, FontSettingsChangedEvent, InputModeSettings, InputModeSettingsChangedEvent, InputSettings, PaneSettings, - PaneSettingsChangedEvent, PrivacySettings, PrivacySettingsChangedEvent, - PrivacySettingsSnapshot, SelectionSettings, VimBannerSettings, + PaneSettingsChangedEvent, SelectionSettings, VimBannerSettings, }; +use crate::settings_view::flags; use crate::settings_view::keybindings::KeybindingChangedNotifier; -use crate::settings_view::mcp_servers_page::MCPServersSettingsPage; -use crate::settings_view::{flags, SettingsSection}; +use crate::settings_view::SettingsSection; use crate::shell_indicator::ShellIndicatorType; use crate::terminal::alias::{check_for_alias_async, AliasedCommand}; -use crate::terminal::alt_screen::alt_screen_element::AltScreenElement; use crate::terminal::alt_screen_reporting::{AltScreenReporting, AltScreenReportingChangedEvent}; use crate::terminal::block_filter::{ filter_button_position_id, BlockFilterEditor, BlockFilterEditorEvent, BlockFilterQuery, OpenedFromClick, }; -use crate::terminal::block_list_element::{ - render_hoverable_block_button, BlockListElement, BlockListMenuSource, BlockListMouseStates, - BlockSelectAction, BlockTextSelectAction, SnackbarHeaderState, ToolbeltButtonTooltip, -}; -use crate::terminal::block_list_viewport::{ - AutoscrollBehavior, InputMode, OverhangingBlock, ScrollPosition, ScrollPositionUpdate, - ScrollState, ViewportState, -}; -use crate::terminal::bootstrap::init_subshell_command; -use crate::terminal::cli_agent_sessions::event::{ - parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, - CLI_AGENT_NOTIFICATION_SENTINEL, -}; -use crate::terminal::cli_agent_sessions::listener::{ - agent_supports_rich_status, is_agent_supported, CLIAgentSessionListener, -}; -#[cfg(not(target_family = "wasm"))] -use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; -use crate::terminal::cli_agent_sessions::{ - CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, - CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, - CLIAgentSessionsModelEvent, -}; -use crate::terminal::color::List; +use crate::terminal::block_list_viewport::OverhangingBlock; +use crate::terminal::block_list_viewport::ScrollPositionUpdate; +use crate::terminal::block_list_viewport::ScrollState; use crate::terminal::command_corrections_denylist::COMMAND_CORRECTIONS_PREFERRED_DENYLIST; -use crate::terminal::event::{ - AfterBlockCompletedEvent, BlockLatencyData, BlockType, RemoteServerSetupState, TerminalMode, - UserBlockCompleted, -}; -use crate::terminal::find::{BlockGridMatch, BlockListMatch, TerminalFindModel}; +use crate::terminal::event::RemoteServerSetupState; use crate::terminal::general_settings::GeneralSettings; use crate::terminal::grid_size_util::grid_cell_dimensions; use crate::terminal::input::decorations::InputBackgroundJobOptions; -use crate::terminal::input::inline_menu::InlineMenuPositioner; -use crate::terminal::input::{ - CommandExecutionSource, InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning, - MenuPositioningProvider, -}; -use crate::terminal::keys::TerminalKeybindings; +use crate::terminal::input::{CommandExecutionSource, InputAction, InputEmptyStateChangeReason}; use crate::terminal::ligature_settings::{should_use_ligature_rendering, LigatureSettings}; -use crate::terminal::links::should_directly_open_link; #[cfg(feature = "local_tty")] use crate::terminal::local_tty::get_shell_starter; #[cfg(feature = "local_tty")] use crate::terminal::local_tty::shell::ShellStarter; #[cfg(all(windows, feature = "local_tty"))] use crate::terminal::local_tty::windows::get_user_and_system_env_variable; -use crate::terminal::model::ansi::{ClearMode, Handler}; -use crate::terminal::model::block::{ - AgentInteractionMetadata, Block, BlockId, BlockMetadata, LONG_RUNNING_BOTTOM_PADDING_LINES, -}; use crate::terminal::model::blockgrid::BlockGrid; -use crate::terminal::model::blocks::{ - BlockFilter, BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, BlockListPoint, Gap, - RemovableBlocklistItem, -}; -use crate::terminal::model::escape_sequences::{self, EscCodes, ToEscapeSequence, C1}; -use crate::terminal::model::grid::grid_handler::{FragmentBoundary, TermMode}; -use crate::terminal::model::index::{Point, Side}; -use crate::terminal::model::mouse::MouseState; -use crate::terminal::model::selection::{SelectAction, SelectionDirection}; use crate::terminal::model::session::active_session::ActiveSession; -use crate::terminal::model::session::{ - BootstrapSessionType, Session, SessionId, SessionType, Sessions, SessionsEvent, -}; -use crate::terminal::model::terminal_model::{ - BlockIndex, BlockSelectionCardinality, SelectedBlocks, TerminalInputState, WithinModel, -}; +use crate::terminal::model::session::{Session, SessionId}; use crate::terminal::model::{ObfuscateSecrets, RespectObfuscatedSecrets, SecretHandle}; -use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher}; use crate::terminal::recorder::PtyRecorder; use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode; +use crate::terminal::session_settings::ToolbarChipSelection; +use crate::terminal::session_settings::{ + NotificationsMode, NotificationsSettings, SessionSettings, +}; use crate::terminal::session_settings::{ - NotificationsMode, NotificationsSettings, SessionSettings, SessionSettingsChangedEvent, - ToolbarChipSelection, DEFAULT_THRESHOLD_FOR_LONG_RUNNING_NOTIFICATION, + SessionSettingsChangedEvent, DEFAULT_THRESHOLD_FOR_LONG_RUNNING_NOTIFICATION, }; use crate::terminal::settings::{TerminalSettings, TerminalSettingsChangedEvent}; use crate::terminal::shared_session::role_change_modal::{ RoleChangeCloseSource, RoleChangeOpenSource, }; use crate::terminal::shared_session::{ - SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionStatus, + SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionSource, + SharedSessionStatus, }; use crate::terminal::ssh::ssh_detection::SshInteractiveSessionDetected; use crate::terminal::view::block_onboarding::onboarding_prompt_block::OnboardingPromptBlock; -use crate::terminal::view::init_environment::mode_selector::{ - EnvironmentSetupMode, EnvironmentSetupModeSelector, EnvironmentSetupModeSelectorEvent, +use crate::terminal::warpify::{ + render::render_subshell_separator, settings::WarpifySettings, SubshellSource, +}; +use crate::terminal::ShellLaunchData; +use crate::terminal::{element_size_at_last_frame, HistoryEntry}; +use crate::terminal::{height_in_range_approx, heights_approx_gt, SizeUpdate}; +use crate::terminal::{heights_approx_eq, CellSizeAndWindowPadding}; +use crate::terminal::{AudibleBell, SizeUpdateReason}; +use crate::terminal::{BlockListSettings, BlockListSettingsChangedEvent}; +use crate::themes::theme::WarpTheme; +use crate::ui_components::icons::{self}; +use crate::util::bindings::{ + custom_tag_to_keystroke, keybinding_name_to_display_string, keybinding_name_to_keystroke, + set_custom_keybinding, CustomAction, +}; +use crate::util::clipboard::clipboard_content_with_escaped_paths; +#[cfg(feature = "local_fs")] +use crate::util::openable_file_type::{is_markdown_file, resolve_file_target, FileTarget}; +use crate::view_components::{DismissibleToast, ToastFlavor}; +use crate::workflows::workflow::Workflow; +use crate::workflows::WorkflowSelectionSource; +use crate::workspace::sync_inputs::SyncedInputState; +use crate::workspace::{CommandSearchOptions, OneTimeModalModel, ToastStack, WorkspaceAction}; +use crate::workspace::{ForkAIConversationParams, ForkFromExchange, ForkedConversationDestination}; +use crate::workspaces::{user_workspaces::UserWorkspaces, workspace::CustomerType}; +use crate::AIRequestUsageModel; +use crate::ActiveSession as WindowActiveSession; +use crate::{report_if_error, AIAgentActionResultType}; +use crate::{safe_error, safe_warn}; + +use async_channel::{Receiver, Sender}; +use chrono::{DateTime, Local, NaiveDateTime}; +use command_corrections::rules::{Rule, RuleId as CommandCorrectionsRuleId}; +use command_corrections::{correct_command, Command, Correction, HistoryItem, SessionMetadata}; +use enclose::enclose; +use instant::Instant; +use itertools::Itertools; +use lazy_static::lazy_static; +use markdown_parser::FormattedTextFragment; +use parking_lot::FairMutex; +use pathfinder_color::ColorU; +use regex::Regex; +use serde::Serialize; +use serde_json::json; +use session_sharing_protocol::common::{ + AgentAttachment, ParticipantId, Role, RoleRequestId, RoleRequestResponse, + ServerConversationToken as SessionSharingServerConversationToken, + WindowSize as SessionSharingWindowSize, +}; +use shared_session::{ + cloud_conversation_continuation::CloudConversationContinuationUiState, SharedSessionAdapter, + Viewer, +}; +use std::any::Any; +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::hash::Hash; +use std::ops::Range; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::str::FromStr; +use std::sync::mpsc::SyncSender; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::Duration; +use sum_tree::SeekBias; +use vec1::vec1; +use warp_core::context_flag::ContextFlag; +use warp_core::user_preferences::GetUserPreferences as _; +#[cfg(feature = "local_fs")] +use warp_util::path::LineAndColumnArg; +use warp_util::path::ShellFamily; +use warpui::clipboard::ClipboardContent; +use warpui::elements::new_scrollable::{ + AxisConfiguration, ClippedAxisConfiguration, DualAxisConfig, NewScrollableElement, + ScrollableAppearance, SingleAxisConfig, +}; +use warpui::elements::{ + get_rich_content_position_id, ChildAnchor, ClippedScrollStateHandle, Container, + CrossAxisAlignment, DispatchEventResult, DropTarget, DropTargetData, Empty, EventHandler, + Expanded, Flex, LiveElement, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement, + ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, + ScrollableElement, ScrollbarWidth, Shrinkable, Text, +}; +use warpui::event::ModifiersState; +use warpui::keymap::Keystroke; +use warpui::notification::{NotificationSendError, RequestPermissionsOutcome, UserNotification}; +use warpui::platform::{Cursor, OperatingSystem}; +use warpui::r#async::executor::Background; +use warpui::r#async::{SpawnedFutureHandle, Timer}; +use warpui::windowing::WindowManager; + +use warpui::assets::asset_cache::{AssetCache, AssetCacheEvent}; +use warpui::image_cache::ImageType; +use warpui::units::{IntoLines, IntoPixels, Lines, Pixels}; +use warpui::{ + accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole}, + elements::SavePosition, + elements::{ + Align, Clipped, ConstrainedBox, CornerRadius, Fill, Hoverable, Icon, MouseStateHandle, + Rect, ScrollStateHandle, Scrollable, + }, + fonts::{Cache as FontCache, FamilyId}, + ui_components::components::UiComponent, + AccessibilityData, AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, + TypedActionView, View, ViewAsRef, ViewContext, WeakViewHandle, +}; +use warpui::{ + elements::Stack, + end_trace_after_next, + geometry::vector::{vec2f, Vector2F}, + record_trace_event, WindowId, +}; + +use warpui::{windowing, CursorInfo, EntityId, EventContext, ModelAsRef, SingletonEntity, Tracked}; + +use crate::ai_assistant::{AskAIType, ASK_AI_ASSISTANT_TEXT}; +use crate::appearance::{Appearance, AppearanceEvent}; +use crate::banner::{ + Banner, BannerAction, BannerEvent, BannerState, BannerTextButton, BannerTextContent, + DismissalType, +}; +use crate::debounce::debounce; +use crate::editor::{AutosuggestionType, CrdtOperation, EditorAction}; +use crate::features::FeatureFlag; +use crate::pane_group::SplitPaneState; +use crate::pane_group::{ + CodeReviewPanelArg, PaneConfiguration, PaneEvent, PaneGroupAction, PaneHeaderAction, + TerminalViewResources, +}; +use crate::resource_center::{ + mark_feature_used_and_write_to_user_defaults, Tip, TipHint, TipsCompleted, +}; +use crate::server::telemetry::{ + self, AgentModeAttachContextMethod, AgentModeEntrypoint, AgentModeRewindEntrypoint, + AnonymousUserSignupEntrypoint, InteractionSource, LinkOpenMethod, NotificationAgentVariant, + PaletteSource, PromptSuggestionViewType, SecretInteraction, SlowBootstrapInfo, + ToggleBlockFilterSource, WorkflowTelemetryMetadata, +}; +use crate::server::{ + server_api::ServerApi, + telemetry::{ + CommandCorrectionAcceptedType, CommandCorrectionEvent, NotificationsTurnedOnSource, + SaveAsWorkflowModalSource, TelemetryEvent, + }, +}; +use crate::session_management::{CommandContext, SessionNavigationPromptElements}; +use crate::settings::{PrivacySettings, PrivacySettingsChangedEvent, PrivacySettingsSnapshot}; +use crate::terminal::alt_screen::alt_screen_element::AltScreenElement; +use crate::terminal::block_list_element::{ + render_hoverable_block_button, BlockListElement, BlockListMouseStates, BlockSelectAction, + BlockTextSelectAction, SnackbarHeaderState, ToolbeltButtonTooltip, +}; +use crate::terminal::block_list_viewport::AutoscrollBehavior; +use crate::terminal::block_list_viewport::{InputMode, ScrollPosition, ViewportState}; +use crate::terminal::bootstrap::init_subshell_command; +use crate::terminal::event::TerminalMode; +use crate::terminal::event::UserBlockCompleted; +use crate::terminal::find::{BlockGridMatch, BlockListMatch, TerminalFindModel}; +use crate::terminal::input::{InputState, MenuPositioning, MenuPositioningProvider}; +use crate::terminal::keys::TerminalKeybindings; +use crate::terminal::model::block::{AgentInteractionMetadata, BlockMetadata}; +use crate::terminal::model::block::{Block, BlockId}; +use crate::terminal::model::blocks::{BlockFilter, BlockList}; +use crate::terminal::model::blocks::{BlockHeight, BlockHeightItem, BlockHeightSummary, Gap}; +use crate::terminal::model::escape_sequences::{self, EscCodes, ToEscapeSequence, C1}; +use crate::terminal::model::grid::grid_handler::{FragmentBoundary, TermMode}; +use crate::terminal::model::index::{Point, Side}; +use crate::terminal::model::mouse::MouseState; +use crate::terminal::model::selection::{SelectAction, SelectionDirection}; +use crate::terminal::model::session::{BootstrapSessionType, SessionType, Sessions, SessionsEvent}; +use crate::terminal::model::terminal_model::{BlockIndex, TerminalInputState}; +use crate::terminal::model::terminal_model::{ + BlockSelectionCardinality, SelectedBlocks, WithinModel, +}; +use crate::terminal::model::{ + ansi::{ClearMode, Handler}, + blocks::BlockListPoint, }; -use crate::terminal::view::init_environment::{InitEnvironmentBlock, InitEnvironmentBlockEvent}; use crate::terminal::view::inline_banner::{ render_agent_mode_setup_banner, AgentModeSetupSpeedbumpBannerAction, AgentModeSetupSpeedbumpBannerState, AliasExpansionBannerState, NotificationsDiscoveryBannerState, NotificationsErrorBannerState, PromptSuggestionBannerState, VimModeBannerState, }; -use crate::terminal::view::passive_suggestions::PromptSuggestionResolution; -pub use crate::terminal::view::rich_content::{ - AIBlockMetadata, AgentViewEntryMetadata, RichContent, RichContentInsertionPosition, - RichContentMetadata, -}; use crate::terminal::view::ssh_file_upload::FileUploadId; -use crate::terminal::view::ssh_remote_server_choice_view::{ - SshRemoteServerChoiceView, SshRemoteServerChoiceViewEvent, -}; -use crate::terminal::view::ssh_remote_server_failed_banner::{ - SshRemoteServerFailedBanner, SshRemoteServerFailedBannerEvent, -}; -use crate::terminal::view::telemetry::PromptSuggestionFallbackReason; -use crate::terminal::view::zero_state_block::TerminalViewZeroStateBlock; -use crate::terminal::warpify::render::render_subshell_separator; -use crate::terminal::warpify::settings::WarpifySettings; -use crate::terminal::warpify::SubshellSource; use crate::terminal::waterfall_gap_element::WaterfallGapElement; +use crate::terminal::ShellHost; use crate::terminal::{ block_list_element::BlockHoverAction, // find::{Event as FindEvent, Find, FindDirection}, @@ -501,45 +519,77 @@ use crate::terminal::{ terminal_size_element::TerminalSizeElement, TerminalModel, }; -use crate::terminal::{ - color, element_size_at_last_frame, height_in_range_approx, heights_approx_eq, - heights_approx_gt, prompt, AudibleBell, BlockListSettings, BlockListSettingsChangedEvent, - CellSizeAndWindowPadding, History, HistoryEntry, ShellHost, ShellLaunchData, SizeInfo, - SizeUpdate, SizeUpdateReason, -}; -use crate::themes::theme::WarpTheme; +use crate::view_components::find::{Event as FindEvent, Find, FindDirection, FindWithinBlockState}; +use settings::{Setting, ToggleableSetting}; +use warp_core::semantic_selection::SemanticSelection; +use warpui::text::SelectionType; + +use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; +use crate::server::telemetry::{BlockLatencyInfo, BootstrappingInfo}; +use crate::terminal::{block_list_element::BlockListMenuSource, prompt}; +use crate::terminal::{color, History, SizeInfo}; +use crate::terminal::{color::List, model::block::LONG_RUNNING_BOTTOM_PADDING_LINES}; +use crate::terminal::{event::AfterBlockCompletedEvent, event::BlockLatencyData, event::BlockType}; use crate::throttle::throttle; -use crate::ui_components::icons::{self}; -use crate::util::bindings::{ - custom_tag_to_keystroke, keybinding_name_to_display_string, keybinding_name_to_keystroke, - set_custom_keybinding, CustomAction, -}; -use crate::util::clipboard::clipboard_content_with_escaped_paths; use crate::util::color::darken; -#[cfg(feature = "local_fs")] -use crate::util::file::external_editor::{settings::EditorLayout, EditorSettings}; -#[cfg(feature = "local_fs")] -use crate::util::openable_file_type::{is_markdown_file, resolve_file_target, FileTarget}; -use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; -use crate::util::truncation::truncate_from_end; -use crate::view_components::action_button::{ActionButton, ButtonSize, KeystrokeSource}; -use crate::view_components::find::{Event as FindEvent, Find, FindDirection, FindWithinBlockState}; -use crate::view_components::{DismissibleToast, ToastFlavor}; -use crate::workflows::workflow::Workflow; -use crate::workflows::WorkflowSelectionSource; -use crate::workspace::sync_inputs::SyncedInputState; -use crate::workspace::view::cloud_agent_capacity_modal::CloudAgentCapacityModalVariant; -use crate::workspace::{ - CommandSearchOptions, ForkAIConversationParams, ForkFromExchange, - ForkedConversationDestination, OneTimeModalModel, ToastStack, WorkspaceAction, +use crate::{send_telemetry_from_ctx, send_telemetry_on_executor, send_telemetry_sync_from_ctx}; + +use self::link_detection::HighlightedLinkOption; +use super::available_shells::AvailableShell; +use super::block_list_viewport::FindMatchScrollLocation; +use super::event::SshLoginStatus; +use super::find::FindOptions; +use super::model::ansi::{SystemDetails, WarpificationUnavailableReason}; +use super::model::block::{ + BlockSection, BlocklistEnvVarMetadata, LONG_RUNNING_COMMAND_DURATION_MS, +}; +use super::model::blocks::RichContentItem; +use super::model::completions::ShellCompletion; +use super::model::rich_content::RichContentType; +use super::model::secrets::RichContentSecretTooltipInfo; +use super::model::selection::ExpandedSelectionRange; +use super::model::session::SessionBootstrappedEvent; +use super::settings::AltScreenPaddingMode; +use super::ssh::error::{SshErrorBlock, SshErrorBlockEvent, SSH_ERROR_BLOCK_VISIBLE_KEY}; +use super::ssh::install_tmux::{ + install_root_tmux_script, install_tmux_script, SshInstallTmuxBlock, SshInstallTmuxBlockEvent, + SshKeyEvent, TmuxInstallMethod, +}; +use super::ssh::root_access::RootAccess; +use super::ssh::ssh_detection::evaluate_warpify_ssh_host; +use super::ssh::util::{ + convert_script_to_one_line, parse_interactive_ssh_command, InteractiveSshCommand, + SshWarpifyCommand, +}; +use super::ssh::warpify::{ + begin_warpify_ssh_session_command, warpify_ssh_session_command, SshWarpifyBlock, + SshWarpifyBlockEvent, }; -use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; -use crate::workspaces::workspace::CustomerType; -use crate::{ - report_if_error, safe_error, safe_warn, send_telemetry_from_ctx, send_telemetry_on_executor, - send_telemetry_sync_from_ctx, AIAgentActionResultType, AIRequestUsageModel, - ActiveSession as WindowActiveSession, +use super::ssh::SSH_WARPIFY_TIMEOUT_DURATION; +use super::warpify::success_block::{WarpifySuccessBlock, WarpifySuccessBlockEvent}; +use super::warpify::trigger_state::{SshBlockState, WarpifyState}; +use super::warpify::WarpificationSource; +use super::{GridType, HistoryEvent}; +use crate::antivirus::AntivirusInfo; +use crate::terminal::links::should_directly_open_link; +use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher}; +use action::RememberForWarpification; +use block_banner::{render_warpification_banner, WarpificationMode, WarpifyBannerState}; +use bookmarks::render_floating_block_snapshot; +use command_corrections::rules::generic::history::History as CommandCorrectionsHistoryRule; +use init::{INPUT_BOX_VISIBLE_KEY, TOGGLE_BLOCK_FILTER_KEYBINDING}; +use inline_banner::{ + render_alias_expansion_banner, render_aws_bedrock_login_banner, + render_aws_cli_not_installed_banner, render_inline_notifications_discovery_banner, + render_inline_notifications_error_banner, render_inline_shared_session_ended_banner, + render_inline_shared_session_started_banner, render_inline_ssh_wrapper_banner, + render_open_in_warp_banner, render_shell_process_terminated_banner, render_vim_mode_banner, + AliasExpansionBanner, AliasExpansionBannerAction, AnonymousUserAISignUpBannerState, + AnonymousUserLoginBannerAction, AwsBedrockLoginBannerAction, AwsBedrockLoginBannerState, + AwsCliNotInstalledBannerAction, AwsCliNotInstalledBannerState, ByoLlmAuthBannerSessionState, + OpenInWarpBannerState, SSHBannerAction, SSHBannerState, VimModeBannerAction, }; +use warp_core::command::ExitCode; lazy_static! { // A set of commands that perform minimal work that we use as a baseline to measure the latency of blocks. @@ -1741,7 +1791,7 @@ pub enum Event { }, StartSharingCurrentSession { scrollback_type: SharedSessionScrollbackType, - source_type: SessionSourceType, + source: SharedSessionSource, }, EstablishedSharedSession { session_id: session_sharing_protocol::common::SessionId, @@ -5385,7 +5435,8 @@ impl TerminalView { | BlocklistAIHistoryEvent::ConversationOwnershipTransferred { .. } | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } - | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => None, + | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } + | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => None, } } @@ -5864,7 +5915,8 @@ impl TerminalView { | BlocklistAIHistoryEvent::ConversationServerTokenAssigned { .. } | BlocklistAIHistoryEvent::NewConversationRequestComplete { .. } | BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. } - | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => {} + | BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } + | BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => {} } ctx.notify(); } @@ -7120,6 +7172,16 @@ impl TerminalView { .active_conversation_id() } + pub fn active_conversation_task_id(&self, app: &AppContext) -> Option { + let history = BlocklistAIHistoryModel::as_ref(app); + let conversation_id = self.active_conversation_id(app).or_else(|| { + self.ai_context_model + .as_ref(app) + .selected_conversation_id(app) + })?; + history.conversation(&conversation_id)?.task_id() + } + pub fn ambient_agent_view_model( &self, ) -> Option<&ModelHandle> { @@ -12818,18 +12880,16 @@ impl TerminalView { // If we were waiting to share this session once it was bootstrapped, // we can now attempt to share it. - let source_type_opt = match self.model.lock().shared_session_status() { - SharedSessionStatus::SharePendingPreBootstrap { source_type } => { - Some(source_type.clone()) - } + let pending_share = match self.model.lock().shared_session_status() { + SharedSessionStatus::SharePendingPreBootstrap { source } => Some(source.clone()), _ => None, }; - if let Some(source_type) = source_type_opt { + if let Some(source) = pending_share { log::info!("Terminal bootstrapped with pending shared session; attempting to share"); self.attempt_to_share_session( SharedSessionScrollbackType::All, None, - source_type, + source, false, ctx, ); @@ -20949,10 +21009,13 @@ impl TerminalView { self.open_share_session_modal(SharedSessionActionSource::FooterChip, ctx); } InputEvent::StartRemoteControl => { + let source = SharedSessionSource::user( + self.active_conversation_task_id(ctx).map(|t| t.to_string()), + ); self.attempt_to_share_session( SharedSessionScrollbackType::All, Some(SharedSessionActionSource::FooterChip), - SessionSourceType::default(), + source, true, ctx, ); @@ -21773,11 +21836,13 @@ impl TerminalView { ) -> ViewHandle { use rand::distributions::{Alphanumeric, DistString}; - use crate::ai::agent::{ - AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentText, AIAgentTextSection, - MessageId, ServerOutputId, + use crate::ai::{ + agent::{ + AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentText, AIAgentTextSection, + MessageId, ServerOutputId, + }, + blocklist::FakeAIBlockModel, }; - use crate::ai::blocklist::FakeAIBlockModel; let inputs = vec![AIAgentInput::UserQuery { query, diff --git a/app/src/terminal/view/shared_session/test_utils.rs b/app/src/terminal/view/shared_session/test_utils.rs index 8165578e43..c23a6f32fb 100644 --- a/app/src/terminal/view/shared_session/test_utils.rs +++ b/app/src/terminal/view/shared_session/test_utils.rs @@ -56,7 +56,7 @@ pub fn terminal_view_for_viewer(app: &mut App) -> ViewHandle { ReplicaId::random(), Box::new(ParticipantList::default()), SessionId::new(), - SessionSourceType::default(), + SessionSourceType::User, ctx, ); }); diff --git a/app/src/terminal/view/shared_session/view_impl.rs b/app/src/terminal/view/shared_session/view_impl.rs index 6c1737ef10..47bee75f50 100644 --- a/app/src/terminal/view/shared_session/view_impl.rs +++ b/app/src/terminal/view/shared_session/view_impl.rs @@ -57,8 +57,8 @@ use crate::terminal::shared_session::role_change_modal::{ }; use crate::terminal::shared_session::settings::SharedSessionSettings; use crate::terminal::shared_session::{ - join_link, SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionStatus, - COPY_LINK_TEXT, + join_link, SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionSource, + SharedSessionStatus, COPY_LINK_TEXT, }; use crate::terminal::view::{ ContextMenuAction, Event, InlineBannerItem, InlineBannerType, PendingUserQueryKind, @@ -511,8 +511,8 @@ impl TerminalView { pub fn attempt_to_share_session( &mut self, scrollback_type: SharedSessionScrollbackType, - source: Option, - source_type: SessionSourceType, + action_source: Option, + source: SharedSessionSource, bypass_conversation_guard: bool, ctx: &mut ViewContext, ) { @@ -548,7 +548,7 @@ impl TerminalView { self.set_show_pane_accent_border(false, ctx); - self.pending_share_source = source; + self.pending_share_source = action_source; self.model .lock() @@ -557,16 +557,16 @@ impl TerminalView { ctx.emit(Event::StartSharingCurrentSession { scrollback_type, - source_type, + source, }); - if let Some(source) = source { + if let Some(action_source) = action_source { send_telemetry_from_ctx!( TelemetryEvent::StartedSharingCurrentSession { includes_scrollback: !matches!( scrollback_type, SharedSessionScrollbackType::None ), - source, + source: action_source, }, ctx ); @@ -574,6 +574,7 @@ impl TerminalView { } /// Sets the PresenceManager and decorates the view accordingly when a shared session has been started. + #[allow(clippy::too_many_arguments)] pub fn on_session_share_started( &mut self, sharer_id: ParticipantId, diff --git a/app/src/terminal/view/shared_session/view_impl_tests.rs b/app/src/terminal/view/shared_session/view_impl_tests.rs index b6676a6f25..23b45d65e2 100644 --- a/app/src/terminal/view/shared_session/view_impl_tests.rs +++ b/app/src/terminal/view/shared_session/view_impl_tests.rs @@ -105,9 +105,9 @@ fn test_on_ambient_agent_execution_ended_enables_followup_input_for_editable_non terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); model.set_shared_session_status(SharedSessionStatus::FinishedViewer); drop(model); @@ -490,7 +490,7 @@ fn test_on_session_share_ended_does_not_insert_tombstone_for_ambient_session_und terminal.update(&mut app, |view, ctx| { view.model .lock() - .set_shared_session_source_type(SessionSourceType::AmbientAgent { task_id: None }); + .set_shared_session_source(SharedSessionSource::ambient_agent(None)); view.on_session_share_ended(ctx); }); @@ -626,9 +626,9 @@ fn configure_ambient_details_panel_test( terminal.update(app, |view, _| { view.model .lock() - .set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + .set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); }); task_id } @@ -1088,9 +1088,9 @@ fn test_on_session_share_ended_enables_followup_input_without_tombstone_for_owne terminal.update(&mut app, |view, ctx| { view.model .lock() - .set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + .set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); view.on_session_share_ended(ctx); }); @@ -1142,9 +1142,9 @@ fn test_on_session_share_ended_hides_input_for_no_cta_tombstone() { }); view.model .lock() - .set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + .set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); view.on_session_share_ended(ctx); }); @@ -1194,9 +1194,9 @@ fn test_on_session_share_ended_does_not_insert_tombstone_for_owned_ambient_sessi terminal.update(&mut app, |view, ctx| { view.model .lock() - .set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + .set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); view.on_session_share_ended(ctx); }); @@ -1245,9 +1245,9 @@ fn test_on_session_share_ended_clears_frozen_followup_input_for_owned_ambient_se }); view.model .lock() - .set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + .set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); view.on_session_share_ended(ctx); }); @@ -1280,7 +1280,7 @@ fn test_on_session_share_ended_does_not_insert_tombstone_for_non_ambient_session terminal.update(&mut app, |view, ctx| { view.model .lock() - .set_shared_session_source_type(SessionSourceType::default()); + .set_shared_session_source(SharedSessionSource::user(None)); view.on_session_share_ended(ctx); }); @@ -1312,9 +1312,9 @@ fn test_on_ambient_agent_execution_ended_inserts_tombstone_when_handoff_enabled( terminal.update(&mut app, |view, ctx| { view.model .lock() - .set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + .set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); view.on_ambient_agent_execution_ended(ctx); view.on_ambient_agent_execution_ended(ctx); }); @@ -1346,9 +1346,9 @@ fn test_on_ambient_agent_execution_ended_enables_followup_for_owned_task_without terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); model.set_shared_session_status(SharedSessionStatus::NotShared); drop(model); @@ -1396,9 +1396,9 @@ fn test_on_ambient_agent_execution_ended_enables_followup_input_without_tombston terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); model.set_shared_session_status(SharedSessionStatus::NotShared); drop(model); @@ -1439,9 +1439,9 @@ fn test_restored_owned_tombstone_hides_input_until_continue() { terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); model.set_shared_session_status(SharedSessionStatus::NotShared); drop(model); @@ -1504,9 +1504,9 @@ fn test_deep_linked_ambient_continuation_refreshes_when_task_data_arrives() { // conversation metadata, so it first renders the conservative // ended-session UI. let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); model.set_shared_session_status(SharedSessionStatus::FinishedViewer); drop(model); @@ -1581,9 +1581,9 @@ fn test_on_ambient_agent_execution_ended_keeps_live_owned_session_on_session_sha terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); model.set_shared_session_status(SharedSessionStatus::executor()); drop(model); view.on_ambient_agent_execution_ended(ctx); @@ -1615,9 +1615,9 @@ fn test_try_submit_pending_cloud_followup_allows_repeat_submission_for_owned_tas terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); model.set_shared_session_status(SharedSessionStatus::executor()); drop(model); @@ -1772,9 +1772,9 @@ fn test_non_owned_tombstone_is_removed_for_followup_and_reinserted_after_complet terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); model.set_shared_session_status(SharedSessionStatus::FinishedViewer); drop(model); @@ -1875,9 +1875,9 @@ fn test_on_ambient_agent_execution_ended_refreshes_open_details_panel_to_termina terminal.update(&mut app, |view, ctx| { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: Some(task_id.to_string()), - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(Some( + task_id.to_string(), + ))); model.set_shared_session_status(SharedSessionStatus::executor()); drop(model); diff --git a/app/src/terminal/view/use_agent_footer/mod.rs b/app/src/terminal/view/use_agent_footer/mod.rs index 1b57b84d5f..3c75d53c00 100644 --- a/app/src/terminal/view/use_agent_footer/mod.rs +++ b/app/src/terminal/view/use_agent_footer/mod.rs @@ -4,68 +4,80 @@ //! offering users the option to bring in the agent. For CLI agent commands (e.g., Claude Code, //! Gemini CLI, Codex), it displays a specialized footer with additional functionality. -use base64::Engine; -use session_sharing_protocol::sharer::SessionSourceType; -use warpui::clipboard::{ClipboardContent, ImageData}; - use crate::ai::agent::ImageContext; use crate::ai::blocklist::agent_view::agent_input_footer::{ AgentInputFooter, AgentInputFooterEvent, }; use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status; use crate::terminal::cli_agent_sessions::{CLIAgentInputEntrypoint, CLIAgentSessionsModel}; -use crate::terminal::shared_session::{SharedSessionActionSource, SharedSessionScrollbackType}; +use crate::terminal::shared_session::{ + SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionSource, +}; use crate::util::image::{infer_mime_type, MAX_IMAGE_SIZE_BYTES_FOR_CLI_AGENT, MIME_SNIFF_BYTES}; +use base64::Engine; +use warpui::clipboard::{ClipboardContent, ImageData}; mod warpify_footer; +pub use crate::terminal::CLIAgent; +use warpify_footer::{WarpifyFooterView, WarpifyFooterViewEvent}; + use std::path::Path; use std::sync::{Arc, LazyLock}; use std::time::Duration; +use warpui::r#async::Timer; + +use crate::code_review::diff_state::GitDeltaPreference; +use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; use anyhow::anyhow; use parking_lot::FairMutex; use pathfinder_color::ColorU; -use warp_core::features::FeatureFlag; -use warp_core::settings::Setting; -use warp_core::ui::appearance::Appearance; -use warp_core::ui::color::contrast::{ - high_enough_contrast, pick_best_foreground_color, MinimumAllowedContrast, -}; -use warp_core::ui::theme::color::internal_colors; -use warp_core::ui::theme::Fill as ThemeFill; -use warp_core::{report_error, send_telemetry_from_ctx}; -use warp_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START}; -use warpify_footer::{WarpifyFooterView, WarpifyFooterViewEvent}; -use warpui::elements::{ - ChildView, Container, CrossAxisAlignment, Empty, Expanded, Flex, MainAxisSize, ParentElement, +use warp_core::{ + features::FeatureFlag, + report_error, send_telemetry_from_ctx, + settings::Setting, + ui::{ + appearance::Appearance, + color::contrast::{ + high_enough_contrast, pick_best_foreground_color, MinimumAllowedContrast, + }, + theme::{color::internal_colors, Fill as ThemeFill}, + }, }; -use warpui::keymap::Keystroke; -use warpui::r#async::Timer; + use warpui::{ + elements::{ + ChildView, Container, CrossAxisAlignment, Empty, Expanded, Flex, MainAxisSize, + ParentElement, + }, + keymap::Keystroke, AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, }; -use super::{RichContentInsertionPosition, TerminalAction, TerminalView}; -use crate::ai::blocklist::agent_view::agent_view_bg_fill; -use crate::ai::blocklist::block::cli_controller::CLISubagentEvent; -use crate::cmd_or_ctrl_shift; -use crate::code_review::diff_state::GitDeltaPreference; -use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; -use crate::server::telemetry::{CLIAgentType, CLISubagentControlState, TelemetryEvent}; -use crate::settings::{ - AISettings, AISettingsChangedEvent, CompiledCommandsForCodingAgentToolbar, InputModeSettings, +use crate::{ + ai::blocklist::{agent_view::agent_view_bg_fill, block::cli_controller::CLISubagentEvent}, + cmd_or_ctrl_shift, + server::telemetry::{CLIAgentType, CLISubagentControlState, TelemetryEvent}, + settings::{ + AISettings, AISettingsChangedEvent, CompiledCommandsForCodingAgentToolbar, + InputModeSettings, + }, + terminal::cli_agent_sessions::CLIAgentRichInputCloseReason, + terminal::{ + model_events::{ModelEvent, ModelEventDispatcher}, + TerminalModel, + }, + ui_components::{blended_colors, icons::Icon}, + view_components::action_button::{ + ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, TooltipAlignment, + }, }; -use crate::terminal::cli_agent_sessions::CLIAgentRichInputCloseReason; -use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher}; + +use warp_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START}; + +use super::{RichContentInsertionPosition, TerminalAction, TerminalView}; use crate::terminal::view::block_banner::WarpificationMode; -pub use crate::terminal::CLIAgent; -use crate::terminal::TerminalModel; -use crate::ui_components::blended_colors; -use crate::ui_components::icons::Icon; -use crate::view_components::action_button::{ - ActionButton, ActionButtonTheme, ButtonSize, KeystrokeSource, TooltipAlignment, -}; /// Small delay inserted between separate PTY writes to CLI agents. /// (Used both for the mode-switch prefix split and for the `DelayedEnter` @@ -242,10 +254,13 @@ impl TerminalView { UseAgentToolbarEvent::StartRemoteControl { scrollback_type } => { self.auto_stop_sharing_on_cli_end = *scrollback_type == SharedSessionScrollbackType::None; + let source = SharedSessionSource::user( + self.active_conversation_task_id(ctx).map(|t| t.to_string()), + ); self.attempt_to_share_session( *scrollback_type, Some(SharedSessionActionSource::FooterChip), - SessionSourceType::default(), + source, true, ctx, ); diff --git a/app/src/terminal/view/use_agent_footer/mod_tests.rs b/app/src/terminal/view/use_agent_footer/mod_tests.rs index 0c3ff5a789..f8c0d63ad3 100644 --- a/app/src/terminal/view/use_agent_footer/mod_tests.rs +++ b/app/src/terminal/view/use_agent_footer/mod_tests.rs @@ -1,31 +1,36 @@ use std::rc::Rc; -use session_sharing_protocol::sharer::SessionSourceType; use warp_core::settings::Setting as _; use warpui::{App, AppContext, SingletonEntity, ViewContext}; +use crate::{ + ai::{ + agent::{ + conversation::AIConversationId, task::TaskId, AIAgentInput, ServerOutputId, + UserQueryMode, + }, + blocklist::{ + agent_view::AgentViewEntryOrigin, + block::cli_controller::UserTakeOverReason, + model::{AIBlockModel, AIBlockOutputStatus, AIRequestType, OutputStatusUpdateCallback}, + AIBlock, ClientIdentifiers, + }, + llms::LLMId, + }, + features::FeatureFlag, + settings::AISettings, + terminal::cli_agent_sessions::{ + CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, + CLIAgentSessionsModel, + }, + terminal::model::ansi::{BootstrappedValue, Handler as _, InitShellValue}, + terminal::shared_session::SharedSessionSource, + terminal::CLIAgent, + test_util::{add_window_with_terminal, terminal::initialize_app_for_terminal_view}, +}; + use super::super::{AIBlockMetadata, RichContentMetadata, RichContentType}; use super::*; -use crate::ai::agent::conversation::AIConversationId; -use crate::ai::agent::task::TaskId; -use crate::ai::agent::{AIAgentInput, ServerOutputId, UserQueryMode}; -use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; -use crate::ai::blocklist::block::cli_controller::UserTakeOverReason; -use crate::ai::blocklist::model::{ - AIBlockModel, AIBlockOutputStatus, AIRequestType, OutputStatusUpdateCallback, -}; -use crate::ai::blocklist::{AIBlock, ClientIdentifiers}; -use crate::ai::llms::LLMId; -use crate::features::FeatureFlag; -use crate::settings::AISettings; -use crate::terminal::cli_agent_sessions::{ - CLIAgentInputState, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, - CLIAgentSessionsModel, -}; -use crate::terminal::model::ansi::{BootstrappedValue, Handler as _, InitShellValue}; -use crate::terminal::CLIAgent; -use crate::test_util::add_window_with_terminal; -use crate::test_util::terminal::initialize_app_for_terminal_view; struct PendingAIBlockModel { conversation_id: AIConversationId, @@ -301,7 +306,7 @@ fn use_agent_footer_hidden_during_cloud_agent_setup_lrc() { // NO CLIAgentSession registered yet. view.model .lock() - .set_shared_session_source_type(SessionSourceType::AmbientAgent { task_id: None }); + .set_shared_session_source(SharedSessionSource::ambient_agent(None)); assert!(view.model.lock().is_shared_ambient_agent_session()); assert!( CLIAgentSessionsModel::as_ref(ctx) @@ -345,7 +350,7 @@ fn cli_agent_footer_renders_for_viewer_of_shared_cloud_agent_session() { // what the viewer's terminal manager does on `JoinedSuccessfully`. view.model .lock() - .set_shared_session_source_type(SessionSourceType::AmbientAgent { task_id: None }); + .set_shared_session_source(SharedSessionSource::ambient_agent(None)); assert!(view.model.lock().is_shared_ambient_agent_session()); // Inject a CLI agent session as `apply_cli_agent_state_update` would on diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index 57e2e62ec8..6c4699a000 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -1,3 +1,18 @@ +use crate::ai::agent::conversation::ConversationStatus; +use crate::ai::agent::task::TaskId; +use crate::ai::agent::{ + AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, UserQueryMode, +}; +use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::cloud_environments::{ + AmbientAgentEnvironment, CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel, +}; +use crate::cloud_object::model::persistence::CloudModel; +use crate::cloud_object::{CloudObjectMetadata, CloudObjectPermissions}; +use crate::server::ids::{ClientId, SyncId}; +use chrono::Local; +use parking_lot::FairMutex; +use session_sharing_protocol::common::CLIAgentSessionState; use std::any::Any; use std::cell::RefCell; use std::collections::HashSet; @@ -5,43 +20,26 @@ use std::pin::pin; use std::rc::Rc; use std::str::FromStr; use std::sync::Arc; - -use chrono::Local; -use parking_lot::FairMutex; -use session_sharing_protocol::common::CLIAgentSessionState; -use session_sharing_protocol::sharer::SessionSourceType; use warp_cli::agent::Harness; use warp_terminal::model::escape_sequences::{BRACKETED_PASTE_END, BRACKETED_PASTE_START}; -use warpui::notification::UserNotification; -use warpui::platform::WindowStyle; -use warpui::{App, Presenter, ReadModel, WindowInvalidation}; - -use super::*; -use crate::ai::agent::conversation::ConversationStatus; -use crate::ai::agent::task::TaskId; -use crate::ai::agent::{ - AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, UserQueryMode, +use warpui::{ + notification::UserNotification, platform::WindowStyle, Presenter, WindowInvalidation, }; -use crate::ai::ambient_agents::AmbientAgentTaskId; +use warpui::{App, ReadModel}; + use crate::ai::blocklist::agent_view::toolbar_item::AgentToolbarItemKind; -use crate::ai::blocklist::agent_view::{AgentViewEntryOrigin, AgentViewState, ExitAgentViewError}; +use crate::ai::blocklist::agent_view::ExitAgentViewError; use crate::ai::blocklist::block::cli_controller::UserTakeOverReason; use crate::ai::blocklist::{ + agent_view::{AgentViewEntryOrigin, AgentViewState}, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, InputConfig, InputType, ResponseStreamId, }; -use crate::ai::cloud_environments::{ - AmbientAgentEnvironment, CloudAmbientAgentEnvironment, CloudAmbientAgentEnvironmentModel, -}; use crate::ai::llms::LLMId; -use crate::cloud_object::model::persistence::CloudModel; -use crate::cloud_object::{CloudObjectMetadata, CloudObjectPermissions}; use crate::context_chips::prompt::Prompt; use crate::editor::{AutosuggestionLocation, AutosuggestionType, CrdtOperation}; use crate::features::FeatureFlag; use crate::pane_group::focus_state::PaneGroupFocusState; -use crate::pane_group::pane::PaneStack; -use crate::pane_group::{BackingView, TerminalPaneId}; -use crate::server::ids::{ClientId, SyncId}; +use crate::pane_group::{pane::PaneStack, BackingView, TerminalPaneId}; use crate::server::server_api::ai::SpawnAgentRequest; use crate::settings::import::model::ImportedConfigModel; use crate::settings::{AISettings, AppEditorSettings, WarpPromptSeparator}; @@ -56,7 +54,9 @@ use crate::terminal::cli_agent_sessions::{ CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, }; -use crate::terminal::model::ansi::{self, BootstrappedValue, InitShellValue, PreexecValue}; + +use crate::terminal::model::ansi::{self, InitShellValue}; +use crate::terminal::model::ansi::{BootstrappedValue, PreexecValue}; use crate::terminal::model::block::AgentViewVisibility; use crate::terminal::model::blocks::{insert_block, TotalIndex}; use crate::terminal::model::grid::Dimensions as _; @@ -65,18 +65,21 @@ use crate::terminal::session_settings::AgentToolbarChipSelection; use crate::terminal::shared_session::shared_handlers::{ apply_cli_agent_state_update, RemoteUpdateGuard, }; -use crate::terminal::shared_session::SharedSessionStatus; +use crate::terminal::shared_session::{SharedSessionSource, SharedSessionStatus}; use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent; use crate::terminal::view::load_ai_conversation::RestoredAIConversation; use crate::terminal::view::shared_session::ConversationEndedTombstoneView; -use crate::terminal::{CLIAgent, MockTerminalManager, TerminalManager, TerminalModel}; -use crate::test_util::terminal::{ - add_window_with_id_and_terminal, initialize_app_for_terminal_view, -}; +use crate::terminal::CLIAgent; + +use crate::terminal::{MockTerminalManager, TerminalManager, TerminalModel}; +use crate::test_util::terminal::add_window_with_id_and_terminal; +use crate::test_util::terminal::initialize_app_for_terminal_view; use crate::test_util::{add_window_with_terminal, assert_eventually}; use crate::view_components::find::FindWithinBlockState; use crate::workspace::ToastStack; +use super::*; + fn add_window_with_cloud_mode_terminal(app: &mut App) -> ViewHandle { let tips_model = app.add_model(|_| Default::default()); let (_, terminal) = app.add_window(WindowStyle::NotStealFocus, |ctx| { @@ -1025,9 +1028,7 @@ fn shared_third_party_viewer_sync_enters_agent_view_and_retags_existing_block() terminal.update(&mut app, |view, ctx| { let harness_block_id = { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: None, - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(None)); model.set_shared_session_status(SharedSessionStatus::ActiveViewer { role: Default::default(), }); @@ -1100,9 +1101,7 @@ fn shared_third_party_viewer_syncs_from_viewer_harness_updated_when_harness_unch terminal.update(&mut app, |view, ctx| { let harness_block_id = { let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { - task_id: None, - }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(None)); model.set_shared_session_status(SharedSessionStatus::ActiveViewer { role: Default::default(), }); @@ -1171,7 +1170,7 @@ fn shared_third_party_viewer_syncs_from_cli_agent_state_without_ambient_model() let harness_block_id = terminal.update(&mut app, |view, _| { assert!(view.ambient_agent_view_model().is_none()); let mut model = view.model.lock(); - model.set_shared_session_source_type(SessionSourceType::AmbientAgent { task_id: None }); + model.set_shared_session_source(SharedSessionSource::ambient_agent(None)); model.set_shared_session_status(SharedSessionStatus::ActiveViewer { role: Default::default(), }); diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index c2c21ff027..84a5f1f20b 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -1,30 +1,4 @@ -use std::collections::HashMap; - -use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; -use ai::project_context::model::ProjectContextModel; -use pane_group::{NotebookPane, PaneState, SplitPaneState, TerminalPaneId}; -use repo_metadata::repositories::DetectedRepositories; -use repo_metadata::watcher::DirectoryWatcher; -#[cfg(feature = "local_fs")] -use repo_metadata::CanonicalizedPath; -#[cfg(feature = "local_fs")] -use repo_metadata::RepoMetadataModel; -use session_sharing_protocol::common::SessionId; -use session_sharing_protocol::sharer::SessionSourceType; -#[cfg(feature = "local_fs")] -use tempfile::TempDir; -use terminal::shared_session::permissions_manager::SessionPermissionsManager; -use terminal::view::ActiveSessionState; -use warp_editor::editor::NavigationKey; -use warpui::platform::WindowStyle; -use warpui::{AddSingletonModel, App, ViewHandle}; -use watcher::HomeDirectoryWatcher; - use super::*; -use crate::ai::active_agent_views_model::ActiveAgentViewsModel; -use crate::ai::agent_conversations_model::AgentConversationsModel; -use crate::ai::agent_tips::AITipModel; -use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::OrchestrationPillBarModel; use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions}; use crate::ai::document::ai_document_model::AIDocumentModel; @@ -32,9 +6,6 @@ use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::facts::manager::AIFactManager; use crate::ai::harness_availability::HarnessAvailabilityModel; use crate::ai::llms::LLMPreferences; -use crate::ai::mcp::gallery::MCPGalleryManager; -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; @@ -52,40 +23,72 @@ use crate::pane_group::{Direction, PaneGroupAction, PaneId}; use crate::pricing::PricingInfoModel; #[cfg(not(target_family = "wasm"))] use crate::remote_server::codebase_index_model::RemoteCodebaseIndexModel; -use crate::resource_center::Tip; -use crate::server::cloud_objects::listener::Listener; -use crate::server::cloud_objects::update_manager::UpdateManager; +use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; +#[cfg(feature = "local_fs")] +use crate::user_config::tab_configs_dir; +use repo_metadata::repositories::DetectedRepositories; +use repo_metadata::watcher::DirectoryWatcher; +#[cfg(feature = "local_fs")] +use repo_metadata::CanonicalizedPath; +#[cfg(feature = "local_fs")] +use repo_metadata::RepoMetadataModel; +use std::collections::HashMap; +#[cfg(feature = "local_fs")] +use tempfile::TempDir; +use watcher::HomeDirectoryWatcher; + +use crate::server::cloud_objects::{listener::Listener, update_manager::UpdateManager}; use crate::server::experiments::ServerExperiments; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; + use crate::server::telemetry::context_provider::AppTelemetryContextProvider; -use crate::settings::cloud_preferences_syncer::CloudPreferencesSyncer; use crate::settings::PrivacySettings; use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::settings_view::DisplayCount; -use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; use crate::system::SystemStats; use crate::tab_configs::tab_config::{TabConfigPaneNode, TabConfigPaneType}; -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::shared_session::{SharedSessionScrollbackType, SharedSessionStatus}; -use crate::test_util::settings::initialize_settings_for_tests; -use crate::undo_close::UndoCloseSettings; -#[cfg(feature = "local_fs")] -use crate::user_config::tab_configs_dir; #[cfg(windows)] use crate::util::traffic_lights::windows::RendererState; -use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher; -use crate::workflows::local_workflows::LocalWorkflows; 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, workspace, AgentNotificationsModel, GlobalResourceHandlesProvider, ObjectActions, + +use crate::terminal::local_tty::spawner::PtySpawner; +use crate::terminal::shared_session::{ + SharedSessionScrollbackType, SharedSessionSource, SharedSessionStatus, +}; + +use crate::ai::active_agent_views_model::ActiveAgentViewsModel; +use crate::ai::agent_conversations_model::AgentConversationsModel; +use crate::ai::agent_tips::AITipModel; +use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; +use crate::ai::mcp::{ + gallery::MCPGalleryManager, templatable_manager::TemplatableMCPServerManager, + FileBasedMCPManager, FileMCPWatcher, }; +use crate::resource_center::Tip; +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +use crate::test_util::settings::initialize_settings_for_tests; +use crate::undo_close::UndoCloseSettings; +use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher; +use crate::workflows::local_workflows::LocalWorkflows; +use crate::{experiments, workspace, GlobalResourceHandlesProvider}; +use crate::{AgentNotificationsModel, ObjectActions}; + +use crate::settings::cloud_preferences_syncer::CloudPreferencesSyncer; +use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; +use ai::project_context::model::ProjectContextModel; +use pane_group::{NotebookPane, PaneState, SplitPaneState, TerminalPaneId}; +use session_sharing_protocol::common::SessionId; +use terminal::shared_session::permissions_manager::SessionPermissionsManager; +use terminal::view::ActiveSessionState; +use warp_editor::editor::NavigationKey; +use warpui::AddSingletonModel; +use warpui::{platform::WindowStyle, App, ViewHandle}; fn initialize_app(app: &mut App) { initialize_settings_for_tests(app); @@ -599,7 +602,7 @@ fn mock_workspace_with_shared_session(app: &mut App) -> ViewHandle { view.attempt_to_share_session( SharedSessionScrollbackType::All, None, - SessionSourceType::default(), + SharedSessionSource::user(None), false, ctx, ); @@ -1068,7 +1071,7 @@ fn setup_session_sharing_test(workspace: &ViewHandle, app: &mut App) terminal.attempt_to_share_session( SharedSessionScrollbackType::None, None, - SessionSourceType::default(), + SharedSessionSource::user(None), false, ctx, ); @@ -1756,7 +1759,7 @@ fn test_stop_sharing_all_sessions_in_tab() { terminal_view.attempt_to_share_session( SharedSessionScrollbackType::None, None, - SessionSourceType::default(), + SharedSessionSource::user(None), false, ctx, ); @@ -1774,7 +1777,7 @@ fn test_stop_sharing_all_sessions_in_tab() { terminal_view.attempt_to_share_session( SharedSessionScrollbackType::None, None, - SessionSourceType::default(), + SharedSessionSource::user(None), false, ctx, ); diff --git a/specs/QUALITY-726/PRODUCT.md b/specs/QUALITY-726/PRODUCT.md new file mode 100644 index 0000000000..8ff13f86fe --- /dev/null +++ b/specs/QUALITY-726/PRODUCT.md @@ -0,0 +1,76 @@ +# Session Sharing for Orchestrated Agent Sessions +## Summary +When a shared agent session has spawned child agents, parent-scoped session views should show the existing orchestration pill bar so viewers can inspect the orchestrator and its direct children. Direct child session links remain child-scoped and do not open the parent orchestration view. +## Problem +Remote cloud agents are already viewed through shared-session viewers, and local orchestrated sessions already use a pill bar to switch between parent and child conversations. The missing product definition is where that existing pill bar should appear across session-sharing entrypoints and local/remote topologies. +## Goals +1. A viewer opening a parent/orchestrator session can inspect the orchestrator and all direct child agents from the existing orchestration pill bar. +2. Sharing a parent session makes the direct child sessions accessible from that parent session. +3. Opening a direct child session link stays scoped to that child session. +4. The parent-scoped pill bar appears consistently in native Warp and the web/WASM shared-session viewer, with platform-specific affordances following the existing pill bar behavior. +5. The behavior is consistent across local-local, local-remote, remote-remote, and remote-local orchestration topologies from the viewer’s perspective. +## Figma +Figma: none provided. Use the existing orchestration pill bar behavior and visual treatment. This spec does not redefine the pill bar’s layout, status badges, hover cards, ordering, truncation, or other interaction details. +## Behavior +### Terms and scope +1. An orchestrator session is an agent session whose active agent spawned one or more direct child agents. +2. A child session is the session for one direct child agent spawned by an orchestrator. +3. A parent session link targets the orchestrator session. A child session link targets one child session. +4. “Local” means the agent is running on the user’s current client when the user owns the session. “Remote” means the agent is running in a cloud or driver process and is viewed through a shared-session viewer. In remote-local flows, the child is local to the remote driver process, but still remote from the user’s client. +5. Only one level of orchestration is supported: one orchestrator plus its direct child agents. Child sessions are treated as leaf sessions for this feature. +### Sharing rules +6. All remote agent sessions are automatically shared, whether the remote agent is an orchestrator or a child. +7. Sessions that are local to the user’s client are not shared until the user explicitly shares them through an existing share entrypoint, such as the share modal, pane/header action, context menu, or copy-sharing-link action. +8. When a parent/orchestrator session becomes shared, its direct child sessions are also accessible to viewers of that parent session so the parent-scoped pill bar can display and open them. +9. Parent sharing applies to children that already exist when sharing starts and to direct children spawned while the parent share remains active. +10. A viewer who can access a parent session link is allowed to access the direct child sessions exposed through that parent view. Opening or copying a direct child link from that context is acceptable, but the direct child link remains child-scoped. +11. Sharing or opening a direct child session does not implicitly share or navigate to the parent or sibling child sessions. +12. Direct child links may still show human-readable names for agents referenced in that child’s transcript. Agent names are considered orchestration metadata that may be mutually visible between parent and child sessions when those sessions are otherwise accessible. +### Viewer entrypoints and pill bar presence +13. When a user starts or opens a remote orchestrator from the `/cloud-agent` flow in native Warp, the resulting shared-session viewer is parent-scoped and shows the existing orchestration pill bar when the orchestrator has direct children. +14. When a user opens an orchestrator session from the Oz web UI, the web viewer is parent-scoped and shows the existing orchestration pill bar when the orchestrator has direct children. +15. When a user opens a `warp://shared_session/...` or web shared-session link for an orchestrator, native Warp or the web viewer opens a parent-scoped view and shows the existing orchestration pill bar when the orchestrator has direct children. +16. When a user explicitly shares a local orchestrator session, the resulting parent session link opens a parent-scoped view and shows the existing orchestration pill bar when the orchestrator has direct children. +17. When a user opens a direct child session link in native Warp or on the web, Warp opens the child-scoped shared-session view. The child-scoped view does not show the parent/orchestrator pill, sibling child pills, or parent orchestration navigation. +18. “Open in desktop” or equivalent handoff actions preserve the link target. A parent link remains parent-scoped after handoff; a child link remains child-scoped after handoff. +19. Shared-session viewers that are created internally so a parent pill can display a remote child do not create additional visible browser tabs, windows, or user-facing links by themselves. +20. If the viewer joins a parent-scoped session before any child has been spawned, the pill bar appears after the first direct child becomes known. A short delay is acceptable. +21. If a parent-scoped viewer is open while additional direct children spawn, the pill bar updates to include those children. +22. On web/WASM, the pill bar uses the existing web-compatible subset of pill bar behavior. Native-only pane-management actions are not required on web. +23. Viewer pill selection is local to that viewer. Switching pills in a shared-session viewer should not force the sharer or other viewers to switch conversations. Any initial selected-conversation sync should follow existing shared-session behavior. +### Conversation body behavior +24. When viewing the orchestrator pill, the viewer sees the orchestrator session transcript, including orchestration cards, child launch requests, lifecycle updates, and other parent-session activity included by the share. +25. When viewing a child from a parent-scoped view, or when opening a child-scoped direct link, the viewer sees that child’s session transcript according to normal shared-session behavior. +26. Within orchestrator and child conversation bodies, user-visible references to agents by internal ID resolve to the correct human-readable agent name wherever the existing pill bar has enough metadata to do so. This includes send-message-to-agent blocks, received-message-from-agent blocks, and lifecycle/status blocks. If a name cannot be resolved, use the same fallback behavior as the existing pill bar or conversation renderer. +27. Since child sessions are leaf sessions for this feature, any orchestration-looking artifacts inside a child transcript render as ordinary transcript content and do not create a second-level pill bar or parent/child navigation. +28. Completed child sessions remain reachable from the parent pill bar and show their final available transcript and status. +29. If a child session exists but is not ready to join yet, selecting it from the parent pill bar shows the existing loading or pending behavior rather than stale parent content. +30. If a child session cannot be loaded because of a network, permission, or session creation failure, the child view shows an unavailable or error state using existing shared-session error patterns. The parent view and other direct child sessions remain usable. +31. If the parent or a child session ends while a viewer is inspecting the orchestration, ended-session behavior follows existing shared-session behavior for the active session. +### Topology-specific behavior +32. Local-local: when a local orchestrator starts local children, nothing is shared until the user shares the parent or a child. Sharing the parent makes the local children accessible from the parent link and visible in the parent-scoped pill bar. Sharing a child directly opens only that child. +33. Local-remote: when a local orchestrator starts a remote child, the local user can inspect the child from the local orchestration UI. The remote child session is automatically shared. If the user shares the local parent, the parent link shows the parent and the remote child in the parent-scoped pill bar. +34. Remote-remote: when a remote orchestrator starts remote children, the parent and children are automatically shared. Opening the parent link shows the parent-scoped pill bar. Opening a child link shows only that child. +35. Remote-local: when a remote orchestrator starts a child that is local to the remote driver process, both sessions are still remote from the user’s client and are automatically shared. Opening the parent link shows the parent-scoped pill bar. Opening a child link shows only that child. +36. Mixed child modes: if one orchestrator has both local-to-parent children and remote children, the parent-scoped pill bar presents the direct children together using the existing pill bar behavior. The viewer should not need to understand where each child is executing to navigate the orchestration. +### Permissions, privacy, and roles +37. A viewer who can open the parent link can inspect all direct children exposed through that parent link without needing separate child links. +38. A parent link must not reveal sessions outside the parent’s direct child set. +39. A child link must not provide navigation to the parent or sibling child sessions. +40. Human-readable agent names may appear in parent and child conversation bodies when the transcript references those agents. +41. A viewer’s role in a child session reached through the parent link must not exceed the viewer’s effective role for the parent share. +42. If a viewer has an interactive/executor role, existing shared-session controls apply only to the currently active session. The pill bar itself is navigation, not an agent-control surface. +43. Request-access, role-change, participant presence, reconnect, and ended-session behavior should follow existing shared-session behavior for the active session. +44. Copying a session sharing link should preserve the current scope. A parent share action copies a parent-scoped link; a child share action copies a child-scoped link. +### Loading and reconciliation +45. Parent-scoped viewers reconcile the direct child list while the parent session is live so newly spawned children appear and lifecycle status changes are reflected through the existing pill bar. +46. If network connectivity is lost, the pill bar keeps the last known direct children and statuses. On reconnection, the viewer reconciles with the current direct child list and statuses. +47. If a direct child finishes before a viewer joins the parent session, the child still appears in the parent pill bar when the parent share has permission to expose it. +48. If a direct child session was never successfully created or shared, the child can still appear as an errored or unavailable child if the parent transcript or orchestration metadata indicates the child launch failed. +49. If parent and child state disagree temporarily, the UI should prefer a stable, non-destructive presentation: keep known direct child pills visible, update statuses when confirmed, and avoid dropping a child from the list solely because a refresh is delayed. +## Non-goals +1. This spec does not add support for more than one level of orchestration. +2. A direct child link does not provide a breadcrumb or “back to parent orchestration” experience. +3. The parent pill bar is not a bulk control surface for cancelling, restarting, messaging, or otherwise managing child agents. +4. This spec does not change the visual design or detailed interaction model of the existing orchestration pill bar. +5. This spec does not introduce a combined export, fork, or replay artifact for an entire orchestration group. diff --git a/specs/QUALITY-726/TECH.md b/specs/QUALITY-726/TECH.md new file mode 100644 index 0000000000..398dd9cb0e --- /dev/null +++ b/specs/QUALITY-726/TECH.md @@ -0,0 +1,276 @@ +# Session Sharing for Orchestrated Agent Sessions +## Context +See `specs/QUALITY-726/PRODUCT.md` for user-visible behavior. This spec maps the product invariants onto the existing orchestration pill bar and shared-session viewer infrastructure, and fixes the gaps observed across the eight share-parent / share-child × local/remote permutations. +The orchestration pill bar in shared-session viewers was originally built for remote-remote (cloud-spawned orchestrator + cloud children) in `specs/orch-pill-bar-web/TECH.md`. That design already covers `apply_children_fetch`, per-child hidden viewer panes, REST polling, and pill click navigation. This spec extends those mechanisms to the other topologies (local-local, local-remote, remote-local, remote-remote child link) and fixes agent-name resolution in both the pill bar and conversation bodies. +### Where the pill bar currently renders +- Render gate (shared by native and viewer pill bars): `app/src/terminal/view/pane_impl.rs:503-552` (`maybe_add_parent_navigation_card`), keyed on `FeatureFlag::OrchestrationPillBar` || `FeatureFlag::OrchestrationViewerPillBar` plus `AgentView` fullscreen. +- Pill data: `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs` reads `BlocklistAIHistoryModel::descendant_conversation_ids_in_spawn_order` via `pill_specs` (`orchestration_pill_bar.rs:555-580`). `pill_specs` returns `None` when the orchestrator has no descendants; the pill bar collapses to `Empty` in that case. +- Shared-session viewer’s discovery side: `OrchestrationViewerModel` REST-polls children (`app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs:1-416`). Its construction site is `app/src/terminal/shared_session/viewer/terminal_manager.rs:778-816`, currently gated on `SessionSourceType::AmbientAgent { .. }`. +### Where children are made shareable +- Remote children are always shared by the server: spawning a remote child mints an `ai_tasks` row and a `SessionSourceType::AmbientAgent { task_id }` shared session. +- Local children created from a local orchestrator inherit sharing via `inherit_share_for_local_child` in `app/src/pane_group/pane/terminal_pane.rs:228-248`, **but only when the host terminal’s `SessionSourceType` is `AmbientAgent`**. Manual local shares (created via the share modal at `app/src/terminal/view/shared_session/view_impl.rs:1864-1890`) do not get an `AmbientAgent` source, so they fail this gate and children stay unshared. +- After QUALITY-726, the host’s orchestrator `task_id` rides on a sibling `source_task_id` field, not on the `SessionSourceType::User` variant itself — see *Sidecar source_task_id design* below. +### Where agent IDs are resolved in conversation bodies +- `conversation_id_for_agent_id` in `app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs:33-45` first checks `BlocklistAIHistoryModel::conversation_id_for_agent_id` (which uses the `agent_id_to_conversation_id` index keyed by `AIConversation::orchestration_agent_id`; see `history_model.rs:961-988`, `history_model.rs:1029-1033`, `agent_id_key` at `history_model.rs:2228-2232`), and falls back to `find_conversation_id_by_server_token`. +- `AIConversation::orchestration_agent_id` is populated when the local conversation is given a server conversation token or a run id (`history_model.rs:961-988`, `history_model.rs:994-1027`). +- `BlocklistAIHistoryModel::start_new_child_conversation` (`history_model.rs:398-433`) sets `agent_name`, `parent_agent_id`, and the parent/child relationship. +### Where the task ↔ session link is established server-side +- Today, when a local-to-driver child of a remote orchestrator starts sharing, no client tells the server which `session_id` is bound to that child’s `ai_tasks` row. The remote driver mints the local `ai_tasks` row at spawn time and the local terminal manager mints the shared session, but the two aren’t linked on the server side. This is the root cause for the remote-local-share-parent (cases 5/6) hang in the pill bar. +- For live session sharing, the viewer joins child sessions by the child task’s `session_id` (surfaced on the client as `AmbientAgentTask::session_id`, populated from `RunItem.session_id` on the server). Other linkages (e.g. `ai_tasks.agent_conversation_id`) are restore-oriented and orthogonal to live shared-session join; we do not need them for QUALITY-726. +### Observed gaps +1. **Local-local: share parent.** Native viewer shows the parent transcript but no pill bar; child names resolve in native, show as `Unknown` on web. Cause: `OrchestrationViewerModel` is not initialized because the shared session source is not `AmbientAgent`; web viewer has no local history index, so agent-id lookups miss; children are not registered into the viewer history. +2. **Local-local: share child.** Child shows without pill bar; child name references show `Orchestrator` (native) or `Orchestrator/Unknown` (web). Cause: child link is a leaf view (expected), but in-transcript references resolve through `parent_agent_id` fallbacks and the missing agent index, so other agents render with the parent’s name placeholder. +3. **Local-remote: share parent.** Native viewer shows the parent transcript but no pill bar; web shows `Unknown` child names. Cause: same `AmbientAgent`-gate problem as (1); the remote child is shareable, but the parent viewer doesn’t know to discover it. +4. **Local-remote: share child.** Child shows without pill bar; same name-resolution issues. Cause: same root cause as (2) plus remote child links don’t pre-load the agent index for sibling references. +5. **Remote-local: share parent.** Pill bar shows, but clicking child stays on loading. Cause: `OrchestrationViewerModel` registers the child but `AmbientAgentTask.session_id` is never populated for local-to-driver children (driver creates the session locally and never reports it via REST). +6. **Remote-local: share child.** Never gets past loading. Cause: same as (5); no `session_id` to join. +7. **Remote-remote: share parent.** Pill bar works, child loads. This is the happy path designed for in `specs/orch-pill-bar-web/TECH.md`. +8. **Remote-remote: share child.** Child loads without pill bar (expected), but name references are not resolved. Cause: child link is leaf (expected); transcript-side agent name index is missing as in (2)/(4). +### Relevant files +- Pill bar UI: `app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs`; `pill_specs` at lines 555-580. +- Render gate: `app/src/terminal/view/pane_impl.rs:503-552`. +- Viewer pill bar discovery model construction: `app/src/terminal/shared_session/viewer/terminal_manager.rs:778-816`. The viewer model itself: `app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs`. +- Per-child hidden viewer panes: `app/src/pane_group/mod.rs:3270-3548` (`create_hidden_child_agent_pane`, `ensure_shared_session_viewer_child_pane`). +- Local sharing cascade: `app/src/pane_group/pane/terminal_pane.rs:183-248` (`host_terminal_shared_session_source_type`, `inherit_share_for_local_child`). +- Share initiation: `app/src/terminal/view/shared_session/view_impl.rs:521-584` (`attempt_to_share_session(source_type: SessionSourceType, ...)`). Call sites that currently pass `SessionSourceType::default()`: `app/src/terminal/view.rs:20928` (`StartRemoteControl`), `app/src/pane_group/mod.rs:2627` (`ShareSessionModalEvent::StartSharing`), `app/src/terminal/view/use_agent_footer/mod.rs:259` (`UseAgentToolbarEvent::StartRemoteControl`), and the test sites in `view_tests.rs:1032/1107/1178`. Cloud-agent path that already passes `AmbientAgent { task_id }`: `app/src/ai/agent_sdk/driver/terminal.rs:133-141`. +- Conversation-body agent name resolution: `app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs:33-129`, `app/src/ai/blocklist/history_model.rs:961-1033`, `app/src/ai/blocklist/history_model.rs:398-450`. +- Existing local-event-to-server sync home: `app/src/ai/blocklist/task_status_sync_model.rs` (subscribes to local events and fires `update_agent_task` with the standard viewer/remote-child guards). Trait signature in `app/src/server/server_api/ai.rs:920-927`; the actual `fire_update` site is `task_status_sync_model.rs:188-200`. +- Source type plumbing: `session_sharing_protocol::sharer::SessionSourceType` (external crate; today: `User` (unit) and `AmbientAgent { task_id }`). +## Proposed changes +The implementation has four threads. They can be implemented in parallel and merged together because they touch largely disjoint files; see Parallelization. +### Thread A — Cascade sharing from manually shared local orchestrators +Goal: invariants 8, 9, 32 (local-local share parent), 33 (local-remote share parent). +#### Sidecar `source_task_id` design +The existing `session_sharing_protocol::sharer::SessionSourceType` has two variants on `main`: +- `User` (unit, default) — the session was started by a user directly. +- `AmbientAgent { task_id: Option }` — the session was started in the course of spinning up an ambient agent. +A manually shared local conversation is conceptually still a `User`-initiated share — the same person clicked “share session” as for any other manual share. What’s new is that modern local conversations are always associated with a server-side `ai_tasks` row by `task_id`, regardless of whether the conversation happens to act as an orchestrator. +An earlier iteration of QUALITY-726 turned `User` into a struct variant `User { task_id: Option }` to carry that id. That broke wire compatibility with pre-QUALITY-726 viewers that only understood the bare `"User"` JSON form. Thread A keeps `User` strictly unit and instead carries the orchestrator `task_id` on a sidecar field of the payloads that already cross the wire: +```rust path=null start=null +// session-sharing-protocol/src/sharer.rs +pub struct InitPayload { + // ...existing fields... + #[serde(default)] + pub source_type: SessionSourceType, // strict `User` | `AmbientAgent { task_id }` + /// Orchestrator `task_id` for this share. Set whenever the conversation + /// has an `ai_tasks` row, regardless of whether `source_type` is `User` + /// or `AmbientAgent`. Old clients omit this; new code reads it directly. + #[serde(default)] + pub source_task_id: Option, + // ... +} + +// session-sharing-protocol/src/viewer.rs::DownstreamMessage::JoinedSuccessfully +#[serde(default)] +pub source_task_id: Option, +``` +The `SessionManifest` in `session-sharing-server` adds the same `source_task_id` field so the server can plumb the value from `InitPayload` to `JoinedSuccessfully`. +This preserves the existing semantic split: +- **`User`** still means “a user initiated this share.” The orchestrator `task_id` lives in `source_task_id`. +- **`AmbientAgent { task_id }`** still means “cloud-executed agent session.” The variant continues to carry the canonical ambient `task_id`. New sharer code MAY mirror that value into `source_task_id` so downstream readers can use a single field; legacy AmbientAgent producers without the mirror keep working because viewers fall back to the variant. +Concrete client places that key off `AmbientAgent` for *cloud-execution* semantics (not orchestration semantics) must continue to match only `AmbientAgent`: +- `app/src/tab.rs:833` — `Indicator::AmbientAgent` paints the tab badge. +- `app/src/terminal/view/shared_session/view_impl.rs:720` — `ai_context_menu.set_is_in_ambient_agent(true)`. +- `app/src/terminal/view/shared_session/view_impl.rs:766-771` — auto-opens the conversation details panel for `CloudMode` viewers. +- `app/src/terminal/view/shared_session/view_impl.rs:817-833` — viewer-driven-sizing skip and shareable-object retention on session end. +- `app/src/terminal/shared_session/viewer/terminal_manager.rs:786` — marks the `TerminalView` as an ambient-agent session view (read in many places). +- `is_ambient_agent_session()` / `is_shared_ambient_agent_session()` and `passive_suggestions/maa.rs` cloud-specific paths. +No behavior change is required for any of those sites: a manual `User` share carrying `source_task_id: Some(_)` continues to fall through every `matches!(source_type, SessionSourceType::AmbientAgent { .. })` check, so no cloud UI activates for a local orchestrator share. +Orchestration-discovery sites read the sidecar instead of the variant. There is no single `SessionSourceType::orchestrator_task_id()` helper anymore; callers read `source_task_id` from the payload (or the model field that mirrors it) and fall back to `AmbientAgent.task_id` only when interoperating with legacy producers. The viewer-side construction site at `terminal_manager.rs:778-816` needs an explicit restructure, not just a one-line gate swap: +- Lift `task_id` parsing out of the existing `match &source_type` block. Read the new `source_task_id` field on `NetworkEvent::JoinedSuccessfully`, falling back to `AmbientAgent.task_id` when the sidecar is `None`. The current code (`terminal_manager.rs:778-783`) only parses when `SessionSourceType::AmbientAgent { task_id }` matches. +- The cloud-only side effects — `mark_terminal_view_as_ambient_agent_session_view` (`terminal_manager.rs:788-790`), `ActiveAgentViewsModel::register_ambient_session` (`794-796`), and the `if matches!(&source_type, SessionSourceType::AmbientAgent { .. })` outer guard at `786` — must stay `AmbientAgent`-only. +- The `OrchestrationViewerModel::new` construction (`terminal_manager.rs:798-816`) moves *outside* the ambient-only guard and runs whenever `enable_orchestration_polling && FeatureFlag::OrchestrationViewerPillBar.is_enabled() && slot.is_none() && resolved_task_id.is_some()`. Pass the resolved `task_id` (sidecar-first) into `OrchestrationViewerModel::new`. +- `pane_group/pane/terminal_pane.rs:183-248` `host_terminal_shared_session_source_type` returns both the active source type AND the host’s `source_task_id`; `inherit_share_for_local_child` cascades when the host has any resolved orchestrator `task_id` (sidecar or `AmbientAgent.task_id`). +- Cascade rule: when the host carries an orchestrator `task_id`, cascade to a local child as the **same variant kind**. `User` host with `source_task_id: Some(parent_task_id)` → child gets `User` with `source_task_id: Some(child_task_id)`. `AmbientAgent { Some(parent_task_id) }` host (cloud orchestrator) → child gets `AmbientAgent { Some(child_task_id) }` (unchanged from today). A host whose orchestrator `task_id` is still `None` (pre-`StreamInit`) does not cascade; once `StreamInit` upgrades the host’s stored `source_task_id` to `Some(_)`, subsequent local children cascade. +#### Wire compatibility +Leaving `SessionSourceType::User` strictly unit means existing readers — including pre-QUALITY-726 viewers — keep parsing the source type without code changes. The sidecar `source_task_id` is additive, gated on `#[serde(default)]`, and ignored by older deserializers that don’t know about it. +- **Sharer → server.** The sharer threads `source_task_id` into `InitPayload`. Older sharers omit the field entirely; the server’s `#[serde(default)]` falls back to `None`, and the server still reads `source_type.AmbientAgent.task_id` for legacy AmbientAgent producers when the sidecar is absent. +- **Server → viewer.** The server emits `source_task_id` on `JoinedSuccessfully` from the manifest. Older viewers ignore the unknown field; new viewers consume it as the canonical orchestrator `task_id` for the share. +- `From<&SessionSourceType> for LegacySessionSourceType` at `sharer.rs:210-217` only needs to recognize the strict unit `User` (matches `main`); no struct-variant wildcard required. +- `session-sharing-server` vendors its own copy of the protocol crate under `protocol/` rather than depending on the published git crate. The sidecar field must land in both repos in lockstep; otherwise the server’s untagged deserializer would drop the field silently and downstream viewers would never see it. This dual-repo update tax is a known maintenance hazard; see *Follow-ups*. +#### Always stamp the conversation’s `task_id` at share time +- Modern local conversations are already associated with a server-side `ai_tasks` row: `AIConversation::task_id` is populated from the first response’s `StreamInit.run_id` (`app/src/ai/agent/conversation.rs:1695-1699`). Thread A reuses this existing id rather than minting a new task at share-time. +- The share initiation API is `TerminalView::attempt_to_share_session(source_type: SessionSourceType, source_task_id: Option, ...)` at `app/src/terminal/view/shared_session/view_impl.rs:521-584`. Update each existing caller that currently passes `SessionSourceType::default()` to instead pass `SessionSourceType::User` plus the conversation’s task id in `source_task_id`. The concrete call sites are enumerated above in *Relevant files*. +- `start_sharing_session` in `app/src/terminal/local_tty/terminal_manager.rs:1306-1480` is the underlying implementation; it already stores the `source_type` on the `TerminalModel` via `set_shared_session_source_type` (line 1330-1332). Add a sibling `shared_session_source_task_id: Option` field on `TerminalModel` with a matching setter, set during `start_sharing_session`. +- Pre-first-response edge case: if the user shares a brand-new local conversation before any response has arrived, the conversation has no `task_id` yet. Options: + - Carry the share intent and stamp the task id on `StreamInit`; the share starts with `source_task_id = None` and upgrades to `source_task_id = Some(...)` once `task_id` is known. The upgrade path mutates the model’s stored sidecar and re-emits it to existing viewers via the active sharer `Network`. + - Or block share acceptance until the conversation has a `task_id` (simpler, less ideal UX). + - Recommend the first: it preserves “share immediately” UX and reuses the existing event that already mutates the conversation. +- Conversations that genuinely have no `task_id` (legacy / unanchored) continue to share with `source_task_id: None` and stay invisible to orchestration discovery. +#### Cascade to local children +- Single cascade rule: `inherit_share_for_local_child` cascades when the host carries an orchestrator `task_id` (resolved from `source_task_id`, with `AmbientAgent.task_id` as a fallback for cloud orchestrators that already populate it). A host that is sharing pre-`StreamInit` (no resolved task_id) does *not* cascade, since the host’s viewer cannot enumerate children via REST without the host task id; cascaded children would just hang as loading placeholders. Once `StreamInit` upgrades the host’s stored `source_task_id` to `Some(_)` (per the pre-first-response handling above), subsequent local children cascade. +#### Catch-up cascade for pre-existing children +- The cascade in `inherit_share_for_local_child` only fires at child-pane *creation* time. If the user shares an orchestrator that already has spawned children, those pre-existing child panes were created with `IsSharedSessionCreator::No` and stay unshared even after the parent share goes active. The parent viewer's pill bar lists the children (warp-server has their task rows) but the materialization gate in `OrchestrationViewerModel` never trips because `session_id` is never populated on the child task rows. +- Fix: `PaneGroup` subscribes to `BlocklistAIHistoryEvent::LocalSharedSessionEstablished` (Thread D's event). When the parent's local share goes active, `transitively_share_existing_local_children` iterates direct child agent panes in this group, computes the cascaded source type via `inherit_share_for_local_child`, and dispatches `attempt_to_share_session` on each child that isn't already in a sharer/viewer state. Cascaded children are recorded in `transitively_shared_child_panes` so the host's stop-share cascade also stops them. +- Multi-level: grandchildren are picked up transitively. Each newly-shared child's own `LocalSharedSessionEstablished` event re-enters the subscriber, which then cascades to its direct children. Direct-only iteration per event keeps each cascade decision local to one host pane. +- The cascade carries the child’s own `task_id` alongside the host’s variant kind. `User` host with `source_task_id: Some(parent_task_id)` → child gets `User` + `source_task_id: Some(child_task_id)`. `AmbientAgent { Some(parent_task_id) }` host (cloud orchestrator) → child gets `AmbientAgent { Some(child_task_id) }` (unchanged from today). The child’s `task_id` is provided by its launch path (`launch_local_no_harness_child` / `launch_local_harness_child`, `terminal_pane.rs:1694-1989`). +- Calling auto-cascaded child shares `User` is semantic shorthand: the *cascade root* was user-initiated, descendants inherit that family for cloud-UI-avoidance purposes, not for strict provenance accuracy. Downstream code that distinguishes user-initiated vs cascaded shares (none today) can use a separate signal if it needs to. +- The local child’s `IsSharedSessionCreator::Yes { source_type }` flows through `insert_terminal_pane_hidden_for_child_agent` (`app/src/pane_group/mod.rs:4405-4433`) into the local terminal manager. +- This means an originally single-agent share that later spawns child agents will surface those children in a pill bar without needing any new “upgrade share” step: as long as the host has its `task_id`, the cascade fires, the viewer’s discovery model picks the child up on the next poll, and the pill bar appears. +- **Stop-share cascade.** When the host’s manual share stops (`stop_sharing_session` in `local_tty/terminal_manager.rs` and the existing `StopSharingCurrentSession` action path), any local children whose share was created via this cascade must also stop sharing. Implementation: track each cascaded child’s `PaneId` in a `transitively_shared_child_panes: HashSet` on `PaneGroup`, populated at cascade time in `inherit_share_for_local_child`; on stop, iterate and call `stop_sharing_session` on each. Cloud-spawned children that share independently (`AmbientAgent` host path) are not affected by this stop because they were never in the cascade set. This satisfies `PRODUCT.md` invariant 12. +- Server-side authorization is unchanged: viewers of the parent already have view access to descendant tasks (see `specs/orch-pill-bar-web/TECH.md:127-135`). REST child discovery (`GET /agent/runs?ancestor_run_id=`) already locates local children via the task-id hierarchy populated by `launch_local_no_harness_child` / `launch_local_harness_child`; no extra server-side linkage is required for discovery. +#### Non-orchestrator shares +- A non-orchestrator user share now carries `source_type = User` plus `source_task_id: Some(...)` whenever the conversation has a `task_id`. The viewer-side `OrchestrationViewerModel` will issue one REST descendant fetch and get an empty list; with no children to register in `BlocklistAIHistoryModel`, the pill bar gate in Thread C collapses to `Empty`. See Thread C for the polling-cost handling. +- A non-orchestrator share with no `task_id` (rare — pre-first-response or legacy conversation) stays `source_task_id: None` and triggers no orchestration discovery at all. +- Pill bar render gate at `pane_impl.rs:517-521` already triggers from `FeatureFlag::OrchestrationViewerPillBar`, so no additional rendering change is required once children are registered in `BlocklistAIHistoryModel` by `OrchestrationViewerModel`. +### Thread B — Resolve sibling agent names in conversation bodies +Goal: invariants 26 (parent + child) and 40. +Two independent gaps; do them as separate edits rather than one combined helper: +##### B1. Populate `agent_id_to_conversation_id` for viewer-created children +- Currently `OrchestrationViewerModel::apply_children_fetch` (`orchestration_viewer_model.rs:235-358`) calls `start_new_child_conversation` and then `conversation.set_task_id(task_id)` via `history.conversation_mut(&id)`. `set_task_id` (`conversation.rs:790-792`) updates the field on the conversation only — it does **not** update `BlocklistAIHistoryModel::agent_id_to_conversation_id`. The index is normally populated through `assign_run_id_for_conversation` (`history_model.rs:994-1027`), which is the function `agent_id_key` keys off (`history_model.rs:2228-2232`). +- Fix: in `apply_children_fetch`, after `start_new_child_conversation`, call `BlocklistAIHistoryModel::assign_run_id_for_conversation(conversation_id, run_id, Some(task_id), terminal_view_id, ctx)` using `AmbientAgentTask.run_id` instead of (or in addition to) `set_task_id`. This populates the index on first poll, so transcript references to that child resolve to its `agent_name`. +##### B2. Backfill `parent_agent_id` on viewer-created children +- Today `apply_children_fetch` calls `start_new_child_conversation`, which internally reads the orchestrator’s `orchestration_agent_id` and sets `parent_agent_id` on the child (`history_model.rs:406-414`). When the orchestrator’s id is `None` at child-creation time, the child’s `parent_agent_id` stays unset and the existing `parent_conversation_id` fallback (`orchestration_conversation_links.rs:120-129`) cannot resolve back to the parent. +- Fix: when the orchestrator conversation receives its own `ConversationServerTokenAssigned` event, iterate previously-tracked viewer-created children whose `parent_agent_id` is unset and call `conversation.set_parent_agent_id(orchestrator.orchestration_agent_id())` directly. No change to the `agent_id_to_conversation_id` index is needed for this leg — that index is keyed by the conversation’s *own* id, not its parent’s. +##### B3. Sibling references in conversation bodies (downstream of B1 + B2) +- With B1 + B2 done, received-message-from-agent / send-message-to-agent / lifecycle blocks resolve correctly through the existing renderer (which calls `conversation_id_for_agent_id`, `orchestration_conversation_links.rs:33-45`). No new renderer code is required. +- Confirm no other call site silently substitutes “Orchestrator” for an unresolved id. The current renderer treats missing entries as “unresolved” at `orchestration_conversation_links.rs:120-129`; audit other resolution paths (lifecycle status block, hover card details, breadcrumb) to make sure they do the same. +##### B4. Child-link sibling preload (cases 2/4/8) +- Deferred to a follow-up. The original design created live sibling conversations on the child-link viewer’s terminal, which polluted `live_conversation_ids_for_terminal_view` and emitted `StartedNewConversation` events for placeholders. A future change should add a name-only resolution path that doesn’t go through `start_new_conversation`. +### Thread C — Render the pill bar in parent-scoped viewers for all topologies +Goal: invariants 24–27, 31, 38, 51, 52 from `PRODUCT.md`. +- The viewer-side gate restructure described in Thread A is what makes Thread C work end-to-end. Specifically: lift `task_id` parsing out of the AmbientAgent-only `match` at `terminal_manager.rs:778-783`, keep the ambient-only side effects guarded at `terminal_manager.rs:786-797`, and move `OrchestrationViewerModel::new` (`798-816`) so it runs whenever `source_type.orchestrator_task_id().is_some()` plus the feature-flag and slot guards. +- The pill bar continues to hide when there are no children. With Thread A’s always-stamp policy, the model spins up for every shared session that has a `task_id`, but its REST descendant fetch returns no rows for non-orchestrators, `descendant_conversation_ids_in_spawn_order(orchestrator_id)` stays empty, and `OrchestrationPillBar::pill_specs` returns `None` (see `orchestration_pill_bar.rs:555-580`), so the pill bar collapses to `Empty`. The only cost is one initial REST fetch per viewer of a non-orchestrator share. +- Polling cost handling. `OrchestrationViewerModel`’s polling state machine (`orchestration_viewer_model.rs:116-186`) today distinguishes only “active” vs “idle” cadence and uses `polling_handle.is_none()` in `maybe_kick_polling` (lines 168-172) to mean “a kick fetch is already in flight — skip to prevent pile-up”. To add a genuine “stopped because empty” state, introduce an explicit flag on the model: + - Add `idle_due_to_no_children: bool` (false by default). + - In `apply_children_fetch`, when the resulting `children` map is empty, set `idle_due_to_no_children = true`, abort the polling handle, and *do not* schedule another timer. + - In `maybe_kick_polling`, treat `idle_due_to_no_children` as a resume signal: if it is true and the new exchange is on the orchestrator, clear the flag and call `fetch_children`. The existing `polling_handle.is_none()` guard alone is not enough — it would conflate the new stopped state with the existing “in-flight” state. The `AppendedExchange` subscription itself stays in place. + - In any subsequent `apply_children_fetch` that does discover children, clear `idle_due_to_no_children` before scheduling the next poll. +- For native parent-scoped viewers (the user who is doing the sharing): the host’s own `TerminalView` already renders the pill bar via `FeatureFlag::OrchestrationPillBar` and `BlocklistAIHistoryModel::descendant_conversation_ids_in_spawn_order`, which already knows about the user’s local children. No new model needed on the sharer side. +- For native parent-scoped *viewers* on a second client (the user joining the share from another device, native build): the path is the same shared-session-viewer `TerminalManager` as the web case, just compiled native. Once Thread A’s gate restructure runs, `OrchestrationViewerModel` constructs and discovery proceeds normally. +- For the web/WASM viewer: same path. Confirm `OrchestrationViewerModel`’s REST client (`ServerApiProvider`) is WASM-safe (`wasm_view.rs:180-196`). The pill bar itself has WASM-incompatible pane-management helpers; those are already guarded behind `#[cfg(not(target_family = "wasm"))]` in `orchestration_pill_bar.rs:1670-1750`. +### Thread D — Surface local-to-driver children inside a remote orchestrator’s pill bar +Goal: invariants 35 (remote-local share parent) and 49. +- Today `OrchestrationViewerModel::apply_children_fetch` (`orchestration_viewer_model.rs:235-358`) waits for `AmbientAgentTask.session_id` before emitting `EnsureSharedSessionViewerChildPane`. For a remote-local child, the local-to-driver child does not get its session id reported via the REST `agent/runs` endpoint, so the materialization never fires and the user sees a perpetual loading pane. +- Root cause: the remote driver mints a local `ai_tasks` row for the local-to-driver child (via the same `launch_local_no_harness_child` path as local-local) and shares that session, but it does not yet register the shared session id on the server side `ai_tasks` row that the viewer can poll. +- Implementation options: + 1. Driver-side server update: when the local-to-driver child starts sharing (via the cascade from Thread A on the driver), report the new `session_id` on the child’s `ai_tasks` row. This is the same `update_agent_task` path used by the SDK driver. Adds one fire-and-forget RPC per local child shared session. + 2. Viewer-side fallback discovery: when the viewer notices a non-terminal child whose `session_id` stays `None` for longer than X seconds, attempt to discover it via a different endpoint. This is a workaround and still depends on the driver having reported the session id. + - Recommend option 1. +- Once `session_id` flows through, the existing `EnsureSharedSessionViewerChildPane` path in `pane_group/mod.rs:3440-3548` joins the child session and the pill UX matches remote-remote. +- **Trigger.** Subscribe to a new lightweight event emitted from `local_tty/terminal_manager.rs` at the point where the sharer’s `Network` reports a successful share creation — specifically alongside `manager.started_share(...)` in the existing `SharedSessionCreatedSuccessfully` handling. Add a new `BlocklistAIHistoryEvent::LocalSharedSessionEstablished { conversation_id, session_id }` and emit it from that site. The new subscriber lives in a dedicated sibling model at `app/src/ai/blocklist/local_shared_session_link_model.rs`, separate from `TaskStatusSyncModel`, so the two concerns (task-status sync vs session-link sync) stay decoupled. +- **RPC.** The trait signature is `update_agent_task(task_id: AmbientAgentTaskId, task_state: Option, session_id: Option, conversation_id: Option, status_message: Option) -> ...` (`app/src/server/server_api/ai.rs:920-927`). The call is: +```rust path=null start=null +ai_client + .update_agent_task( + task_id, + /* task_state */ None, + /* session_id */ Some(session_id), + /* conversation_id */ None, + /* status_message */ None, + ) + .await +``` + Wrap it in the same fire-and-forget pattern as `TaskStatusSyncModel::fire_update` (`task_status_sync_model.rs:188-200`). +- **Guards.** Apply the same viewer/remote-child/missing-id checks already used in `TaskStatusSyncModel`: + - Skip when the conversation is a viewer (`conversation.is_viewing_shared_session()`). + - Skip when the child is itself a remote child placeholder (`conversation.is_remote_child()`). + - Skip when `task_id` or `session_id` is `None`. + - Dedupe per `(task_id, session_id)` in an in-memory `HashSet`; reconnect/restart paths should not re-fire. The server treats repeated updates as idempotent; dedupe is a network-traffic optimization. +#### Server-side gates required by Thread D +Two `warp-server` predicates that previously assumed cloud-only execution must be relaxed before Thread D's `update_agent_task(session_id)` will land for local children: +- **`updateSharedSessionLinkQuery` state gate** (`model/ai_run_executions.go`). The query previously required `state = 'RUNNING'`, but local executions transition directly from `CLAIMED` to `ENDED` without ever passing through `RUNNING` (only the cloud worker's `markExecutionRunning` path advances state). Predicate must accept `state IN ('CLAIMED', 'RUNNING')` so the update lands for local children. `ENDED` stays excluded to block stale-session writes against terminal rows. +- **`convertTasksToItems` REST stale-link guard** (`router/handlers/public_api/agent_webhooks.go`). The `/agent/runs?ancestor_run_id=` handler previously stripped `session_id` for any non-active run without GCS transcript data. Local runs have no GCS transcript and reach terminal state quickly; the guard must exempt LOCAL-execution rows so child `session_id` continues to surface to the viewer after the child finishes. The stale-link concern motivating the original guard is cloud-sandbox-specific. +### Cross-cutting: keep the existing pill bar visual + interactions unchanged +- `PRODUCT.md` invariant 22 explicitly defers visual/interaction design to the existing pill bar. No changes to `orchestration_pill_bar.rs` are required for QUALITY-726 beyond making sure the gating expressions in Threads A/C produce a non-empty pill set in each topology. +- Continue to gate the entire feature behind `FeatureFlag::OrchestrationViewerPillBar` so partial rollouts don’t break manual shares for users who don’t have the flag. +### Out of scope +- Multi-level orchestration (children with children) — `PRODUCT.md` non-goal 1. +- Bulk controls (cancel/restart/message) from the parent pill bar — non-goal 3. +- Visual redesign of the pill bar — non-goal 4. +- Combined export/replay artifacts — non-goal 5. +- Web viewer pane management actions remain native-only and are not added here. +## Testing and validation +Map each affected `PRODUCT.md` invariant to a concrete test or manual verification. Numbers in parentheses reference `specs/QUALITY-726/PRODUCT.md`. +### Unit tests +- Thread A: `terminal_pane_tests.rs` (or sibling) — `inherit_share_for_local_child` returns `Yes { User, source_task_id: Some(child_task_id) }` when the host is sharing as `User` with `source_task_id: Some(_)`, returns `Yes { AmbientAgent { task_id: Some(child_task_id) }, source_task_id: Some(child_task_id) }` when the host is sharing as `AmbientAgent { task_id: Some(_) }`, and returns `No` when the host has no resolved orchestrator `task_id`. Covers (32), (33). +- Thread A: share-modal handler integration — verify that any call to `attempt_to_share_session` on a conversation with a `task_id` passes `SessionSourceType::User` plus `source_task_id: Some(...)`, regardless of whether the conversation currently has child agents. Add a pre-first-response variant that asserts the sidecar is upgraded on `StreamInit` once `task_id` becomes available. Add a regression test that a conversation with no `task_id` stays at `source_task_id: None`. Covers (8), (9). +- Thread A: no-cloud-UI regression — a `User` viewer with `source_task_id: Some(...)` must not get the `Indicator::AmbientAgent` tab badge, must not auto-open the conversation details panel, and must not flip `set_is_in_ambient_agent(true)` on the AI context menu. Cover with focused unit tests on `tab.rs:833`, `view_impl.rs:720`, and `view_impl.rs:766-771`. +- Thread A: stop-share cascade — starting a manual share on a host with a `task_id`, spawning a local child (which cascades), then stopping the host’s share, should also stop the cascaded child’s share. A cloud-spawned `AmbientAgent`-cascaded child remains unaffected. Covers `PRODUCT.md:12` and Risks L7. +- Thread A: wire-compat tests in the `session-sharing-protocol` crate — (1) deserializing the legacy `"User"` payload still produces `SessionSourceType::User` and an `InitPayload` round-trip leaves `source_task_id` populated when present; (2) `serde_json::to_string(&SessionSourceType::User)` continues to produce the bare `"User"` form so pre-QUALITY-726 readers can parse it; (3) an `InitPayload` without `source_task_id` deserializes with `source_task_id: None` (backward compat for older sharers); (4) `From<&SessionSourceType> for LegacySessionSourceType` round-trips both `User` and `AmbientAgent` to their legacy unit counterparts. +- Thread C: `orchestration_viewer_model_tests.rs` — a viewer of a `User` share with `source_task_id: Some(...)` and no descendants sets `idle_due_to_no_children = true`, aborts the polling handle after the first empty fetch, and resumes polling on the next `AppendedExchange` event on the orchestrator. A subsequent fetch that discovers children clears the flag and returns to active cadence. Covers the polling-cost mitigation. +- Thread B (B1): `history_model_tests.rs` — after `apply_children_fetch` calls `assign_run_id_for_conversation(child_id, run_id, ...)`, `conversation_id_for_agent_id(run_id)` returns `Some(child_id)`. Covers (26). +- Thread B (B2): `history_model_tests.rs` — a child whose orchestrator’s server token arrived after the child was created has its `parent_agent_id` backfilled when `ConversationServerTokenAssigned` fires for the orchestrator; `parent_conversation_id(child, ctx)` resolves to the orchestrator afterwards. Covers (26). +- Thread D: a `LocalSharedSessionEstablished` (or equivalent) event with `task_id` + `session_id` triggers exactly one `update_agent_task(task_id, None, Some(session_id), None, None)` RPC; verify the dedupe set blocks a second identical event. Add the standard viewer-guard / remote-child-guard / missing-id negative cases. Covers (35), (49). +### Integration tests +- Local-local share-parent: extend `view_impl_tests.rs` (mirrors existing `JoinedSharedSession` tests) to assert the pill bar renders with all known children once the orchestrator is shared and at least one child exists. Covers (32). +- Local-remote share-parent: combine a local orchestrator with a remote child; assert the parent viewer’s pill bar lists the remote child and selecting it materializes the existing child pane. Covers (33). +- Remote-local share-parent: stub `update_agent_task(session_id)` to be present, assert pill click materializes the child viewer pane. Covers (35). +- Child-link views (2, 4, 8): assert child transcript references resolve to siblings’ display names via `conversation_id_for_agent_id`, not `“Orchestrator”` or `“Unknown”`. Covers (14), (26), (40). +### Manual validation +For each of the eight topology × scope combinations in the user’s exploration, validate: +1. Parent link → parent transcript renders, pill bar visible when ≥1 direct child, child references show correct agent names. +2. Child link → child transcript only, pill bar hidden, sibling references show correct agent names. +3. Web/WASM viewer parity for both link types (no native-only affordances expected on web). +4. Behavior on parent share stop, child finishes, child errored, reconnect — pill bar persists with last known state. +Track results in a check matrix in the PR description. +### Regression coverage +- Add `task_status_sync_model_tests.rs`-shaped coverage for the new `session_id` link (Thread D): positive case (local child gets a `session_id`, RPC fires once), viewer guard (`is_viewing_shared_session = true` → no RPC), remote-child guard (`is_remote_child = true` → no RPC), missing-id guards, and dedupe on repeated `(task_id, session_id)`. +- `orchestration_pill_bar_tests.rs` should keep its existing rendering coverage; no behavior changes required there. +## Parallelization +The four threads touch largely independent code areas. They run as parallel local sub-agents with separate worktrees once Thread A0 lands, then merge into a single PR (or 1-PR-per-thread). The strict sequencing is: A0 (protocol crate) → A/B/C/D in parallel. +All worktrees live under the shared task directory `~/src/orch-shared-sessions/`, alongside the existing `warp` and `warp-server` worktrees on this branch. Thread A0 adds a sibling `session-sharing-protocol` worktree. Each warp-side thread gets its own warp worktree so the four threads can run in parallel without conflicting on the same working copy. +- **Thread A0** — protocol crate changes + warp rev bump (Thread A prerequisite). + - Files owned: external `session-sharing-protocol` crate (`sharer.rs`, `viewer.rs` if needed for legacy payload audits) and the warp-side `Cargo.toml` git rev pin at `Cargo.toml:249` (applied via the `warp-thread-a` worktree as part of A landing). + - Worktree: `~/src/orch-shared-sessions/session-sharing-protocol` (new sibling of the existing `warp` and `warp-server` worktrees). + - Branch: `matthew/QUALITY-726-protocol`. + - Depends on: none. + - Must merge to the protocol crate (and have its commit hash available) before A or C can compile on the warp side. +- **Thread A** — local share cascade + reuse of orchestrator `task_id` (warp client). + - Files owned: `app/src/pane_group/pane/terminal_pane.rs`, `app/src/pane_group/mod.rs` (cascade tracking + stop-share iteration), `app/src/terminal/view/shared_session/view_impl.rs` (`attempt_to_share_session` callers), `app/src/terminal/local_tty/terminal_manager.rs` (`start_sharing_session` + source type upgrade on `StreamInit`), call sites that currently pass `SessionSourceType::default()` (see *Relevant files*), and the `Cargo.toml:249` git rev bump that picks up Thread A0’s commit, new tests. + - Worktree: `~/src/orch-shared-sessions/warp-thread-a`. + - Branch: `matthew/QUALITY-726-thread-a`. + - Depends on: Thread A0. + - Shared gate logic with Thread C: the viewer-side gate restructure at `terminal_manager.rs:778-816` is owned by Thread A (it lives in a Thread-A-owned file); Thread C consumes the helper / restructured gate and adds the polling-cost mitigation. +- **Thread B** — agent-id index population (B1) + parent_agent_id backfill (B2). B4 (child-link sibling preload) is deferred; see §B4. + - Files owned: `app/src/ai/blocklist/history_model.rs`, `app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs` (audit only), `app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs`, tests. + - Worktree: `~/src/orch-shared-sessions/warp-thread-b`. + - Branch: `matthew/QUALITY-726-thread-b`. + - Depends on: none beyond A0 (the source type change does not affect Thread B’s code paths). +- **Thread C** — polling-cost mitigation + render-gate verification. + - Files owned: `app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs` (idle-due-to-empty flag, polling state machine), `app/src/terminal/view/pane_impl.rs` (gate audit only — no expected changes), integration tests. + - Worktree: `~/src/orch-shared-sessions/warp-thread-c`. + - Branch: `matthew/QUALITY-726-thread-c`. + - Depends on: Thread A (consumes the viewer-side gate restructure landed in Thread A). + - Coordination with Thread B: both threads touch `orchestration_viewer_model.rs`. C’s state-machine changes are in the polling/timer section (`116-186`); B’s changes are in `apply_children_fetch` (`235-358`) and the join handshake. Merge order does not matter, but rebase the second-to-merge thread on top of the first. +- **Thread D** — driver-side `session_id` link. + - Files owned: `app/src/terminal/local_tty/terminal_manager.rs` (`SharedSessionCreatedSuccessfully` emission point + new event), `app/src/ai/blocklist/local_shared_session_link_model.rs` (new dedicated subscriber model), tests. + - Worktree: `~/src/orch-shared-sessions/warp-thread-d`. + - Branch: `matthew/QUALITY-726-thread-d`. + - Depends on: none beyond A0 (the new `update_agent_task(session_id)` call uses an existing trait signature parameter). +Execution mode: all five threads run locally. The repo builds and integration tests run on the developer’s machine; no remote-only resources are involved. +```mermaid +graph TD + A0[Thread A0: session-sharing-protocol crate + Cargo.toml rev bump] + A[Thread A: share cascade + reuse orchestrator task_id] + B[Thread B: agent-id index + parent_agent_id backfill] + C[Thread C: polling-cost mitigation + render-gate verification] + D[Thread D: driver session_id link] + Merge[Combined PR / merge point] + A0 --> A + A0 --> C + A --> C + A0 --> B + A0 --> D + A --> Merge + B --> Merge + C --> Merge + D --> Merge +``` +If launched as sub-agents, each one should: +- Stay in its assigned worktree under `~/src/orch-shared-sessions/`. +- Run `./script/presubmit` (warp) or the crate-equivalent (`cargo test`, `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings`, `cargo fmt --check` in `session-sharing-protocol`) before reporting back. +- Report the branch name, changed files, and a list of failing or skipped tests. +- Not merge into `matthew/orch-shared-sessions` directly; the orchestrator (the user, or a separate merge step) integrates the threads into one branch. +## Risks and mitigations +- **Risk:** Cascading sharing from a manually-shared host to every local child it spawns could surprise users who expect a single-pane share. Mitigation: the cascade only fires for local children of a host that is currently sharing AND has a `task_id`; the user can see the cascaded children in the orchestration pill bar; stop-share on the host cascades to a stop-share on the children (see Thread A “Stop-share cascade”); document the behavior in the share modal copy. +- **Risk:** A user shares before any response has come back, so the conversation has no `task_id` yet. Mitigation: stamp the share with `source_task_id: None` initially and upgrade to `source_task_id: Some(...)` on the first `StreamInit` event. The cascade does not fire until the upgrade happens, so children spawned in the pre-stamp window are not auto-shared; surface this in the share modal copy if it becomes a UX problem in practice. +- **Risk:** Every non-orchestrator user share now triggers one initial REST descendant fetch in the viewer, since `source_task_id: Some(...)` is now the new default whenever the conversation has an `ai_tasks` row. Mitigation: the explicit `idle_due_to_no_children` flag in `OrchestrationViewerModel` (see Thread C) ensures the model stops polling after an empty descendant fetch and only resumes on a real `AppendedExchange`. The single initial fetch per viewer is acceptable. +- **Risk:** Driver-side `session_id` link introduces a new fire-and-forget RPC. Mitigation: dedupe on `(task_id, session_id)` and apply the standard viewer/remote-child/missing-id guards (see Thread D) so it only fires when the local client actually owns the shared session. +- **Risk:** Adding `source_task_id` to `InitPayload` and `JoinedSuccessfully` adds new fields to the wire. Mitigation: both are `#[serde(default)]`, so old producers/consumers ignore them silently. `SessionSourceType::User` stays unit-shaped, so legacy viewers continue to parse the source type without changes. +- **Risk:** Forgetting to migrate an orchestration-discovery site from `matches!(source_type, SessionSourceType::AmbientAgent { .. })` to the new sidecar-read path would leave the local-local pill bar broken even after the rollout. Mitigation: read `source_task_id` via a single helper on the payload/model, audit and migrate every existing match site, and add a clippy-friendly comment at the `AmbientAgent`-only sites (tab indicator, AI context menu, details-panel auto-open, viewer-driven sizing skip) explaining why they intentionally stay variant-matched. +- **Risk:** `TelemetryEvent::JoinedSharedSession { session_id, source_type }` (`view_impl.rs:773-779`) still emits the variant kind, but the orchestrator `task_id` is no longer carried inside the variant. Mitigation: include `source_task_id` as a sibling field on the telemetry event so analytics dashboards can filter on the task id without parsing the variant payload. +## Follow-ups +- Once Threads A and D are in place, evaluate whether the `OrchestrationViewerModel` polling cadence is still appropriate for live local-local shares (every 5s may be excessive when all updates are also flowing through the shared-session WebSocket). A follow-up could switch to a WebSocket-driven update for local-local at the cost of larger protocol changes. +- Consider unifying the dispatcher state-machine on the server side so local and cloud executions share a single life-cycle for session-link writes. The CLAIMED-vs-RUNNING split is currently implicit in worker behavior; an explicit `state IN ('CLAIMED', 'RUNNING')` predicate captures the intent but doesn't address why local executions never advance past CLAIMED in the first place. +- Once the upstream `session-sharing-protocol` commit is published and consumed by both warp and session-sharing-server, drop the vendored copy in session-sharing-server entirely and depend on the git crate. Removes the two-place-update tax for future wire changes. From 93ac28ff54c8d2fc7437eac1f71ec9020e40de1a Mon Sep 17 00:00:00 2001 From: cephalonaut Date: Fri, 22 May 2026 03:21:03 -0400 Subject: [PATCH 2/4] Skip synthetic Finished for in-flight exchanges in scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the sharer builds historical scrollback for a late-joining viewer, `reconstruct_response_events_from_conversations` was unconditionally emitting a Finished event after every exchange, including ones still in-flight. The viewer would take its `current_response_id` slot on the replayed Finished and then drop every live ClientAction arriving for the same still-running stream — the conversation appeared stuck on the initial prompt until the next request started. Gate the synthetic Finished on `exchange.output_status.is_finished()` so in-flight exchanges end the scrollback with their accumulated messages only; the live wire's real Finished closes the stream naturally. Co-Authored-By: Oz --- .../shared_session/replay_agent_conversations.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/terminal/shared_session/replay_agent_conversations.rs b/app/src/terminal/shared_session/replay_agent_conversations.rs index fb180a5b6e..467402d5c6 100644 --- a/app/src/terminal/shared_session/replay_agent_conversations.rs +++ b/app/src/terminal/shared_session/replay_agent_conversations.rs @@ -132,8 +132,16 @@ pub fn reconstruct_response_events_from_conversations( )); } - // Finish this exchange - events.push(create_finished_event_from_conversation(conversation)); + // Finish this exchange — but ONLY if it actually finished. If an + // exchange is still in-flight when the scrollback is built, emitting a + // synthetic Finished here corrupts the late-joining viewer's stream: + // the viewer clears `current_response_id` and then drops every live + // ClientAction that arrives for the same in-flight stream. Skipping + // the synthetic Finished lets the live wire's real Finished close the + // stream naturally for the viewer. + if exchange.output_status.is_finished() { + events.push(create_finished_event_from_conversation(conversation)); + } } events From e23b5e9b418b1741cc0cb3b1dc9317b0229400a3 Mon Sep 17 00:00:00 2001 From: cephalonaut Date: Fri, 22 May 2026 03:41:43 -0400 Subject: [PATCH 3/4] Gate wasm-unused imports and dead function with cfg attributes `BlocklistAIHistoryEvent` (pane_group/mod.rs) and `SharedSessionSource` (pane_group/pane/terminal_pane.rs) are only consumed inside `#[cfg(not(target_family = "wasm"))]` blocks, so the wasm build flags them as unused. `PaneGroup::stop_transitively_shared_child_shares` is only invoked from a non-wasm dispatch arm, so the wasm build flags it as dead. Gate each with the matching cfg attribute to keep the wasm clippy build warning-clean. Co-Authored-By: Oz --- app/src/pane_group/mod.rs | 176 ++++++++---------- app/src/pane_group/pane/terminal_pane.rs | 121 ++++++------ .../pane_group/pane/terminal_pane_tests.rs | 3 +- 3 files changed, 137 insertions(+), 163 deletions(-) diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index cf016d49f1..f21e85a398 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -1,77 +1,14 @@ -use crate::ai::active_agent_views_model::ActiveAgentViewsModel; -use crate::ai::agent::api::ServerConversationToken; -use crate::ai::agent::conversation::{AIAgentHarness, AIConversation, AIConversationId}; -use crate::ai::agent_conversations_model::{ - AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel, - AgentConversationsModelEvent, -}; -use crate::ai::ai_document_view::AIDocumentView; -use crate::ai::ambient_agents::AmbientAgentTaskId; -use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; -use crate::ai::blocklist::history_model::CloudConversationData; -use crate::ai::blocklist::inline_action::code_diff_view::CodeDiffView; -use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId; -use crate::ai::blocklist::suggested_rule_modal::SuggestedRuleAndId; -use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel, InputConfig}; -use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; -use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}; -use crate::ai::llms::LLMId; -use crate::ai::restored_conversations::RestoredAgentConversations; -use crate::auth::auth_manager::AuthManager; -use crate::auth::auth_view_modal::AuthViewVariant; -use crate::auth::AuthStateProvider; -use crate::cloud_object::Space; -use crate::code::buffer_location::LocalOrRemotePath; -#[cfg(feature = "local_fs")] -use crate::code::editor_management::CodeSource; -use crate::code::view::CodeViewAction; -use crate::code_review::comments::{AttachedReviewComment, PendingImportedReviewComment}; -use crate::code_review::diff_state::DiffMode; -use crate::env_vars::EnvVarCollectionType; -use crate::notebooks::file::FileNotebookView; -use crate::pane_group::focus_state::PaneGroupFocusEvent; -use crate::pane_group::pane::get_started_pane::GetStartedPane; -#[cfg(not(target_family = "wasm"))] -use crate::pane_group::pane::terminal_pane::{ - host_terminal_shared_session_source_type, inherit_share_for_local_child, -}; -use crate::pane_group::pane::welcome_pane::WelcomePane; -use crate::pane_group::pane::ActionOrigin; -use crate::quit_warning::UnsavedStateSummary; -#[cfg(target_family = "wasm")] -use crate::server::cloud_objects::update_manager::UpdateManager; -use crate::server::server_api::ServerApiProvider; -use crate::settings::{AISettings, DefaultSessionMode, PaneSettings}; -use crate::settings_view::SettingsSection; -use crate::shell_indicator::ShellIndicatorType; -use crate::terminal::available_shells::{AvailableShell, AvailableShells}; -#[cfg(not(target_family = "wasm"))] -use crate::terminal::cli_agent_sessions::plugin_manager::PluginModalKind; -use crate::terminal::view::inline_banner::{ - ZeroStatePromptSuggestionTriggeredFrom, ZeroStatePromptSuggestionType, -}; -use crate::terminal::view::load_ai_conversation::RestoredAIConversation; -use crate::undo_close::UndoCloseStack; -use crate::undo_close::UndoCloseStackEvent; -#[cfg(target_family = "wasm")] -use crate::uri::browser_url_handler::update_browser_url; -#[cfg(feature = "local_fs")] -use crate::util::openable_file_type::FileTarget; -use crate::view_components::ToastFlavor; -use crate::workflows::workflow::Workflow; -use warp_terminal::shell::{ShellName, ShellType}; - use std::any::Any; use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::path::PathBuf; use std::rc::Rc; -use std::sync::{mpsc::SyncSender, Arc}; +use std::sync::mpsc::SyncSender; +use std::sync::Arc; use itertools::Itertools; use lazy_static::lazy_static; - use markdown_parser::FormattedTextFragment; use parking_lot::FairMutex; use pathfinder_geometry::rect::RectF; @@ -80,6 +17,7 @@ use serde::{Deserialize, Serialize}; use session_sharing_protocol::common::{ ParticipantId, Role, RoleRequestId, RoleRequestRejectedReason, RoleRequestResponse, SessionId, }; +use settings::Setting as _; use tree::DEFAULT_FLEX_VALUE; use typed_path::TypedPath; use url::Url; @@ -87,26 +25,44 @@ use uuid::Uuid; use warp_cli::agent::Harness; use warp_core::command::ExitCode; use warp_core::context_flag::ContextFlag; +use warp_terminal::shell::{ShellName, ShellType}; use warp_util::path::convert_wsl_to_windows_host_path; #[cfg(feature = "local_fs")] use warp_util::path::LineAndColumnArg; use warp_util::remote_path::RemotePath; use warpui::elements::{ - Clipped, CrossAxisAlignment, DispatchEventResult, EventHandler, Flex, MainAxisSize, Shrinkable, - Stack, + ChildView, Clipped, CrossAxisAlignment, DispatchEventResult, Element, EventHandler, Flex, + MainAxisSize, ParentElement, Shrinkable, Stack, }; use warpui::keymap::{Context, EditableBinding, FixedBinding}; use warpui::notification::NotificationSendError; - use warpui::windowing::WindowManager; use warpui::{ - elements::{ChildView, Element, ParentElement}, - AppContext, Entity, EntityId, ModelHandle, TypedActionView, View, ViewHandle, WeakViewHandle, - WindowId, + AppContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, + ViewHandle, WeakViewHandle, WindowId, }; -use warpui::{SingletonEntity, ViewContext}; -use crate::ai::blocklist::SerializedBlockListItem; +use crate::ai::active_agent_views_model::ActiveAgentViewsModel; +use crate::ai::agent::api::ServerConversationToken; +use crate::ai::agent::conversation::{AIAgentHarness, AIConversation, AIConversationId}; +use crate::ai::agent_conversations_model::{ + AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel, + AgentConversationsModelEvent, +}; +use crate::ai::ai_document_view::AIDocumentView; +use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; +use crate::ai::blocklist::history_model::CloudConversationData; +use crate::ai::blocklist::inline_action::code_diff_view::CodeDiffView; +use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId; +use crate::ai::blocklist::suggested_rule_modal::SuggestedRuleAndId; +#[cfg(not(target_family = "wasm"))] +use crate::ai::blocklist::BlocklistAIHistoryEvent; +use crate::ai::blocklist::{BlocklistAIHistoryModel, InputConfig, SerializedBlockListItem}; +use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; +use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}; +use crate::ai::llms::LLMId; +use crate::ai::restored_conversations::RestoredAgentConversations; use crate::ai_assistant::AskAIType; #[cfg(feature = "local_fs")] use crate::app_state::CodePaneSnapShot; @@ -116,30 +72,60 @@ use crate::app_state::{ TerminalPaneSnapshot, WorkflowPaneSnapshot, }; use crate::appearance::Appearance; +use crate::auth::auth_manager::AuthManager; +use crate::auth::auth_view_modal::AuthViewVariant; +use crate::auth::AuthStateProvider; use crate::banner::{Banner, BannerEvent, BannerState, BannerTextContent, DismissalType}; use crate::channel::{Channel, ChannelState}; -use crate::code::view::CodeView; +use crate::cloud_object::Space; +use crate::code::active_file::ActiveFileModel; +use crate::code::buffer_location::LocalOrRemotePath; +#[cfg(feature = "local_fs")] +use crate::code::editor_management::CodeSource; +use crate::code::view::{CodeView, CodeViewAction}; +use crate::code_review::comments::{AttachedReviewComment, PendingImportedReviewComment}; +use crate::code_review::diff_state::DiffMode; use crate::drive::items::WarpDriveItemId; use crate::drive::{CloudObjectTypeAndId, OpenWarpDriveObjectArgs}; +use crate::env_vars::EnvVarCollectionType; use crate::features::FeatureFlag; use crate::launch_configs::launch_config::{self, PaneMode, PaneTemplateType}; +use crate::notebooks::file::FileNotebookView; +use crate::palette::PaletteMode; +use crate::pane_group::focus_state::PaneGroupFocusEvent; +use crate::pane_group::pane::get_started_pane::GetStartedPane; +#[cfg(not(target_family = "wasm"))] +use crate::pane_group::pane::terminal_pane::{ + host_terminal_shared_session_source_type, inherit_share_for_local_child, +}; +use crate::pane_group::pane::welcome_pane::WelcomePane; +use crate::pane_group::pane::ActionOrigin; use crate::persistence::ModelEvent; -use crate::report_if_error; +use crate::quit_warning::UnsavedStateSummary; use crate::resource_center::{ mark_feature_used_and_write_to_user_defaults, Tip, TipAction, TipsCompleted, }; +#[cfg(target_family = "wasm")] +use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::ids::{ObjectUid, SyncId}; +use crate::server::server_api::{ServerApi, ServerApiProvider}; use crate::server::telemetry::{ AnonymousUserSignupEntrypoint, PaletteSource, SharingDialogSource, TelemetryEvent, }; use crate::session_management::SessionNavigationData; +use crate::settings::{AISettings, DefaultSessionMode, PaneSettings}; use crate::settings_view::mcp_servers_page::MCPServersSettingsPage; +use crate::settings_view::SettingsSection; +use crate::shell_indicator::ShellIndicatorType; +use crate::terminal::available_shells::{AvailableShell, AvailableShells}; +#[cfg(not(target_family = "wasm"))] +use crate::terminal::cli_agent_sessions::plugin_manager::PluginModalKind; use crate::terminal::general_settings::{GeneralSettings, GeneralSettingsChangedEvent}; #[cfg(feature = "local_tty")] use crate::terminal::local_tty; use crate::terminal::model::session::Session; -use crate::terminal::session_settings::NewSessionSource; -use crate::terminal::session_settings::SessionSettings; +use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; +use crate::terminal::session_settings::{NewSessionSource, SessionSettings}; use crate::terminal::shared_session::render_util::ParticipantAvatarParams; use crate::terminal::shared_session::role_change_modal::{ RoleChangeCloseSource, RoleChangeModal, RoleChangeModalEvent, @@ -148,6 +134,10 @@ use crate::terminal::shared_session::share_modal::{ShareSessionModal, ShareSessi use crate::terminal::shared_session::{ self, IsSharedSessionCreator, SharedSessionActionSource, SharedSessionSource, }; +use crate::terminal::view::inline_banner::{ + ZeroStatePromptSuggestionTriggeredFrom, ZeroStatePromptSuggestionType, +}; +use crate::terminal::view::load_ai_conversation::RestoredAIConversation; use crate::terminal::view::ssh_file_upload::FileUploadId; use crate::terminal::view::{ BlockNotification, ConversationRestorationInNewPaneType, ExecuteCommandEvent, @@ -155,23 +145,21 @@ use crate::terminal::view::{ }; use crate::terminal::{ MockTerminalManager, ShareBlockModal, ShareBlockModalEvent, ShellLaunchData, ShellLaunchState, + TerminalManager, TerminalModel, TerminalView, }; -use crate::{cmd_or_ctrl_shift, send_telemetry_from_ctx}; -use settings::Setting as _; - -use crate::code::active_file::ActiveFileModel; +use crate::undo_close::{UndoCloseStack, UndoCloseStackEvent}; +#[cfg(target_family = "wasm")] +use crate::uri::browser_url_handler::update_browser_url; use crate::util::bindings::{is_binding_pty_compliant, CustomAction}; +#[cfg(feature = "local_fs")] +use crate::util::openable_file_type::FileTarget; +use crate::view_components::ToastFlavor; +use crate::workflows::workflow::Workflow; use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType}; - -use crate::palette::PaletteMode; -use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; use crate::workspace::{ self, CommandSearchOptions, PaneViewLocator, TabBarLocation, WorkspaceAction, }; -use crate::{ - server::server_api::ServerApi, - terminal::{TerminalManager, TerminalModel, TerminalView}, -}; +use crate::{cmd_or_ctrl_shift, report_if_error, send_telemetry_from_ctx}; mod child_agent; pub mod focus_state; @@ -179,14 +167,12 @@ pub mod pane; pub mod tree; pub mod working_directories; use child_agent::{apply_hidden_child_agent_task_context, HiddenChildAgentTaskContext}; - use focus_state::PaneGroupFocusState; #[cfg(test)] #[path = "mod_tests.rs"] mod tests; -pub use crate::code_review::CodeReviewPanelArg; pub use pane::ai_document_pane::AIDocumentPane; pub use pane::ai_fact_pane::AIFactPane; pub use pane::code_diff_pane::CodeDiffPane; @@ -200,16 +186,15 @@ pub use pane::notebook_pane::NotebookPane; pub use pane::settings_pane::SettingsPane; pub use pane::terminal_pane::TerminalPane; pub use pane::workflow_pane::WorkflowPane; -pub use pane::PaneHeaderAction; -pub use pane::PaneHeaderCustomAction; pub use pane::{ AnyPaneContent, BackingView, PaneConfiguration, PaneConfigurationEvent, PaneContent, PaneEvent, - PaneId, PaneView, TerminalPaneId, + PaneHeaderAction, PaneHeaderCustomAction, PaneId, PaneView, TerminalPaneId, }; pub use tree::{Direction, PaneData, PaneFlex, PaneNode, SplitDirection}; pub use working_directories::{WorkingDirectoriesEvent, WorkingDirectoriesModel}; use self::pane::{DetachType, PaneViewEvent}; +pub use crate::code_review::CodeReviewPanelArg; lazy_static! { // The value to use as the initial window bounds if we are unable to @@ -4576,7 +4561,10 @@ impl PaneGroup { } /// Stop the shared session on every child pane that was transitively - /// shared from `host_pane_id`. + /// shared from `host_pane_id`. Only called from a non-wasm dispatch arm + /// (`Event::StopSharingCurrentSession`), so the definition mirrors that + /// cfg gate to keep wasm builds warning-clean. + #[cfg(not(target_family = "wasm"))] fn stop_transitively_shared_child_shares( &mut self, host_pane_id: PaneId, diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index d4a0dcd8fe..ee8603d6c7 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1,68 +1,69 @@ //! Implementation of terminal panes. -use crate::code::buffer_location::LocalOrRemotePath; -#[cfg(feature = "local_fs")] -use crate::pane_group::CodeSource; #[cfg(not(target_family = "wasm"))] use std::collections::HashMap; use std::sync::mpsc::SyncSender; -use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use base64::Engine as _; +#[cfg(not(target_family = "wasm"))] +use session_sharing_protocol::sharer::SessionSourceType; use url::Url; use warp_cli::agent::Harness; +use warp_core::execution_mode::AppExecutionMode; use warp_multi_agent_api as multi_agent_api; - use warpui::{ AppContext, EntityId, ModelHandle, SingletonEntity, ViewContext, ViewHandle, WindowId, }; -use crate::{ - ai::{ - active_agent_views_model::ActiveAgentViewsModel, - agent::{ - conversation::{AIConversationId, ConversationStatus}, - StartAgentExecutionMode, - }, - ambient_agents::{ - task::{normalize_orchestrator_agent_name, HarnessConfig}, - AgentConfigSnapshot, AmbientAgentTaskId, - }, - blocklist::{ - agent_view::{AgentViewControllerEvent, AgentViewEntryOrigin}, - orchestration_event_streamer::OrchestrationEventStreamer, - orchestration_events::{OrchestrationEventService, SendEventResult}, - BlocklistAIHistoryModel, StartAgentRequest, - }, - conversation_utils, - llms::LLMPreferences, - skills::SkillManager, - }, - app_state::{AmbientAgentPaneSnapshot, LeafContents, TerminalPaneSnapshot}, - features::FeatureFlag, - pane_group::child_agent::{ - create_error_child_agent_conversation, ErrorChildAgentConversationRequest, - }, - pane_group::{self, Direction, Event::OpenConversationHistory, PaneGroup}, - persistence::{BlockCompleted, ModelEvent}, - server::server_api::ai::{SpawnAgentRequest, UserQueryMode}, - session_management::SessionNavigationData, - terminal::cli_agent_sessions::CLIAgentSessionsModel, - terminal::view::ambient_agent::should_disable_snapshot, - terminal::{ - general_settings::GeneralSettings, - shared_session::{ - join_link, - manager::{Manager, ManagerEvent}, - role_change_modal::RoleChangeOpenSource, - SharedSessionSource, SharedSessionStatus, - }, - view::Event, - TerminalManager, TerminalView, - }, - view_components::ToastFlavor, - workspace::{sync_inputs::SyncedInputState, PaneViewLocator, WorkspaceRegistry}, - AIExecutionProfilesModel, +#[cfg(not(target_family = "wasm"))] +use super::local_harness_launch::{prepare_local_harness_child_launch, PreparedLocalHarnessLaunch}; +use super::{ + DetachType, PaneConfiguration, PaneContent, PaneId, PaneStackEvent, PaneView, ShareableLink, + ShareableLinkError, TerminalPaneId, }; - +use crate::ai::active_agent_views_model::ActiveAgentViewsModel; +use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; +use crate::ai::agent::StartAgentExecutionMode; +use crate::ai::ambient_agents::task::{normalize_orchestrator_agent_name, HarnessConfig}; +use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId}; +use crate::ai::blocklist::agent_view::{AgentViewControllerEvent, AgentViewEntryOrigin}; +use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; +use crate::ai::blocklist::orchestration_events::{OrchestrationEventService, SendEventResult}; +#[cfg(feature = "local_fs")] +use crate::ai::blocklist::BlocklistAIHistoryEvent; +use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequest}; +use crate::ai::conversation_utils; +use crate::ai::llms::LLMPreferences; +use crate::ai::skills::SkillManager; +use crate::app_state::{AmbientAgentPaneSnapshot, LeafContents, TerminalPaneSnapshot}; +use crate::code::buffer_location::LocalOrRemotePath; +use crate::features::FeatureFlag; +use crate::pane_group::child_agent::{ + create_error_child_agent_conversation, ErrorChildAgentConversationRequest, +}; +#[cfg(feature = "local_fs")] +use crate::pane_group::CodeSource; +use crate::pane_group::Event::OpenConversationHistory; +use crate::pane_group::{self, Direction, PaneGroup}; +use crate::persistence::{BlockCompleted, ModelEvent}; +use crate::server::server_api::ai::{SpawnAgentRequest, UserQueryMode}; +#[cfg(not(target_family = "wasm"))] +use crate::server::server_api::ServerApiProvider; +use crate::session_management::SessionNavigationData; +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; +use crate::terminal::general_settings::GeneralSettings; +use crate::terminal::shared_session::manager::{Manager, ManagerEvent}; +use crate::terminal::shared_session::role_change_modal::RoleChangeOpenSource; +#[cfg(not(target_family = "wasm"))] +use crate::terminal::shared_session::SharedSessionSource; +use crate::terminal::shared_session::{join_link, SharedSessionStatus}; +use crate::terminal::view::ambient_agent::should_disable_snapshot; +use crate::terminal::view::Event; +use crate::terminal::{TerminalManager, TerminalView}; +use crate::view_components::ToastFlavor; +use crate::workspace::sync_inputs::SyncedInputState; +use crate::workspace::{PaneViewLocator, WorkspaceRegistry}; +use crate::AIExecutionProfilesModel; // Imports below are only consumed by the non-wasm `launch_local_*_child` // dispatch helpers; gating them keeps the wasm build warning-clean. #[cfg(not(target_family = "wasm"))] @@ -75,22 +76,6 @@ use crate::{ terminal::shared_session::IsSharedSessionCreator, }; -#[cfg(feature = "local_fs")] -use crate::ai::blocklist::BlocklistAIHistoryEvent; -#[cfg(not(target_family = "wasm"))] -use crate::server::server_api::ServerApiProvider; - -#[cfg(not(target_family = "wasm"))] -use session_sharing_protocol::sharer::SessionSourceType; -use warp_core::execution_mode::AppExecutionMode; - -#[cfg(not(target_family = "wasm"))] -use super::local_harness_launch::{prepare_local_harness_child_launch, PreparedLocalHarnessLaunch}; -use super::{ - DetachType, PaneConfiguration, PaneContent, PaneId, PaneStackEvent, PaneView, ShareableLink, - ShareableLinkError, TerminalPaneId, -}; - pub type TerminalPaneView = PaneView; /// Data kept for terminal panes. diff --git a/app/src/pane_group/pane/terminal_pane_tests.rs b/app/src/pane_group/pane/terminal_pane_tests.rs index 5824bb6989..4a3db178e5 100644 --- a/app/src/pane_group/pane/terminal_pane_tests.rs +++ b/app/src/pane_group/pane/terminal_pane_tests.rs @@ -3,9 +3,10 @@ //! is gated by `FeatureFlag::OrchestrationViewerPillBar` so each case //! must override it explicitly. -use super::*; use uuid::Uuid; +use super::*; + fn new_task_id() -> AmbientAgentTaskId { Uuid::new_v4().to_string().parse().unwrap() } From c2f61d89af52679db3b4ba4d71af63f5f73aa51f Mon Sep 17 00:00:00 2001 From: cephalonaut Date: Fri, 22 May 2026 03:55:27 -0400 Subject: [PATCH 4/4] Restore master import ordering on rebase-churned files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase ran the workspace's default rustfmt against several files whose import blocks then drifted from master's organization, producing 800+ lines of import-only churn in the PR diff. Re-formatting these four files with rustfmt's stable form (`imports_granularity=Module`, `group_imports=StdExternalCrate`) restores their original master-side import layout. No semantic changes — diff vs master shrinks by ~800 lines on these four files alone: app/src/terminal/view.rs 863 → 38 lines app/src/terminal/model/terminal_model.rs 166 → 46 lines app/src/terminal/local_tty/terminal_manager.rs 238 → 119 lines app/src/workspace/view_tests.rs 101 → 13 lines Co-Authored-By: Oz --- .../terminal/local_tty/terminal_manager.rs | 119 ++- app/src/terminal/model/terminal_model.rs | 122 ++- app/src/terminal/view.rs | 825 ++++++++---------- app/src/workspace/view_tests.rs | 92 +- 4 files changed, 544 insertions(+), 614 deletions(-) diff --git a/app/src/terminal/local_tty/terminal_manager.rs b/app/src/terminal/local_tty/terminal_manager.rs index 3a82415870..5163e7fab0 100644 --- a/app/src/terminal/local_tty/terminal_manager.rs +++ b/app/src/terminal/local_tty/terminal_manager.rs @@ -1,46 +1,17 @@ -use crate::ai::aws_credentials::AwsCredentialRefresher as _; -use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; -use crate::auth::auth_state::AuthState; -use crate::auth::AuthStateProvider; -use crate::terminal::model::terminal_model::ExitReason; -use crate::terminal::shared_session::replay_agent_conversations::reconstruct_response_events_from_conversations; -use crate::terminal::shared_session::shared_handlers::{ - apply_auto_approve_agent_actions_update, apply_cli_agent_state_update, apply_input_mode_update, - apply_selected_agent_model_update, apply_selected_conversation_update, - build_selected_conversation_update, RemoteUpdateGuard, -}; -use crate::terminal::shell::ShellName; -use crate::terminal::warpify::settings::WarpifySettings; -use crate::terminal::TerminalManager as _; -use anyhow::Context as _; -use async_broadcast::InactiveReceiver; use std::any::Any; use std::cell::RefCell; +use std::collections::HashMap; +use std::ffi::OsString; +use std::path::PathBuf; use std::rc::Rc; use std::sync::mpsc::{SendError, SyncSender}; -use std::{collections::HashMap, ffi::OsString, path::PathBuf, sync::Arc, thread::JoinHandle}; - -use session_sharing_protocol::sharer::{ - AddGuestsResponse, FailedToInitializeSessionReason, Lifetime, LinkAccessLevelUpdateResponse, - QuotaType, RemoveGuestResponse, SessionEndedReason, SessionSourceType, - TeamAccessLevelUpdateResponse, UpdatePendingUserRoleResponse, -}; - -use crate::editor::CrdtOperation; -use crate::network::{NetworkStatusEvent, NetworkStatusKind}; -use crate::terminal::available_shells::{AvailableShell, AvailableShells}; -use crate::terminal::shared_session::permissions_manager::SessionPermissionsManager; -use crate::terminal::shared_session::presence_manager::PresenceManager; -use crate::terminal::ShellLaunchData; -use crate::terminal::ShellLaunchState; -use crate::view_components::ToastFlavor; +use std::sync::Arc; +use std::thread::JoinHandle; +use anyhow::Context as _; +use async_broadcast::InactiveReceiver; use parking_lot::{FairMutex, Mutex}; use pathfinder_geometry::vector::Vector2F; - -use crate::terminal::cli_agent_sessions::{ - CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, -}; use session_sharing_protocol::common::{ ActivePrompt, AgentPromptFailureReason, CLIAgentSessionState, CommandExecutionFailureReason, ControlAction, ControlActionFailureReason, SelectedAgentModel, @@ -50,42 +21,70 @@ use session_sharing_protocol::common::{ use session_sharing_protocol::common::{ LongRunningCommandAgentInteractionState, SelectedConversation, UniversalDeveloperInputContext, }; +use session_sharing_protocol::sharer::{ + AddGuestsResponse, FailedToInitializeSessionReason, Lifetime, LinkAccessLevelUpdateResponse, + QuotaType, RemoveGuestResponse, SessionEndedReason, SessionSourceType, + TeamAccessLevelUpdateResponse, UpdatePendingUserRoleResponse, +}; use settings::Setting as _; +use warp_core::execution_mode::AppExecutionMode; +use warp_core::send_telemetry_from_ctx; use warpui::r#async::executor::Background; use warpui::{AppContext, ModelContext, ModelHandle, SingletonEntity, ViewHandle, WindowId}; +#[cfg(unix)] +use { + super::terminal_attributes::TerminalAttributesPoller, + crate::terminal::local_tty::terminal_attributes::Event as TerminalAttributesPollerEvent, + crate::terminal::model::terminal_model::BlockIndex, + crate::terminal::session_settings::NotificationsMode, nix::sys::termios::LocalFlags, +}; -use warp_core::execution_mode::AppExecutionMode; - +use super::event_loop::EventLoop; +use super::shell::{ShellStarter, ShellStarterSource}; +use super::{mio_channel, recorder}; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::agent::conversation::AIConversation; +use crate::ai::aws_credentials::AwsCredentialRefresher as _; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent}; use crate::ai::blocklist::{ BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIControllerEvent, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, InputConfig, SerializedBlockListItem, }; -use crate::terminal::view::ConversationRestorationInNewPaneType; - +use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; +use crate::auth::auth_state::AuthState; +use crate::auth::AuthStateProvider; use crate::banner::BannerState; use crate::context_chips::current_prompt::CurrentPrompt; use crate::context_chips::prompt_snapshot::PromptSnapshot; use crate::context_chips::prompt_type::PromptType; +use crate::editor::CrdtOperation; use crate::features::FeatureFlag; +use crate::network::{NetworkStatusEvent, NetworkStatusKind}; use crate::pane_group::TerminalViewResources; use crate::persistence::ModelEvent; - -use crate::send_telemetry_on_executor; use crate::server::telemetry::{TelemetryAgentViewEntryOrigin, TelemetryEvent}; -use crate::settings::DebugSettings; -use crate::settings::{PrivacySettings, SshSettings}; -use warp_core::send_telemetry_from_ctx; - +use crate::settings::{DebugSettings, PrivacySettings, SshSettings}; +use crate::terminal::available_shells::{AvailableShell, AvailableShells}; +use crate::terminal::cli_agent_sessions::{ + CLIAgentInputState, CLIAgentSessionsModel, CLIAgentSessionsModelEvent, +}; +use crate::terminal::event_listener::ChannelEventListener; +use crate::terminal::local_tty::{Pty, PtyOptions}; use crate::terminal::model::session::Sessions; - +use crate::terminal::model::terminal_model::ExitReason; use crate::terminal::model_events::ModelEventDispatcher; use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode; use crate::terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent}; use crate::terminal::shared_session::manager::Manager; +use crate::terminal::shared_session::permissions_manager::SessionPermissionsManager; +use crate::terminal::shared_session::presence_manager::PresenceManager; +use crate::terminal::shared_session::replay_agent_conversations::reconstruct_response_events_from_conversations; use crate::terminal::shared_session::settings::SharedSessionSettings; +use crate::terminal::shared_session::shared_handlers::{ + apply_auto_approve_agent_actions_update, apply_cli_agent_state_update, apply_input_mode_update, + apply_selected_agent_model_update, apply_selected_conversation_update, + build_selected_conversation_update, RemoteUpdateGuard, +}; use crate::terminal::shared_session::sharer::network::{ failed_to_add_guests_user_error, failed_to_initialize_session_user_error, session_terminated_reason_string, Network, NetworkEvent, @@ -94,7 +93,9 @@ use crate::terminal::shared_session::{ IsSharedSessionCreator, SharedSessionActionSource, SharedSessionScrollbackType, SharedSessionSource, SharedSessionStatus, }; -use crate::terminal::view::Event as TerminalViewEvent; +use crate::terminal::shell::ShellName; +use crate::terminal::view::{ConversationRestorationInNewPaneType, Event as TerminalViewEvent}; +use crate::terminal::warpify::settings::WarpifySettings; use crate::terminal::writeable_pty::pty_controller::{EventLoopSendError, EventLoopSender}; use crate::terminal::writeable_pty::terminal_manager_util::{ init_pty_controller_model, init_remote_server_controller, wire_up_pty_controller_with_view, @@ -102,25 +103,11 @@ use crate::terminal::writeable_pty::terminal_manager_util::{ }; use crate::terminal::writeable_pty::{self, Message}; use crate::terminal::{ - event_listener::ChannelEventListener, - local_tty::{Pty, PtyOptions}, - TerminalModel, -}; -use crate::terminal::{terminal_manager, TerminalView, PTY_READS_BROADCAST_CHANNEL_SIZE}; -use crate::NetworkStatus; - -use super::mio_channel; -use super::recorder; -use super::shell::ShellStarter; -use super::{event_loop::EventLoop, shell::ShellStarterSource}; - -#[cfg(unix)] -use { - super::terminal_attributes::TerminalAttributesPoller, - crate::terminal::local_tty::terminal_attributes::Event as TerminalAttributesPollerEvent, - crate::terminal::model::terminal_model::BlockIndex, - crate::terminal::session_settings::NotificationsMode, nix::sys::termios::LocalFlags, + terminal_manager, ShellLaunchData, ShellLaunchState, TerminalManager as _, TerminalModel, + TerminalView, PTY_READS_BROADCAST_CHANNEL_SIZE, }; +use crate::view_components::ToastFlavor; +use crate::{send_telemetry_on_executor, NetworkStatus}; type PtyController = writeable_pty::PtyController>; type RemoteServerController = diff --git a/app/src/terminal/model/terminal_model.rs b/app/src/terminal/model/terminal_model.rs index 1e88872f61..b6eca5a518 100644 --- a/app/src/terminal/model/terminal_model.rs +++ b/app/src/terminal/model/terminal_model.rs @@ -1,34 +1,37 @@ -use crate::ai::ambient_agents::AmbientAgentTaskId; -use crate::ai::blocklist::SerializedBlockListItem; -use crate::terminal::available_shells::AvailableShell; -use crate::terminal::block_list_element::GridType; -use crate::terminal::event::{ - BootstrappedEvent, Event, ExecutedExecutorCommandEvent, InitSshEvent, InitSubshellEvent, - SourcedRcFileInSubshellEvent, SshLoginStatus, TerminalMode, -}; -use crate::terminal::event_listener::ChannelEventListener; -use crate::terminal::model::ansi; -use crate::terminal::model::bootstrap::BootstrapStage; -use crate::terminal::model::completions::{ - ShellCompletion, ShellCompletionUpdate, ShellData as CompletionsShellData, -}; -use crate::terminal::model::escape_sequences::ModeProvider; -use crate::terminal::model::index::VisibleRow; -use crate::terminal::model::iterm_image::{ITermImage, ITermImageMetadata}; -use crate::terminal::shared_session::{ - ai_agent::encode_agent_response_event, SharedSessionSource, SharedSessionStatus, -}; -use crate::terminal::ssh::util::{InteractiveSshCommand, SshLoginState}; -use crate::terminal::{block_filter::BlockFilterQuery, model::ansi::Handler}; -use crate::terminal::{color, ssh, BlockPadding, ShellHost, SizeUpdate, SizeUpdateReason}; -use crate::terminal::{ShellLaunchData, ShellLaunchState}; -use crate::util::AsciiDebug; +use std::cmp::{max, min}; +use std::collections::HashMap; +use std::num::ParseIntError; +use std::ops::{Range, RangeInclusive}; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; -pub use crate::terminal::history::HistoryEntry; +use async_channel::Sender; +use base64::Engine; +use hex::FromHexError; +use instant::Instant; +use itertools::{Either, Itertools}; +use serde::Serialize; +use session_sharing_protocol::common::{ + AICommandMetadata, OrderedTerminalEventType, ParticipantId, +}; +use session_sharing_protocol::sharer::SessionSourceType; +use warp_core::features::FeatureFlag; +use warp_core::report_error; +use warp_core::semantic_selection::SemanticSelection; +pub use warp_terminal::model::BlockIndex; +use warp_terminal::model::{KeyboardModes, KeyboardModesApplyBehavior}; +use warpui::assets::asset_cache::Asset; +use warpui::image_cache::ImageType; +use warpui::r#async::executor::Background; +#[cfg(not(target_family = "wasm"))] +use warpui::util::save_as_file; +use warpui::AppContext; +use super::super::{AltScreen, BlockList}; use super::ansi::{ - FinishUpdateValue, InputBufferValue, Mode, PendingHook, TmuxInstallFailedInfo, - WarpificationUnavailableReason, + BootstrappedValue, FinishUpdateValue, InputBufferValue, Mode, PendingHook, + TmuxInstallFailedInfo, WarpificationUnavailableReason, }; use super::block::{ AgentInteractionMetadata, Block, BlockId, BlockMetadata, BlockSize, BlockState, @@ -48,50 +51,43 @@ use super::secrets::{RespectObfuscatedSecrets, SecretAndHandle}; use super::selection::ScrollDelta; use super::session::{BootstrapSessionType, InBandCommandOutputReceiver, SessionId}; use super::tmux::commands::TmuxCommand; -use super::{ - super::{AltScreen, BlockList}, - ansi::BootstrappedValue, -}; use super::{tmux, Secret, SecretHandle}; +use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::blocklist::SerializedBlockListItem; +use crate::terminal::available_shells::AvailableShell; +use crate::terminal::block_filter::BlockFilterQuery; +use crate::terminal::block_list_element::GridType; +use crate::terminal::event::{ + BootstrappedEvent, Event, ExecutedExecutorCommandEvent, InitSshEvent, InitSubshellEvent, + SourcedRcFileInSubshellEvent, SshLoginStatus, TerminalMode, +}; +use crate::terminal::event_listener::ChannelEventListener; +pub use crate::terminal::history::HistoryEntry; +use crate::terminal::model::ansi; use crate::terminal::model::ansi::{ - ClearValue, CommandFinishedValue, ExitShellValue, InitShellValue, InitSshValue, + ClearValue, CommandFinishedValue, ExitShellValue, Handler, InitShellValue, InitSshValue, InitSubshellValue, PreInteractiveSSHSessionValue, PrecmdValue, PreexecValue, SSHValue, SourcedRcFileForWarpValue, }; +use crate::terminal::model::bootstrap::BootstrapStage; +use crate::terminal::model::completions::{ + ShellCompletion, ShellCompletionUpdate, ShellData as CompletionsShellData, +}; +use crate::terminal::model::escape_sequences::ModeProvider; use crate::terminal::model::grid::IndexRegion; +use crate::terminal::model::index::VisibleRow; +use crate::terminal::model::iterm_image::{ITermImage, ITermImageMetadata}; +use crate::terminal::model::secrets::ObfuscateSecrets; use crate::terminal::model::session::SessionInfo; +use crate::terminal::shared_session::ai_agent::encode_agent_response_event; +use crate::terminal::shared_session::{SharedSessionSource, SharedSessionStatus}; use crate::terminal::shell::{ShellName, ShellType}; - -use crate::terminal::model::secrets::ObfuscateSecrets; -use session_sharing_protocol::sharer::SessionSourceType; -use warp_core::report_error; -#[cfg(not(target_family = "wasm"))] -use warpui::util::save_as_file; - -use async_channel::Sender; -use base64::Engine; -use hex::FromHexError; -use instant::Instant; -use itertools::{Either, Itertools}; -use serde::Serialize; -use session_sharing_protocol::common::{ - AICommandMetadata, OrderedTerminalEventType, ParticipantId, +use crate::terminal::ssh::util::{InteractiveSshCommand, SshLoginState}; +use crate::terminal::{ + color, ssh, BlockPadding, ShellHost, ShellLaunchData, ShellLaunchState, SizeUpdate, + SizeUpdateReason, }; -use std::cmp::{max, min}; -use std::collections::HashMap; -use std::num::ParseIntError; -use std::ops::{Range, RangeInclusive}; -use std::path::PathBuf; -use std::str::FromStr; -use std::sync::Arc; -use warp_core::features::FeatureFlag; -use warp_core::semantic_selection::SemanticSelection; -pub use warp_terminal::model::BlockIndex; -use warp_terminal::model::{KeyboardModes, KeyboardModesApplyBehavior}; -use warpui::assets::asset_cache::Asset; -use warpui::image_cache::ImageType; -use warpui::r#async::executor::Background; -use warpui::AppContext; +use crate::util::AsciiDebug; /// Max size of the window title stack. const TITLE_STACK_MAX_DEPTH: usize = 4096; diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index c75630dbd0..8f75a0bd32 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -14,16 +14,17 @@ pub use load_ai_conversation::ConversationRestorationInNewPaneType; // TODO(advait): if we align on prompt suggestions banner in Input, move code out of inline_banner mod. pub(crate) mod init_environment; mod init_project; -use crate::ai::block_context::BlockContext; -#[cfg(feature = "local_fs")] -use crate::ai::skills::SkillOpenOrigin; -use crate::global_resource_handles::GlobalResourceHandlesProvider; pub use init_project::{ InitActionResult, InitProjectModel, InitProjectModelEvent, InitStepBlock, InitStepKind, ProjectScopedRulesResult, }; use onboarding::callout::{FinalState, OnboardingCalloutViewEvent, OnboardingQuery}; use onboarding::{OnboardingCalloutView, OnboardingKeybindings}; + +use crate::ai::block_context::BlockContext; +#[cfg(feature = "local_fs")] +use crate::ai::skills::SkillOpenOrigin; +use crate::global_resource_handles::GlobalResourceHandlesProvider; pub(crate) mod docker_sandbox; mod link_detection; mod open_in_warp; @@ -45,269 +46,414 @@ mod tooltips; pub mod use_agent_footer; mod zero_state_block; -use warpui::clipboard_utils::get_image_filepaths_from_paths; - -use std::ops::Deref as _; - -use crate::ai::blocklist::agent_view::fork_from_last_known_good_state_exchange_id; -use crate::ai::blocklist::agent_view::{ - agent_view_bg_fill, get_agent_view_entry_block_position_id, AgentViewController, - AgentViewControllerEvent, AgentViewDisplayMode, AgentViewEntryBlockParams, - AgentViewEntryOrigin, AgentViewHeaderDisabledTheme, AgentViewHeaderTheme, - AgentViewZeroStateBlock, AgentViewZeroStateEvent, EphemeralMessageModel, - ExitConfirmationTrigger, InlineAgentViewHeader, OrchestrationPillBar, - ENTER_OR_EXIT_CONFIRMATION_WINDOW, -}; -use crate::ai::conversation_utils; -use crate::ai::predict::prompt_suggestions::{ - has_pending_code_or_unit_test_prompt_suggestion, - is_accept_prompt_suggestion_bound_to_cmd_enter, - is_accept_prompt_suggestion_bound_to_ctrl_enter, -}; -use crate::search::slash_command_menu::static_commands::commands; -use crate::terminal::input::inline_menu::InlineMenuPositioner; -use crate::terminal::view::passive_suggestions::PromptSuggestionResolution; -pub use crate::terminal::view::rich_content::{ - AIBlockMetadata, AgentViewEntryMetadata, RichContent, RichContentInsertionPosition, - RichContentMetadata, -}; -use crate::terminal::view::zero_state_block::TerminalViewZeroStateBlock; -use crate::view_components::action_button::{ActionButton, ButtonSize, KeystrokeSource}; - -use use_agent_footer::UseAgentToolbar; - -use super::cli_agent; -use super::CLIAgent; -#[cfg(feature = "local_fs")] -use crate::ai::agent::{CurrentHead, DiffBase}; -use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent}; -use crate::ai::ambient_agents::{ - conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus, -}; -use crate::ai::blocklist::block::cli::{CLISubagentView, CLISubagentViewEvent}; -use crate::ai::blocklist::block::cli_controller::{ - CLISubagentController, CLISubagentEvent, UserTakeOverReason, -}; -use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBarEvent; -use crate::ai::blocklist::usage::conversation_usage_view::{ - ConversationUsageInfo, ConversationUsageView, TimingInfo, -}; -use crate::ai::blocklist::{block_context_from_terminal_model, SlashCommandRequest}; -use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; -use crate::ai::loading::shimmering_warp_loading_text; -#[cfg(feature = "local_fs")] -use crate::code_review::context::{ - convert_file_diffs_to_diffset_hunks, create_attachment_reference_and_key, - register_diffset_attachment, -}; -#[cfg(feature = "local_fs")] -use crate::code_review::DiffSetScope; -use crate::terminal::model::blocks::RemovableBlocklistItem; -#[cfg(feature = "local_fs")] -use crate::util::file::external_editor::{settings::EditorLayout, EditorSettings}; -use crate::util::truncation::truncate_from_end; - -use crate::ai::agent::api::ServerConversationToken; -use crate::ai::agent::redaction::redact_secrets; -use crate::ai::agent::todos::popup::{AgentTodosPopupEvent, AgentTodosPopupView}; -use crate::ai::agent::{ - AIAgentPtyWriteMode, AgentReviewCommentBatch, CancellationReason, PassiveSuggestionTrigger, - ServerOutputId, ShellCommandCompletedTrigger, -}; -use crate::ai::blocklist::block::{AIBlockAction, FinishReason}; -use crate::ai::blocklist::codebase_index_speedbump_banner::{ - CodebaseIndexSpeedbumpBannerAction, CodebaseIndexSpeedbumpBannerState, VisibilityState, -}; -use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper, AIBlockOutputStatus}; -#[cfg(feature = "local_fs")] -use crate::ai::persisted_workspace::PersistedWorkspace; -use crate::code_review::comments::{ - convert_insert_review_comments, AttachedReviewComment, PendingImportedReviewComment, -}; -#[cfg(feature = "local_fs")] -use crate::code_review::diff_state::LocalDiffStateModel; -use crate::code_review::diff_state::{DiffMode, GitDeltaPreference}; -#[cfg(feature = "local_fs")] -use crate::code_review::git_status_update::{ - GitRepoStatusModel, GitStatusMetadata, GitStatusUpdateModel, -}; -use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; -use crate::projects::ProjectManagementModel; -use crate::remote_server::manager::{ - RemoteServerInitPhase, RemoteServerManager, RemoteServerManagerEvent, -}; -use crate::settings::ai::FocusedTerminalInfo; -use crate::settings_view::mcp_servers_page::MCPServersSettingsPage; -use crate::terminal::cli_agent_sessions::event::{ - parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, - CLI_AGENT_NOTIFICATION_SENTINEL, -}; -use crate::terminal::cli_agent_sessions::listener::{ - agent_supports_rich_status, is_agent_supported, CLIAgentSessionListener, -}; -#[cfg(not(target_family = "wasm"))] -use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; -use crate::terminal::cli_agent_sessions::{ - CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, - CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, - CLIAgentSessionsModelEvent, -}; -use crate::terminal::view::init_environment::{ - mode_selector::{ - EnvironmentSetupMode, EnvironmentSetupModeSelector, EnvironmentSetupModeSelectorEvent, - }, - InitEnvironmentBlock, InitEnvironmentBlockEvent, -}; -use crate::terminal::view::ssh_remote_server_choice_view::{ - SshRemoteServerChoiceView, SshRemoteServerChoiceViewEvent, -}; -use crate::terminal::view::ssh_remote_server_failed_banner::{ - SshRemoteServerFailedBanner, SshRemoteServerFailedBannerEvent, -}; -use crate::terminal::view::telemetry::PromptSuggestionFallbackReason; -use crate::workspace::view::cloud_agent_capacity_modal::CloudAgentCapacityModalVariant; -use crate::workspaces::user_workspaces::UserWorkspacesEvent; +use std::any::Any; +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::hash_map::Entry; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::hash::Hash; +use std::ops::{Deref as _, Range}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::str::FromStr; +use std::sync::mpsc::SyncSender; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::Duration; -pub use self::link_detection::GridHighlightedLink; -pub use self::link_detection::{RichContentLink, RichContentLinkTooltipInfo}; -use crate::ai::llms::{LLMId, LLMModelHost, LLMPreferences}; -use crate::settings::CodeSettings; -use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; +use action::RememberForWarpification; pub use action::{AgentOnboardingVersion, OnboardingIntention, OnboardingVersion, TerminalAction}; use ai::api_keys::{ApiKeyManager, AwsCredentialsState}; use ai::index::full_source_code_embedding::manager::{BuildSource, CodebaseIndexManager}; +use async_channel::{Receiver, Sender}; +use block_banner::{render_warpification_banner, WarpificationMode, WarpifyBannerState}; pub use block_banner::{WithinBlockBanner, BLOCK_BANNER_HEIGHT}; use block_onboarding::onboarding_agentic_suggestions_block::{ OnboardingAgenticSuggestionsBlock, OnboardingAgenticSuggestionsBlockEvent, OnboardingChipType, }; use block_onboarding::onboarding_drive_sharing_block::OnboardingDriveSharingBlock; +use bookmarks::render_floating_block_snapshot; +use chrono::{DateTime, Local, NaiveDateTime}; +use command_corrections::rules::generic::history::History as CommandCorrectionsHistoryRule; +use command_corrections::rules::{Rule, RuleId as CommandCorrectionsRuleId}; +use command_corrections::{correct_command, Command, Correction, HistoryItem, SessionMetadata}; +use enclose::enclose; pub use init::{ init, CANCEL_COMMAND_KEYBINDING, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING, TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING, }; +use init::{INPUT_BOX_VISIBLE_KEY, TOGGLE_BLOCK_FILTER_KEYBINDING}; +use inline_banner::{ + render_alias_expansion_banner, render_aws_bedrock_login_banner, + render_aws_cli_not_installed_banner, render_inline_notifications_discovery_banner, + render_inline_notifications_error_banner, render_inline_shared_session_ended_banner, + render_inline_shared_session_started_banner, render_inline_ssh_wrapper_banner, + render_open_in_warp_banner, render_shell_process_terminated_banner, render_vim_mode_banner, + AliasExpansionBanner, AliasExpansionBannerAction, AnonymousUserAISignUpBannerState, + AnonymousUserLoginBannerAction, AwsBedrockLoginBannerAction, AwsBedrockLoginBannerState, + AwsCliNotInstalledBannerAction, AwsCliNotInstalledBannerState, ByoLlmAuthBannerSessionState, + OpenInWarpBannerState, SSHBannerAction, SSHBannerState, VimModeBannerAction, +}; pub use inline_banner::{NotificationsDiscoveryBannerAction, NotificationsErrorBannerAction}; +use instant::Instant; +use itertools::Itertools; +use lazy_static::lazy_static; +use markdown_parser::FormattedTextFragment; +use parking_lot::FairMutex; +use pathfinder_color::ColorU; +use regex::Regex; #[cfg(not(target_family = "wasm"))] use repo_metadata::repositories::DetectedRepositories; use repo_metadata::repositories::RepoDetectionSource; -use session_sharing_protocol::common::LongRunningCommandAgentInteractionState; +use serde::Serialize; +use serde_json::json; +use session_sharing_protocol::common::{ + AgentAttachment, LongRunningCommandAgentInteractionState, ParticipantId, Role, RoleRequestId, + RoleRequestResponse, ServerConversationToken as SessionSharingServerConversationToken, + WindowSize as SessionSharingWindowSize, +}; use session_sharing_protocol::sharer::{RoleUpdateReason, SessionEndedReason}; +use settings::{Setting, ToggleableSetting}; +use shared_session::cloud_conversation_continuation::CloudConversationContinuationUiState; +use shared_session::{SharedSessionAdapter, Viewer}; use ssh_file_upload::{FileUpload, FileUploadEvent}; +use sum_tree::SeekBias; +use use_agent_footer::UseAgentToolbar; use uuid::Uuid; +use vec1::vec1; use warp_core::channel::ChannelState; +use warp_core::command::ExitCode; +use warp_core::context_flag::ContextFlag; +use warp_core::semantic_selection::SemanticSelection; +use warp_core::user_preferences::GetUserPreferences as _; use warp_util::local_or_remote_path::LocalOrRemotePath; -use warpui::elements::{shimmering_text::ShimmeringTextStateHandle, Border, ChildView}; -use warpui::fonts::Properties; -use warpui::{ViewHandle, WeakModelHandle}; +#[cfg(feature = "local_fs")] +use warp_util::path::LineAndColumnArg; +use warp_util::path::ShellFamily; +use warpui::accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole}; +use warpui::assets::asset_cache::{AssetCache, AssetCacheEvent}; +use warpui::clipboard::ClipboardContent; +use warpui::clipboard_utils::get_image_filepaths_from_paths; +use warpui::elements::new_scrollable::{ + AxisConfiguration, ClippedAxisConfiguration, DualAxisConfig, NewScrollableElement, + ScrollableAppearance, SingleAxisConfig, +}; +use warpui::elements::shimmering_text::ShimmeringTextStateHandle; +use warpui::elements::{ + get_rich_content_position_id, Align, Border, ChildAnchor, ChildView, Clipped, + ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, + DispatchEventResult, DropTarget, DropTargetData, Empty, EventHandler, Expanded, Fill, Flex, + Hoverable, Icon, LiveElement, MouseStateHandle, NewScrollable, OffsetPositioning, ParentAnchor, + ParentElement, ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds, + Radius, Rect, SavePosition, ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, + Shrinkable, Stack, Text, +}; +use warpui::event::ModifiersState; +use warpui::fonts::{Cache as FontCache, FamilyId, Properties}; +use warpui::geometry::vector::{vec2f, Vector2F}; +use warpui::image_cache::ImageType; +use warpui::keymap::Keystroke; +use warpui::notification::{NotificationSendError, RequestPermissionsOutcome, UserNotification}; +use warpui::platform::{Cursor, OperatingSystem}; +use warpui::r#async::executor::Background; +use warpui::r#async::{SpawnedFutureHandle, Timer}; +use warpui::text::SelectionType; +use warpui::ui_components::components::UiComponent; +use warpui::units::{IntoLines, IntoPixels, Lines, Pixels}; +use warpui::windowing::WindowManager; +use warpui::{ + end_trace_after_next, record_trace_event, windowing, AccessibilityData, AppContext, + BlurContext, CursorInfo, Element, Entity, EntityId, EventContext, FocusContext, ModelAsRef, + ModelHandle, SingletonEntity, Tracked, TypedActionView, View, ViewAsRef, ViewContext, + ViewHandle, WeakModelHandle, WeakViewHandle, WindowId, +}; +use self::link_detection::HighlightedLinkOption; +pub use self::link_detection::{GridHighlightedLink, RichContentLink, RichContentLinkTooltipInfo}; +use super::available_shells::AvailableShell; +use super::block_list_viewport::FindMatchScrollLocation; +use super::event::SshLoginStatus; +use super::find::FindOptions; +use super::model::ansi::{SystemDetails, WarpificationUnavailableReason}; +use super::model::block::{ + BlockSection, BlocklistEnvVarMetadata, LONG_RUNNING_COMMAND_DURATION_MS, +}; +use super::model::blocks::RichContentItem; +use super::model::completions::ShellCompletion; +use super::model::rich_content::RichContentType; +use super::model::secrets::RichContentSecretTooltipInfo; +use super::model::selection::ExpandedSelectionRange; +use super::model::session::SessionBootstrappedEvent; +use super::settings::AltScreenPaddingMode; +use super::ssh::error::{SshErrorBlock, SshErrorBlockEvent, SSH_ERROR_BLOCK_VISIBLE_KEY}; +use super::ssh::install_tmux::{ + install_root_tmux_script, install_tmux_script, SshInstallTmuxBlock, SshInstallTmuxBlockEvent, + SshKeyEvent, TmuxInstallMethod, +}; +use super::ssh::root_access::RootAccess; +use super::ssh::ssh_detection::evaluate_warpify_ssh_host; +use super::ssh::util::{ + convert_script_to_one_line, parse_interactive_ssh_command, InteractiveSshCommand, + SshWarpifyCommand, +}; +use super::ssh::warpify::{ + begin_warpify_ssh_session_command, warpify_ssh_session_command, SshWarpifyBlock, + SshWarpifyBlockEvent, +}; +use super::ssh::SSH_WARPIFY_TIMEOUT_DURATION; +use super::warpify::success_block::{WarpifySuccessBlock, WarpifySuccessBlockEvent}; +use super::warpify::trigger_state::{SshBlockState, WarpifyState}; +use super::warpify::WarpificationSource; +use super::{cli_agent, CLIAgent, GridType, HistoryEvent}; +use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; - +use crate::ai::agent::redaction::redact_secrets; +use crate::ai::agent::todos::popup::{AgentTodosPopupEvent, AgentTodosPopupView}; #[cfg(any(test, feature = "integration_tests"))] use crate::ai::agent::UserQueryMode; use crate::ai::agent::{ - AIAgentActionType, AIAgentOutputStatus, AIAgentTextSection, EntrypointType, - FinishedAIAgentOutput, RenderableAIError, StaticQueryType, + AIAgentActionId, AIAgentActionType, AIAgentCitation, AIAgentContext, AIAgentExchangeId, + AIAgentInput, AIAgentOutputStatus, AIAgentPtyWriteMode, AIAgentTextSection, + AgentReviewCommentBatch, CancellationReason, EntrypointType, FileLocations, + FinishedAIAgentOutput, PassiveCodeDiffEntry, PassiveSuggestionResultType, + PassiveSuggestionTrigger, RenderableAIError, ServerOutputId, ShellCommandCompletedTrigger, + StaticQueryType, +}; +#[cfg(feature = "local_fs")] +use crate::ai::agent::{CurrentHead, DiffBase}; +use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent}; +use crate::ai::ambient_agents::{ + conversation_output_status_from_conversation, AmbientAgentTaskId, AmbientConversationStatus, }; use crate::ai::blocklist::agent_view::agent_input_footer::toolbar_item::AgentToolbarItemKind; +use crate::ai::blocklist::agent_view::{ + agent_view_bg_fill, fork_from_last_known_good_state_exchange_id, + get_agent_view_entry_block_position_id, AgentViewController, AgentViewControllerEvent, + AgentViewDisplayMode, AgentViewEntryBlockParams, AgentViewEntryOrigin, + AgentViewHeaderDisabledTheme, AgentViewHeaderTheme, AgentViewZeroStateBlock, + AgentViewZeroStateEvent, EphemeralMessageModel, ExitConfirmationTrigger, InlineAgentViewHeader, + OrchestrationPillBar, ENTER_OR_EXIT_CONFIRMATION_WINDOW, +}; +use crate::ai::blocklist::block::cli::{CLISubagentView, CLISubagentViewEvent}; +use crate::ai::blocklist::block::cli_controller::{ + CLISubagentController, CLISubagentEvent, UserTakeOverReason, +}; +use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBarEvent; +use crate::ai::blocklist::block::{AIBlockAction, FinishReason}; +use crate::ai::blocklist::codebase_index_speedbump_banner::{ + CodebaseIndexSpeedbumpBannerAction, CodebaseIndexSpeedbumpBannerState, VisibilityState, +}; +use crate::ai::blocklist::inline_action::code_diff_view::{CodeDiffView, FileDiff}; +use crate::ai::blocklist::model::{ + AIBlockModel, AIBlockModelHelper, AIBlockModelImpl, AIBlockOutputStatus, +}; use crate::ai::blocklist::suggested_agent_mode_workflow_modal::SuggestedAgentModeWorkflowAndId; use crate::ai::blocklist::suggested_rule_modal::SuggestedRuleAndId; -use crate::ai::blocklist::{model::AIBlockModelImpl, ClientIdentifiers}; -use crate::ai::{ - agent::{ - AIAgentActionId, AIAgentCitation, AIAgentContext, AIAgentExchangeId, AIAgentInput, - FileLocations, PassiveCodeDiffEntry, PassiveSuggestionResultType, - }, - blocklist::{ - ai_brand_color, get_ai_block_overflow_menu_element_position_id, - get_attached_blocks_chip_element_position_id, - inline_action::code_diff_view::{CodeDiffView, FileDiff}, - summarization_cancel_dialog::SummarizationCancelDialog, - telemetry_banner::{should_collect_ai_ugc_telemetry, TelemetryBanner}, - AIBlock, AIBlockEvent, BlocklistAIActionEvent, BlocklistAIActionModel, - BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIController, - BlocklistAIControllerEvent, BlocklistAIHistoryEvent, BlocklistAIHistoryModel, - BlocklistAIInputEvent, BlocklistAIInputModel, ConversationStatusUpdate, InputConfig, - InputType, LegacyPassiveSuggestionsEvent, LegacyPassiveSuggestionsModel, - MaaPassiveSuggestionsEvent, MaaPassiveSuggestionsModel, PassiveSuggestionsModels, - PendingQueryState, RequestFileEditsFormatKind, ShellCommandExecutor, - ShellCommandExecutorEvent, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, - ATTACH_AS_AGENT_MODE_CONTEXT_TEXT, PRE_REWIND_PREFIX, - }, - execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}, - get_relevant_files::controller::GetRelevantFilesController, +use crate::ai::blocklist::summarization_cancel_dialog::SummarizationCancelDialog; +use crate::ai::blocklist::telemetry_banner::{should_collect_ai_ugc_telemetry, TelemetryBanner}; +use crate::ai::blocklist::usage::conversation_usage_view::{ + ConversationUsageInfo, ConversationUsageView, TimingInfo, +}; +use crate::ai::blocklist::{ + ai_brand_color, block_context_from_terminal_model, + get_ai_block_overflow_menu_element_position_id, get_attached_blocks_chip_element_position_id, + AIBlock, AIBlockEvent, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent, + BlocklistAIContextModel, BlocklistAIController, BlocklistAIControllerEvent, + BlocklistAIHistoryEvent, BlocklistAIHistoryModel, BlocklistAIInputEvent, BlocklistAIInputModel, + ClientIdentifiers, ConversationStatusUpdate, InputConfig, InputType, + LegacyPassiveSuggestionsEvent, LegacyPassiveSuggestionsModel, MaaPassiveSuggestionsEvent, + MaaPassiveSuggestionsModel, PassiveSuggestionsModels, PendingQueryState, + RequestFileEditsFormatKind, ShellCommandExecutor, ShellCommandExecutorEvent, + SlashCommandRequest, StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, + ATTACH_AS_AGENT_MODE_CONTEXT_TEXT, PRE_REWIND_PREFIX, }; +use crate::ai::conversation_utils; +use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion}; +use crate::ai::execution_profiles::profiles::{AIExecutionProfilesModel, ClientProfileId}; +use crate::ai::get_relevant_files::controller::GetRelevantFilesController; +use crate::ai::llms::{LLMId, LLMModelHost, LLMPreferences}; +use crate::ai::loading::shimmering_warp_loading_text; +#[cfg(feature = "local_fs")] +use crate::ai::persisted_workspace::PersistedWorkspace; +use crate::ai::predict::prompt_suggestions::{ + has_pending_code_or_unit_test_prompt_suggestion, + is_accept_prompt_suggestion_bound_to_cmd_enter, + is_accept_prompt_suggestion_bound_to_ctrl_enter, +}; +use crate::ai_assistant::{AskAIType, ASK_AI_ASSISTANT_TEXT}; +use crate::antivirus::AntivirusInfo; +use crate::appearance::{Appearance, AppearanceEvent}; use crate::auth::auth_manager::AuthManager; use crate::auth::auth_state::AuthState; use crate::auth::auth_view_modal::AuthViewVariant; use crate::auth::{AuthStateProvider, UserUid}; use crate::autoupdate::{self, get_update_state, AutoupdateStage}; +use crate::banner::{ + Banner, BannerAction, BannerEvent, BannerState, BannerTextButton, BannerTextContent, + DismissalType, +}; use crate::cloud_object::model::actions::ObjectActionType; use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::{CloudObject, GenericStringObjectFormat, JsonObjectType}; #[cfg(feature = "local_fs")] use crate::code::editor_management::CodeSource; +use crate::code_review::comments::{ + convert_insert_review_comments, AttachedReviewComment, PendingImportedReviewComment, +}; +#[cfg(feature = "local_fs")] +use crate::code_review::context::{ + convert_file_diffs_to_diffset_hunks, create_attachment_reference_and_key, + register_diffset_attachment, +}; +#[cfg(feature = "local_fs")] +use crate::code_review::diff_state::LocalDiffStateModel; +use crate::code_review::diff_state::{DiffMode, GitDeltaPreference}; +#[cfg(feature = "local_fs")] +use crate::code_review::git_status_update::{ + GitRepoStatusModel, GitStatusMetadata, GitStatusUpdateModel, +}; +use crate::code_review::telemetry_event::CodeReviewPaneEntrypoint; +#[cfg(feature = "local_fs")] +use crate::code_review::DiffSetScope; use crate::context_chips::prompt::Prompt; use crate::context_chips::prompt_type::PromptType; use crate::context_chips::ContextChipKind; +use crate::debounce::debounce; use crate::drive::settings::WarpDriveSettings; use crate::drive::sharing::ShareableObject; use crate::drive::CloudObjectTypeAndId; -use crate::env_vars::{ - env_var_collection_block::{EnvVarCollectionBlock, EnvVarCollectionBlockEvent}, - CloudEnvVarCollection, EnvVar, +use crate::editor::{AutosuggestionType, CrdtOperation, EditorAction}; +use crate::env_vars::env_var_collection_block::{ + EnvVarCollectionBlock, EnvVarCollectionBlockEvent, +}; +use crate::env_vars::{CloudEnvVarCollection, EnvVar}; +use crate::features::FeatureFlag; +use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; +use crate::pane_group::focus_state::PaneFocusHandle; +use crate::pane_group::{ + CodeReviewPanelArg, PaneConfiguration, PaneEvent, PaneGroupAction, PaneHeaderAction, + SplitPaneState, TerminalViewResources, +}; +use crate::persistence::{self, FinishedCommandMetadata}; +use crate::projects::ProjectManagementModel; +use crate::remote_server::manager::{ + RemoteServerInitPhase, RemoteServerManager, RemoteServerManagerEvent, +}; +use crate::resource_center::{ + mark_feature_used_and_write_to_user_defaults, Tip, TipHint, TipsCompleted, }; -use crate::pane_group::focus_state::PaneFocusHandle; -use crate::persistence::{self, FinishedCommandMetadata}; +use crate::search::slash_command_menu::static_commands::commands; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::ids::{ObjectUid, SyncId}; -use crate::server::telemetry::SharingDialogSource; +use crate::server::server_api::ServerApi; +use crate::server::telemetry::{ + self, AgentModeAttachContextMethod, AgentModeEntrypoint, AgentModeRewindEntrypoint, + AnonymousUserSignupEntrypoint, BlockLatencyInfo, BootstrappingInfo, + CommandCorrectionAcceptedType, CommandCorrectionEvent, InteractionSource, LinkOpenMethod, + NotificationAgentVariant, NotificationsTurnedOnSource, PaletteSource, PromptSuggestionViewType, + SaveAsWorkflowModalSource, SecretInteraction, SharingDialogSource, SlowBootstrapInfo, + TelemetryEvent, ToggleBlockFilterSource, WorkflowTelemetryMetadata, +}; +use crate::session_management::{CommandContext, SessionNavigationPromptElements}; +use crate::settings::ai::FocusedTerminalInfo; #[cfg(feature = "local_fs")] use crate::settings::import::model::ImportedConfigModel; use crate::settings::import::view::{SettingsImportEvent, SettingsImportView}; use crate::settings::{ AISettings, AISettingsChangedEvent, AliasExpansionSettings, AppEditorSettings, - BlockVisibilitySettings, BlockVisibilitySettingsChangedEvent, DebugSettings, + BlockVisibilitySettings, BlockVisibilitySettingsChangedEvent, CodeSettings, DebugSettings, DebugSettingsChangedEvent, EmacsBindingsSettings, FontSettings, FontSettingsChangedEvent, InputModeSettings, InputModeSettingsChangedEvent, InputSettings, PaneSettings, - PaneSettingsChangedEvent, SelectionSettings, VimBannerSettings, + PaneSettingsChangedEvent, PrivacySettings, PrivacySettingsChangedEvent, + PrivacySettingsSnapshot, SelectionSettings, VimBannerSettings, }; -use crate::settings_view::flags; use crate::settings_view::keybindings::KeybindingChangedNotifier; -use crate::settings_view::SettingsSection; +use crate::settings_view::mcp_servers_page::MCPServersSettingsPage; +use crate::settings_view::{flags, SettingsSection}; use crate::shell_indicator::ShellIndicatorType; use crate::terminal::alias::{check_for_alias_async, AliasedCommand}; +use crate::terminal::alt_screen::alt_screen_element::AltScreenElement; use crate::terminal::alt_screen_reporting::{AltScreenReporting, AltScreenReportingChangedEvent}; use crate::terminal::block_filter::{ filter_button_position_id, BlockFilterEditor, BlockFilterEditorEvent, BlockFilterQuery, OpenedFromClick, }; -use crate::terminal::block_list_viewport::OverhangingBlock; -use crate::terminal::block_list_viewport::ScrollPositionUpdate; -use crate::terminal::block_list_viewport::ScrollState; +use crate::terminal::block_list_element::{ + render_hoverable_block_button, BlockListElement, BlockListMenuSource, BlockListMouseStates, + BlockSelectAction, BlockTextSelectAction, SnackbarHeaderState, ToolbeltButtonTooltip, +}; +use crate::terminal::block_list_viewport::{ + AutoscrollBehavior, InputMode, OverhangingBlock, ScrollPosition, ScrollPositionUpdate, + ScrollState, ViewportState, +}; +use crate::terminal::bootstrap::init_subshell_command; +use crate::terminal::cli_agent_sessions::event::{ + parse_event, CLIAgentEvent, CLIAgentEventPayload, CLIAgentEventType, + CLI_AGENT_NOTIFICATION_SENTINEL, +}; +use crate::terminal::cli_agent_sessions::listener::{ + agent_supports_rich_status, is_agent_supported, CLIAgentSessionListener, +}; +#[cfg(not(target_family = "wasm"))] +use crate::terminal::cli_agent_sessions::plugin_manager::{plugin_manager_for, PluginModalKind}; +use crate::terminal::cli_agent_sessions::{ + CLIAgentInputEntrypoint, CLIAgentInputState, CLIAgentRichInputCloseReason, CLIAgentSession, + CLIAgentSessionContext, CLIAgentSessionStatus, CLIAgentSessionsModel, + CLIAgentSessionsModelEvent, +}; +use crate::terminal::color::List; use crate::terminal::command_corrections_denylist::COMMAND_CORRECTIONS_PREFERRED_DENYLIST; -use crate::terminal::event::RemoteServerSetupState; +use crate::terminal::event::{ + AfterBlockCompletedEvent, BlockLatencyData, BlockType, RemoteServerSetupState, TerminalMode, + UserBlockCompleted, +}; +use crate::terminal::find::{BlockGridMatch, BlockListMatch, TerminalFindModel}; use crate::terminal::general_settings::GeneralSettings; use crate::terminal::grid_size_util::grid_cell_dimensions; use crate::terminal::input::decorations::InputBackgroundJobOptions; -use crate::terminal::input::{CommandExecutionSource, InputAction, InputEmptyStateChangeReason}; +use crate::terminal::input::inline_menu::InlineMenuPositioner; +use crate::terminal::input::{ + CommandExecutionSource, InputAction, InputEmptyStateChangeReason, InputState, MenuPositioning, + MenuPositioningProvider, +}; +use crate::terminal::keys::TerminalKeybindings; use crate::terminal::ligature_settings::{should_use_ligature_rendering, LigatureSettings}; +use crate::terminal::links::should_directly_open_link; #[cfg(feature = "local_tty")] use crate::terminal::local_tty::get_shell_starter; #[cfg(feature = "local_tty")] use crate::terminal::local_tty::shell::ShellStarter; #[cfg(all(windows, feature = "local_tty"))] use crate::terminal::local_tty::windows::get_user_and_system_env_variable; +use crate::terminal::model::ansi::{ClearMode, Handler}; +use crate::terminal::model::block::{ + AgentInteractionMetadata, Block, BlockId, BlockMetadata, LONG_RUNNING_BOTTOM_PADDING_LINES, +}; use crate::terminal::model::blockgrid::BlockGrid; +use crate::terminal::model::blocks::{ + BlockFilter, BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, BlockListPoint, Gap, + RemovableBlocklistItem, +}; +use crate::terminal::model::escape_sequences::{self, EscCodes, ToEscapeSequence, C1}; +use crate::terminal::model::grid::grid_handler::{FragmentBoundary, TermMode}; +use crate::terminal::model::index::{Point, Side}; +use crate::terminal::model::mouse::MouseState; +use crate::terminal::model::selection::{SelectAction, SelectionDirection}; use crate::terminal::model::session::active_session::ActiveSession; -use crate::terminal::model::session::{Session, SessionId}; +use crate::terminal::model::session::{ + BootstrapSessionType, Session, SessionId, SessionType, Sessions, SessionsEvent, +}; +use crate::terminal::model::terminal_model::{ + BlockIndex, BlockSelectionCardinality, SelectedBlocks, TerminalInputState, WithinModel, +}; use crate::terminal::model::{ObfuscateSecrets, RespectObfuscatedSecrets, SecretHandle}; +use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher}; use crate::terminal::recorder::PtyRecorder; use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode; -use crate::terminal::session_settings::ToolbarChipSelection; -use crate::terminal::session_settings::{ - NotificationsMode, NotificationsSettings, SessionSettings, -}; use crate::terminal::session_settings::{ - SessionSettingsChangedEvent, DEFAULT_THRESHOLD_FOR_LONG_RUNNING_NOTIFICATION, + NotificationsMode, NotificationsSettings, SessionSettings, SessionSettingsChangedEvent, + ToolbarChipSelection, DEFAULT_THRESHOLD_FOR_LONG_RUNNING_NOTIFICATION, }; use crate::terminal::settings::{TerminalSettings, TerminalSettingsChangedEvent}; use crate::terminal::shared_session::role_change_modal::{ @@ -319,197 +465,34 @@ use crate::terminal::shared_session::{ }; use crate::terminal::ssh::ssh_detection::SshInteractiveSessionDetected; use crate::terminal::view::block_onboarding::onboarding_prompt_block::OnboardingPromptBlock; -use crate::terminal::warpify::{ - render::render_subshell_separator, settings::WarpifySettings, SubshellSource, -}; -use crate::terminal::ShellLaunchData; -use crate::terminal::{element_size_at_last_frame, HistoryEntry}; -use crate::terminal::{height_in_range_approx, heights_approx_gt, SizeUpdate}; -use crate::terminal::{heights_approx_eq, CellSizeAndWindowPadding}; -use crate::terminal::{AudibleBell, SizeUpdateReason}; -use crate::terminal::{BlockListSettings, BlockListSettingsChangedEvent}; -use crate::themes::theme::WarpTheme; -use crate::ui_components::icons::{self}; -use crate::util::bindings::{ - custom_tag_to_keystroke, keybinding_name_to_display_string, keybinding_name_to_keystroke, - set_custom_keybinding, CustomAction, -}; -use crate::util::clipboard::clipboard_content_with_escaped_paths; -#[cfg(feature = "local_fs")] -use crate::util::openable_file_type::{is_markdown_file, resolve_file_target, FileTarget}; -use crate::view_components::{DismissibleToast, ToastFlavor}; -use crate::workflows::workflow::Workflow; -use crate::workflows::WorkflowSelectionSource; -use crate::workspace::sync_inputs::SyncedInputState; -use crate::workspace::{CommandSearchOptions, OneTimeModalModel, ToastStack, WorkspaceAction}; -use crate::workspace::{ForkAIConversationParams, ForkFromExchange, ForkedConversationDestination}; -use crate::workspaces::{user_workspaces::UserWorkspaces, workspace::CustomerType}; -use crate::AIRequestUsageModel; -use crate::ActiveSession as WindowActiveSession; -use crate::{report_if_error, AIAgentActionResultType}; -use crate::{safe_error, safe_warn}; - -use async_channel::{Receiver, Sender}; -use chrono::{DateTime, Local, NaiveDateTime}; -use command_corrections::rules::{Rule, RuleId as CommandCorrectionsRuleId}; -use command_corrections::{correct_command, Command, Correction, HistoryItem, SessionMetadata}; -use enclose::enclose; -use instant::Instant; -use itertools::Itertools; -use lazy_static::lazy_static; -use markdown_parser::FormattedTextFragment; -use parking_lot::FairMutex; -use pathfinder_color::ColorU; -use regex::Regex; -use serde::Serialize; -use serde_json::json; -use session_sharing_protocol::common::{ - AgentAttachment, ParticipantId, Role, RoleRequestId, RoleRequestResponse, - ServerConversationToken as SessionSharingServerConversationToken, - WindowSize as SessionSharingWindowSize, -}; -use shared_session::{ - cloud_conversation_continuation::CloudConversationContinuationUiState, SharedSessionAdapter, - Viewer, -}; -use std::any::Any; -use std::borrow::Cow; -use std::cell::RefCell; -use std::collections::hash_map::Entry; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::fmt; -use std::hash::Hash; -use std::ops::Range; -use std::path::{Path, PathBuf}; -use std::rc::Rc; -use std::str::FromStr; -use std::sync::mpsc::SyncSender; -use std::sync::Arc; -use std::thread::JoinHandle; -use std::time::Duration; -use sum_tree::SeekBias; -use vec1::vec1; -use warp_core::context_flag::ContextFlag; -use warp_core::user_preferences::GetUserPreferences as _; -#[cfg(feature = "local_fs")] -use warp_util::path::LineAndColumnArg; -use warp_util::path::ShellFamily; -use warpui::clipboard::ClipboardContent; -use warpui::elements::new_scrollable::{ - AxisConfiguration, ClippedAxisConfiguration, DualAxisConfig, NewScrollableElement, - ScrollableAppearance, SingleAxisConfig, -}; -use warpui::elements::{ - get_rich_content_position_id, ChildAnchor, ClippedScrollStateHandle, Container, - CrossAxisAlignment, DispatchEventResult, DropTarget, DropTargetData, Empty, EventHandler, - Expanded, Flex, LiveElement, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement, - ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, - ScrollableElement, ScrollbarWidth, Shrinkable, Text, -}; -use warpui::event::ModifiersState; -use warpui::keymap::Keystroke; -use warpui::notification::{NotificationSendError, RequestPermissionsOutcome, UserNotification}; -use warpui::platform::{Cursor, OperatingSystem}; -use warpui::r#async::executor::Background; -use warpui::r#async::{SpawnedFutureHandle, Timer}; -use warpui::windowing::WindowManager; - -use warpui::assets::asset_cache::{AssetCache, AssetCacheEvent}; -use warpui::image_cache::ImageType; -use warpui::units::{IntoLines, IntoPixels, Lines, Pixels}; -use warpui::{ - accessibility::{AccessibilityContent, ActionAccessibilityContent, WarpA11yRole}, - elements::SavePosition, - elements::{ - Align, Clipped, ConstrainedBox, CornerRadius, Fill, Hoverable, Icon, MouseStateHandle, - Rect, ScrollStateHandle, Scrollable, - }, - fonts::{Cache as FontCache, FamilyId}, - ui_components::components::UiComponent, - AccessibilityData, AppContext, BlurContext, Element, Entity, FocusContext, ModelHandle, - TypedActionView, View, ViewAsRef, ViewContext, WeakViewHandle, -}; -use warpui::{ - elements::Stack, - end_trace_after_next, - geometry::vector::{vec2f, Vector2F}, - record_trace_event, WindowId, -}; - -use warpui::{windowing, CursorInfo, EntityId, EventContext, ModelAsRef, SingletonEntity, Tracked}; - -use crate::ai_assistant::{AskAIType, ASK_AI_ASSISTANT_TEXT}; -use crate::appearance::{Appearance, AppearanceEvent}; -use crate::banner::{ - Banner, BannerAction, BannerEvent, BannerState, BannerTextButton, BannerTextContent, - DismissalType, -}; -use crate::debounce::debounce; -use crate::editor::{AutosuggestionType, CrdtOperation, EditorAction}; -use crate::features::FeatureFlag; -use crate::pane_group::SplitPaneState; -use crate::pane_group::{ - CodeReviewPanelArg, PaneConfiguration, PaneEvent, PaneGroupAction, PaneHeaderAction, - TerminalViewResources, -}; -use crate::resource_center::{ - mark_feature_used_and_write_to_user_defaults, Tip, TipHint, TipsCompleted, -}; -use crate::server::telemetry::{ - self, AgentModeAttachContextMethod, AgentModeEntrypoint, AgentModeRewindEntrypoint, - AnonymousUserSignupEntrypoint, InteractionSource, LinkOpenMethod, NotificationAgentVariant, - PaletteSource, PromptSuggestionViewType, SecretInteraction, SlowBootstrapInfo, - ToggleBlockFilterSource, WorkflowTelemetryMetadata, -}; -use crate::server::{ - server_api::ServerApi, - telemetry::{ - CommandCorrectionAcceptedType, CommandCorrectionEvent, NotificationsTurnedOnSource, - SaveAsWorkflowModalSource, TelemetryEvent, - }, -}; -use crate::session_management::{CommandContext, SessionNavigationPromptElements}; -use crate::settings::{PrivacySettings, PrivacySettingsChangedEvent, PrivacySettingsSnapshot}; -use crate::terminal::alt_screen::alt_screen_element::AltScreenElement; -use crate::terminal::block_list_element::{ - render_hoverable_block_button, BlockListElement, BlockListMouseStates, BlockSelectAction, - BlockTextSelectAction, SnackbarHeaderState, ToolbeltButtonTooltip, -}; -use crate::terminal::block_list_viewport::AutoscrollBehavior; -use crate::terminal::block_list_viewport::{InputMode, ScrollPosition, ViewportState}; -use crate::terminal::bootstrap::init_subshell_command; -use crate::terminal::event::TerminalMode; -use crate::terminal::event::UserBlockCompleted; -use crate::terminal::find::{BlockGridMatch, BlockListMatch, TerminalFindModel}; -use crate::terminal::input::{InputState, MenuPositioning, MenuPositioningProvider}; -use crate::terminal::keys::TerminalKeybindings; -use crate::terminal::model::block::{AgentInteractionMetadata, BlockMetadata}; -use crate::terminal::model::block::{Block, BlockId}; -use crate::terminal::model::blocks::{BlockFilter, BlockList}; -use crate::terminal::model::blocks::{BlockHeight, BlockHeightItem, BlockHeightSummary, Gap}; -use crate::terminal::model::escape_sequences::{self, EscCodes, ToEscapeSequence, C1}; -use crate::terminal::model::grid::grid_handler::{FragmentBoundary, TermMode}; -use crate::terminal::model::index::{Point, Side}; -use crate::terminal::model::mouse::MouseState; -use crate::terminal::model::selection::{SelectAction, SelectionDirection}; -use crate::terminal::model::session::{BootstrapSessionType, SessionType, Sessions, SessionsEvent}; -use crate::terminal::model::terminal_model::{BlockIndex, TerminalInputState}; -use crate::terminal::model::terminal_model::{ - BlockSelectionCardinality, SelectedBlocks, WithinModel, -}; -use crate::terminal::model::{ - ansi::{ClearMode, Handler}, - blocks::BlockListPoint, +use crate::terminal::view::init_environment::mode_selector::{ + EnvironmentSetupMode, EnvironmentSetupModeSelector, EnvironmentSetupModeSelectorEvent, }; +use crate::terminal::view::init_environment::{InitEnvironmentBlock, InitEnvironmentBlockEvent}; use crate::terminal::view::inline_banner::{ render_agent_mode_setup_banner, AgentModeSetupSpeedbumpBannerAction, AgentModeSetupSpeedbumpBannerState, AliasExpansionBannerState, NotificationsDiscoveryBannerState, NotificationsErrorBannerState, PromptSuggestionBannerState, VimModeBannerState, }; +use crate::terminal::view::passive_suggestions::PromptSuggestionResolution; +pub use crate::terminal::view::rich_content::{ + AIBlockMetadata, AgentViewEntryMetadata, RichContent, RichContentInsertionPosition, + RichContentMetadata, +}; use crate::terminal::view::ssh_file_upload::FileUploadId; +use crate::terminal::view::ssh_remote_server_choice_view::{ + SshRemoteServerChoiceView, SshRemoteServerChoiceViewEvent, +}; +use crate::terminal::view::ssh_remote_server_failed_banner::{ + SshRemoteServerFailedBanner, SshRemoteServerFailedBannerEvent, +}; +use crate::terminal::view::telemetry::PromptSuggestionFallbackReason; +use crate::terminal::view::zero_state_block::TerminalViewZeroStateBlock; +use crate::terminal::warpify::render::render_subshell_separator; +use crate::terminal::warpify::settings::WarpifySettings; +use crate::terminal::warpify::SubshellSource; use crate::terminal::waterfall_gap_element::WaterfallGapElement; -use crate::terminal::ShellHost; use crate::terminal::{ block_list_element::BlockHoverAction, // find::{Event as FindEvent, Find, FindDirection}, @@ -519,77 +502,45 @@ use crate::terminal::{ terminal_size_element::TerminalSizeElement, TerminalModel, }; -use crate::view_components::find::{Event as FindEvent, Find, FindDirection, FindWithinBlockState}; -use settings::{Setting, ToggleableSetting}; -use warp_core::semantic_selection::SemanticSelection; -use warpui::text::SelectionType; - -use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; -use crate::server::telemetry::{BlockLatencyInfo, BootstrappingInfo}; -use crate::terminal::{block_list_element::BlockListMenuSource, prompt}; -use crate::terminal::{color, History, SizeInfo}; -use crate::terminal::{color::List, model::block::LONG_RUNNING_BOTTOM_PADDING_LINES}; -use crate::terminal::{event::AfterBlockCompletedEvent, event::BlockLatencyData, event::BlockType}; -use crate::throttle::throttle; -use crate::util::color::darken; -use crate::{send_telemetry_from_ctx, send_telemetry_on_executor, send_telemetry_sync_from_ctx}; - -use self::link_detection::HighlightedLinkOption; -use super::available_shells::AvailableShell; -use super::block_list_viewport::FindMatchScrollLocation; -use super::event::SshLoginStatus; -use super::find::FindOptions; -use super::model::ansi::{SystemDetails, WarpificationUnavailableReason}; -use super::model::block::{ - BlockSection, BlocklistEnvVarMetadata, LONG_RUNNING_COMMAND_DURATION_MS, -}; -use super::model::blocks::RichContentItem; -use super::model::completions::ShellCompletion; -use super::model::rich_content::RichContentType; -use super::model::secrets::RichContentSecretTooltipInfo; -use super::model::selection::ExpandedSelectionRange; -use super::model::session::SessionBootstrappedEvent; -use super::settings::AltScreenPaddingMode; -use super::ssh::error::{SshErrorBlock, SshErrorBlockEvent, SSH_ERROR_BLOCK_VISIBLE_KEY}; -use super::ssh::install_tmux::{ - install_root_tmux_script, install_tmux_script, SshInstallTmuxBlock, SshInstallTmuxBlockEvent, - SshKeyEvent, TmuxInstallMethod, +use crate::terminal::{ + color, element_size_at_last_frame, height_in_range_approx, heights_approx_eq, + heights_approx_gt, prompt, AudibleBell, BlockListSettings, BlockListSettingsChangedEvent, + CellSizeAndWindowPadding, History, HistoryEntry, ShellHost, ShellLaunchData, SizeInfo, + SizeUpdate, SizeUpdateReason, }; -use super::ssh::root_access::RootAccess; -use super::ssh::ssh_detection::evaluate_warpify_ssh_host; -use super::ssh::util::{ - convert_script_to_one_line, parse_interactive_ssh_command, InteractiveSshCommand, - SshWarpifyCommand, +use crate::themes::theme::WarpTheme; +use crate::throttle::throttle; +use crate::ui_components::icons::{self}; +use crate::util::bindings::{ + custom_tag_to_keystroke, keybinding_name_to_display_string, keybinding_name_to_keystroke, + set_custom_keybinding, CustomAction, }; -use super::ssh::warpify::{ - begin_warpify_ssh_session_command, warpify_ssh_session_command, SshWarpifyBlock, - SshWarpifyBlockEvent, +use crate::util::clipboard::clipboard_content_with_escaped_paths; +use crate::util::color::darken; +#[cfg(feature = "local_fs")] +use crate::util::file::external_editor::{settings::EditorLayout, EditorSettings}; +#[cfg(feature = "local_fs")] +use crate::util::openable_file_type::{is_markdown_file, resolve_file_target, FileTarget}; +use crate::util::repo_detection::{detect_possible_git_repo, RepoDetectionSessionType}; +use crate::util::truncation::truncate_from_end; +use crate::view_components::action_button::{ActionButton, ButtonSize, KeystrokeSource}; +use crate::view_components::find::{Event as FindEvent, Find, FindDirection, FindWithinBlockState}; +use crate::view_components::{DismissibleToast, ToastFlavor}; +use crate::workflows::workflow::Workflow; +use crate::workflows::WorkflowSelectionSource; +use crate::workspace::sync_inputs::SyncedInputState; +use crate::workspace::view::cloud_agent_capacity_modal::CloudAgentCapacityModalVariant; +use crate::workspace::{ + CommandSearchOptions, ForkAIConversationParams, ForkFromExchange, + ForkedConversationDestination, OneTimeModalModel, ToastStack, WorkspaceAction, }; -use super::ssh::SSH_WARPIFY_TIMEOUT_DURATION; -use super::warpify::success_block::{WarpifySuccessBlock, WarpifySuccessBlockEvent}; -use super::warpify::trigger_state::{SshBlockState, WarpifyState}; -use super::warpify::WarpificationSource; -use super::{GridType, HistoryEvent}; -use crate::antivirus::AntivirusInfo; -use crate::terminal::links::should_directly_open_link; -use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher}; -use action::RememberForWarpification; -use block_banner::{render_warpification_banner, WarpificationMode, WarpifyBannerState}; -use bookmarks::render_floating_block_snapshot; -use command_corrections::rules::generic::history::History as CommandCorrectionsHistoryRule; -use init::{INPUT_BOX_VISIBLE_KEY, TOGGLE_BLOCK_FILTER_KEYBINDING}; -use inline_banner::{ - render_alias_expansion_banner, render_aws_bedrock_login_banner, - render_aws_cli_not_installed_banner, render_inline_notifications_discovery_banner, - render_inline_notifications_error_banner, render_inline_shared_session_ended_banner, - render_inline_shared_session_started_banner, render_inline_ssh_wrapper_banner, - render_open_in_warp_banner, render_shell_process_terminated_banner, render_vim_mode_banner, - AliasExpansionBanner, AliasExpansionBannerAction, AnonymousUserAISignUpBannerState, - AnonymousUserLoginBannerAction, AwsBedrockLoginBannerAction, AwsBedrockLoginBannerState, - AwsCliNotInstalledBannerAction, AwsCliNotInstalledBannerState, ByoLlmAuthBannerSessionState, - OpenInWarpBannerState, SSHBannerAction, SSHBannerState, VimModeBannerAction, +use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; +use crate::workspaces::workspace::CustomerType; +use crate::{ + report_if_error, safe_error, safe_warn, send_telemetry_from_ctx, send_telemetry_on_executor, + send_telemetry_sync_from_ctx, AIAgentActionResultType, AIRequestUsageModel, + ActiveSession as WindowActiveSession, }; -use warp_core::command::ExitCode; lazy_static! { // A set of commands that perform minimal work that we use as a baseline to measure the latency of blocks. @@ -21836,13 +21787,11 @@ impl TerminalView { ) -> ViewHandle { use rand::distributions::{Alphanumeric, DistString}; - use crate::ai::{ - agent::{ - AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentText, AIAgentTextSection, - MessageId, ServerOutputId, - }, - blocklist::FakeAIBlockModel, + use crate::ai::agent::{ + AIAgentInput, AIAgentOutput, AIAgentOutputMessage, AIAgentText, AIAgentTextSection, + MessageId, ServerOutputId, }; + use crate::ai::blocklist::FakeAIBlockModel; let inputs = vec![AIAgentInput::UserQuery { query, diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 84a5f1f20b..6ce6e3bc40 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -1,4 +1,29 @@ +use std::collections::HashMap; + +use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; +use ai::project_context::model::ProjectContextModel; +use pane_group::{NotebookPane, PaneState, SplitPaneState, TerminalPaneId}; +use repo_metadata::repositories::DetectedRepositories; +use repo_metadata::watcher::DirectoryWatcher; +#[cfg(feature = "local_fs")] +use repo_metadata::CanonicalizedPath; +#[cfg(feature = "local_fs")] +use repo_metadata::RepoMetadataModel; +use session_sharing_protocol::common::SessionId; +#[cfg(feature = "local_fs")] +use tempfile::TempDir; +use terminal::shared_session::permissions_manager::SessionPermissionsManager; +use terminal::view::ActiveSessionState; +use warp_editor::editor::NavigationKey; +use warpui::platform::WindowStyle; +use warpui::{AddSingletonModel, App, ViewHandle}; +use watcher::HomeDirectoryWatcher; + use super::*; +use crate::ai::active_agent_views_model::ActiveAgentViewsModel; +use crate::ai::agent_conversations_model::AgentConversationsModel; +use crate::ai::agent_tips::AITipModel; +use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; use crate::ai::blocklist::agent_view::orchestration_pill_bar_model::OrchestrationPillBarModel; use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions}; use crate::ai::document::ai_document_model::AIDocumentModel; @@ -6,6 +31,9 @@ use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; use crate::ai::facts::manager::AIFactManager; use crate::ai::harness_availability::HarnessAvailabilityModel; use crate::ai::llms::LLMPreferences; +use crate::ai::mcp::gallery::MCPGalleryManager; +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; @@ -23,72 +51,42 @@ use crate::pane_group::{Direction, PaneGroupAction, PaneId}; use crate::pricing::PricingInfoModel; #[cfg(not(target_family = "wasm"))] use crate::remote_server::codebase_index_model::RemoteCodebaseIndexModel; -use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; -#[cfg(feature = "local_fs")] -use crate::user_config::tab_configs_dir; -use repo_metadata::repositories::DetectedRepositories; -use repo_metadata::watcher::DirectoryWatcher; -#[cfg(feature = "local_fs")] -use repo_metadata::CanonicalizedPath; -#[cfg(feature = "local_fs")] -use repo_metadata::RepoMetadataModel; -use std::collections::HashMap; -#[cfg(feature = "local_fs")] -use tempfile::TempDir; -use watcher::HomeDirectoryWatcher; - -use crate::server::cloud_objects::{listener::Listener, update_manager::UpdateManager}; +use crate::resource_center::Tip; +use crate::server::cloud_objects::listener::Listener; +use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::experiments::ServerExperiments; use crate::server::server_api::ServerApiProvider; use crate::server::sync_queue::SyncQueue; - use crate::server::telemetry::context_provider::AppTelemetryContextProvider; +use crate::settings::cloud_preferences_syncer::CloudPreferencesSyncer; use crate::settings::PrivacySettings; use crate::settings_view::keybindings::KeybindingChangedNotifier; use crate::settings_view::DisplayCount; +use crate::suggestions::ignored_suggestions_model::IgnoredSuggestionsModel; use crate::system::SystemStats; use crate::tab_configs::tab_config::{TabConfigPaneNode, TabConfigPaneType}; +use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::terminal::history::History; use crate::terminal::keys::TerminalKeybindings; -#[cfg(windows)] -use crate::util::traffic_lights::windows::RendererState; -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::terminal::local_tty::spawner::PtySpawner; use crate::terminal::shared_session::{ SharedSessionScrollbackType, SharedSessionSource, SharedSessionStatus, }; - -use crate::ai::active_agent_views_model::ActiveAgentViewsModel; -use crate::ai::agent_conversations_model::AgentConversationsModel; -use crate::ai::agent_tips::AITipModel; -use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; -use crate::ai::mcp::{ - gallery::MCPGalleryManager, templatable_manager::TemplatableMCPServerManager, - FileBasedMCPManager, FileMCPWatcher, -}; -use crate::resource_center::Tip; -use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel; use crate::test_util::settings::initialize_settings_for_tests; use crate::undo_close::UndoCloseSettings; +#[cfg(feature = "local_fs")] +use crate::user_config::tab_configs_dir; +#[cfg(windows)] +use crate::util::traffic_lights::windows::RendererState; use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher; use crate::workflows::local_workflows::LocalWorkflows; -use crate::{experiments, workspace, GlobalResourceHandlesProvider}; -use crate::{AgentNotificationsModel, ObjectActions}; - -use crate::settings::cloud_preferences_syncer::CloudPreferencesSyncer; -use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; -use ai::project_context::model::ProjectContextModel; -use pane_group::{NotebookPane, PaneState, SplitPaneState, TerminalPaneId}; -use session_sharing_protocol::common::SessionId; -use terminal::shared_session::permissions_manager::SessionPermissionsManager; -use terminal::view::ActiveSessionState; -use warp_editor::editor::NavigationKey; -use warpui::AddSingletonModel; -use warpui::{platform::WindowStyle, App, ViewHandle}; +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, workspace, AgentNotificationsModel, GlobalResourceHandlesProvider, ObjectActions, +}; fn initialize_app(app: &mut App) { initialize_settings_for_tests(app);