diff --git a/app/src/pane_group/ambient_pane_restoration.rs b/app/src/pane_group/ambient_pane_restoration.rs new file mode 100644 index 0000000000..a5943f5c22 --- /dev/null +++ b/app/src/pane_group/ambient_pane_restoration.rs @@ -0,0 +1,196 @@ +use session_sharing_protocol::common::SessionId; +use uuid::Uuid; +use warpui::{SingletonEntity, ViewContext, ViewHandle}; + +use crate::ai::agent::api::ServerConversationToken; +use crate::ai::agent_conversations_model::{ + AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel, +}; +use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::blocklist::BlocklistAIHistoryModel; +use crate::pane_group::{PaneGroup, PaneId, TerminalPane, TerminalViewResources}; +use crate::terminal::TerminalView; +use crate::workspace::WorkspaceAction; + +/// The restoration path for an ambient agent pane. +pub(in crate::pane_group) enum AmbientRestoreKind { + /// Active shared session + SharedSession { session_id: SessionId }, + /// Conversation data isn't loaded yet — show a loading pane and + /// defer the real restoration to the pending-restoration subscription + /// (which waits for the data to be loaded async). + PendingRestoration { task_id: AmbientAgentTaskId }, + /// If there's no task ID to restore, we open a fresh cloud mode pane + /// (this is a valid state from when a user quits with an empty cloud mode pane). + NewCloudConversation, +} + +impl PaneGroup { + /// Stores the pending ambient agent restorations, triggers async fetches for + /// their task data, and sets up a single long-lived subscription that will + /// process each pane as its task data arrives. + pub(in crate::pane_group) fn register_pending_ambient_restorations( + &mut self, + pending: Vec<(AmbientAgentTaskId, PaneId)>, + ctx: &mut ViewContext, + ) { + for (task_id, _) in &pending { + AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.get_or_async_fetch_task_data(task_id, ctx); + }); + } + + self.pending_ambient_agent_conversation_restorations = pending.into_iter().collect(); + + self.ensure_pending_ambient_restoration_subscription(ctx); + } + + /// Drains entries from `pending_ambient_agent_conversation_restorations` + /// for which task data is now available, replacing or hydrating the + /// corresponding panes. + pub(in crate::pane_group) fn process_pending_ambient_restorations( + &mut self, + ctx: &mut ViewContext, + ) { + if self + .pending_ambient_agent_conversation_restorations + .is_empty() + { + return; + } + + let ready_tasks: Vec<_> = self + .pending_ambient_agent_conversation_restorations + .keys() + .filter(|task_id| { + AgentConversationsModel::as_ref(ctx) + .get_task_data(task_id) + .is_some() + }) + .copied() + .collect(); + + let resources = TerminalViewResources { + tips_completed: self.tips_completed.clone(), + server_api: self.server_api.clone(), + model_event_sender: self.model_event_sender.clone(), + }; + let view_size = Self::estimated_view_bounds(ctx).size(); + + for task_id in ready_tasks { + let Some(pane_id) = self + .pending_ambient_agent_conversation_restorations + .remove(&task_id) + else { + continue; + }; + let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else { + continue; + }; + + match AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( + task.task_id, + )), + None, + ctx, + ) { + Some(WorkspaceAction::OpenOrAttachAmbientAgentConversation { + session_id, + task_id: _, + }) => { + let (view, terminal_manager) = Self::create_shared_session_viewer( + session_id, + resources.clone(), + view_size, + true, // enable_orchestration_polling + true, // is_cloud_mode + ctx, + ); + let new_pane = TerminalPane::new( + Uuid::new_v4().as_bytes().to_vec(), + terminal_manager, + view, + self.model_event_sender.clone(), + ctx, + ); + self.replace_pane(pane_id, new_pane, false, ctx); + } + Some(WorkspaceAction::OpenConversationTranscriptViewer { + conversation_id, + ambient_agent_task_id, + }) => { + if let Some(target_view) = self.terminal_view_from_pane_id(pane_id, ctx) { + Self::fetch_and_load_transcript( + target_view, + conversation_id, + ambient_agent_task_id, + ctx, + ); + } else { + self.pending_ambient_agent_conversation_restorations + .insert(task_id, pane_id); + } + } + _ => { + self.replace_pane_with_new_cloud_conversation(pane_id, ctx); + } + } + } + } + + /// Replaces a pane with a new cloud conversation. + fn replace_pane_with_new_cloud_conversation( + &mut self, + pane_id: PaneId, + ctx: &mut ViewContext, + ) { + let resources = TerminalViewResources { + tips_completed: self.tips_completed.clone(), + server_api: self.server_api.clone(), + model_event_sender: self.model_event_sender.clone(), + }; + let view_size = Self::estimated_view_bounds(ctx).size(); + let (view, terminal_manager) = + Self::create_ambient_agent_terminal(resources, view_size, ctx); + let new_pane = TerminalPane::new( + Uuid::new_v4().as_bytes().to_vec(), + terminal_manager, + view, + self.model_event_sender.clone(), + ctx, + ); + self.replace_pane(pane_id, new_pane, false, ctx); + } + + /// Fetches conversation data and loads it into the given transcript viewer. + fn fetch_and_load_transcript( + target_view: ViewHandle, + server_conversation_token: ServerConversationToken, + ambient_agent_task_id: Option, + ctx: &mut ViewContext, + ) { + let history_model_handle = BlocklistAIHistoryModel::handle(ctx); + + let future = history_model_handle.update(ctx, |history_model, ctx| { + history_model.load_conversation_by_server_token(&server_conversation_token, ctx) + }); + ctx.spawn(future, move |group, conversation, ctx| { + if let Some(conversation) = conversation { + group.load_data_into_transcript_viewer( + target_view, + conversation, + ambient_agent_task_id, + ctx, + ); + } else if let Some(pane_id) = + group.find_pane_id_for_terminal_view(target_view.id(), ctx) + { + log::error!( + "Failed to restore ambient agent pane, replacing with new cloud conversation" + ); + group.replace_pane_with_new_cloud_conversation(pane_id, ctx); + } + }); + } +} diff --git a/app/src/pane_group/child_agent/hydration.rs b/app/src/pane_group/child_agent/hydration.rs new file mode 100644 index 0000000000..7f2af85b43 --- /dev/null +++ b/app/src/pane_group/child_agent/hydration.rs @@ -0,0 +1,394 @@ +use warpui::{SingletonEntity, ViewContext}; + +use crate::ai::agent::api::ServerConversationToken; +use crate::ai::agent::conversation::{AIConversation, AIConversationId}; +use crate::ai::agent_conversations_model::AgentConversationsModel; +use crate::ai::ambient_agents::{ + AmbientAgentLiveSessionState, AmbientAgentTask, AmbientAgentTaskId, +}; +use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; +use crate::ai::blocklist::history_model::CloudConversationData; +use crate::ai::blocklist::BlocklistAIHistoryModel; +use crate::pane_group::{AmbientAgentViewModelHandleExt, PaneGroup, PaneId}; +use crate::terminal::view::load_ai_conversation::{ + RestoreConversationEntryBehavior, RestoredAIConversation, +}; + +/// How to hydrate a restored hidden remote-child pane given its +/// [`AmbientAgentTask`]. See [`decide_remote_child_hydration_action`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::pane_group) enum RemoteChildHydrationAction { + /// Attachable live session — join it in place. + LiveAttach, + /// No live session but a server conversation token is available; + /// `task_is_terminal` controls whether the post-merge step inserts a + /// conversation-ended tombstone (only terminal runs do). + LoadTranscript { + server_token: ServerConversationToken, + task_is_terminal: bool, + }, + /// Neither live nor cloud transcript available; fall through to + /// `attach_ambient_session_and_maybe_tombstone`. `task_is_terminal` + /// gates the tombstone so an `ActiveUnattachable` run with no server + /// token isn't visually marked as ended. + Fallback { task_is_terminal: bool }, +} + +/// Pure decision function backing [`PaneGroup::attempt_remote_child_hydration`]. +/// Free-standing so it's unit-testable without a `PaneGroup`. +pub(in crate::pane_group) fn decide_remote_child_hydration_action( + task: &AmbientAgentTask, +) -> RemoteChildHydrationAction { + let live_session_state = task.active_live_session_state(); + if matches!( + live_session_state, + AmbientAgentLiveSessionState::Attachable { .. } + ) { + return RemoteChildHydrationAction::LiveAttach; + } + + let task_is_terminal = matches!(live_session_state, AmbientAgentLiveSessionState::Inactive); + + // Empty/whitespace tokens would drive a no-op cloud fetch followed by + // a misleading tombstone; route them to `Fallback` instead. + let server_token = task + .conversation_id() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(|t| ServerConversationToken::new(t.to_string())); + + match server_token { + Some(server_token) => RemoteChildHydrationAction::LoadTranscript { + server_token, + task_is_terminal, + }, + None => RemoteChildHydrationAction::Fallback { task_is_terminal }, + } +} + +impl PaneGroup { + /// Task-backed restore path for the `is_remote_child` branch of + /// `create_hidden_child_agent_pane`. Always creates the hidden ambient + /// pane, registers it in `child_agent_panes` keyed by the placeholder's + /// local `AIConversationId`, then dispatches via + /// `attempt_remote_child_hydration` (or queues a pending entry while + /// task data is fetched). + /// + /// Idempotent: skipped when the placeholder already has a live tracked + /// pane, so repeat calls from `restore_missing_child_agent_panes_for_parent` + /// — including while the initial async hydration is still in flight — + /// don't create a duplicate hidden pane and orphan the first one. + pub(super) fn hydrate_task_backed_hidden_child_pane( + &mut self, + child_conversation: AIConversation, + parent_pane_id: PaneId, + task_id: AmbientAgentTaskId, + ctx: &mut ViewContext, + ) { + let child_id = child_conversation.id(); + + // Idempotency guard — see fn doc. + if let Some(existing_pane_id) = self.child_agent_panes.get(&child_id).copied() { + if self.has_pane_id(existing_pane_id) { + return; + } + } + + let new_pane_id = + self.insert_ambient_agent_pane_hidden_for_child_agent(parent_pane_id, ctx); + + let Some(new_terminal_view) = self.terminal_view_from_pane_id(new_pane_id, ctx) else { + log::error!("Failed to get terminal view for remote child agent pane {child_id:?}"); + self.discard_pane(new_pane_id.into(), ctx); + return; + }; + + // Restore the placeholder so the pane has parent linkage + agent + // name before task-backed hydration runs. + let mut restored = false; + new_terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view.restore_conversation_after_view_creation( + RestoredAIConversation::new(child_conversation), + true, + RestoreConversationEntryBehavior::PreserveAgentViewState, + ctx, + ); + terminal_view.enter_agent_view( + None, + Some(child_id), + AgentViewEntryOrigin::CloudAgent, + ctx, + ); + restored = terminal_view + .ambient_agent_view_model() + .into_optional_handle() + .is_some(); + }); + + if !restored { + log::error!( + "Failed to restore remote child agent pane {child_id:?}: missing ambient agent view model" + ); + self.discard_pane(new_pane_id.into(), ctx); + return; + } + + // Placeholder's local id stays the canonical `child_agent_panes` + // key across live-attach and transcript hydration. + self.child_agent_panes.insert(child_id, new_pane_id.into()); + + let task_now = AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.get_or_async_fetch_task_data(&task_id, ctx) + }); + + if task_now.is_none() { + // Task data not yet cached: queue a pending hydration and + // attempt a live-attach in the meantime so streaming runs are + // not stalled while waiting on the fetch. + self.pending_remote_child_hydrations + .insert(task_id, child_id); + self.ensure_pending_ambient_restoration_subscription(ctx); + self.apply_existing_ambient_task_to_pane(new_pane_id.into(), child_id, task_id, ctx); + return; + } + + self.attempt_remote_child_hydration(child_id, task_id, ctx); + } + + /// Dispatches the hydration action chosen by + /// [`decide_remote_child_hydration_action`]. Inspects the + /// [`AmbientAgentTask`] directly because `resolve_open_action` collapses + /// the navigate-to-local and hydrate-cloud-transcript intents into one + /// variant once `conversations_by_id` carries the placeholder. + fn attempt_remote_child_hydration( + &mut self, + child_id: AIConversationId, + task_id: AmbientAgentTaskId, + ctx: &mut ViewContext, + ) { + let Some(pane_id) = self + .child_agent_panes + .get(&child_id) + .copied() + .filter(|pane_id| self.has_pane_id(*pane_id)) + else { + return; + }; + + let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else { + // Defensive: callers only reach here after `get_task_data` + // returned `Some`. If it's gone now, leave the pending entry + // alone so the next `TasksUpdated` can re-drive. + return; + }; + + match decide_remote_child_hydration_action(&task) { + RemoteChildHydrationAction::LiveAttach => { + self.apply_existing_ambient_task_to_pane(pane_id, child_id, task_id, ctx); + } + RemoteChildHydrationAction::LoadTranscript { + server_token, + task_is_terminal, + } => { + self.hydrate_remote_child_transcript_in_place( + pane_id, + child_id, + task_id, + server_token, + task_is_terminal, + ctx, + ); + } + RemoteChildHydrationAction::Fallback { task_is_terminal } => { + // No live session, no server token: attach to the + // (possibly empty) ambient session, then insert the + // conversation-ended tombstone iff the run is terminal so + // an `ActiveUnattachable` child isn't visually ended. + self.attach_ambient_session_and_maybe_tombstone( + pane_id, + child_id, + task_id, + task_is_terminal, + ctx, + ); + } + } + } + + /// Attaches the hidden child pane's ambient agent view model to the + /// live ambient session for `task_id`. Wrapper around + /// `AmbientAgentViewModel::enter_viewing_existing_session` that also + /// sets the active conversation id. + fn apply_existing_ambient_task_to_pane( + &mut self, + pane_id: PaneId, + child_id: AIConversationId, + task_id: AmbientAgentTaskId, + ctx: &mut ViewContext, + ) { + let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) else { + return; + }; + terminal_view.update(ctx, |terminal_view, ctx| { + let Some(ambient_agent_view_model) = terminal_view + .ambient_agent_view_model() + .into_optional_handle() + .cloned() + else { + return; + }; + ambient_agent_view_model.update(ctx, |model, ctx| { + model.set_conversation_id(Some(child_id)); + model.enter_viewing_existing_session(task_id, ctx); + }); + }); + } + + /// Fetches the cloud transcript identified by `server_token`, hydrates + /// the placeholder via + /// `hydrate_remote_child_placeholder_with_cloud_transcript`, and + /// re-restores the merged conversation into the pane. + /// `task_is_terminal` gates the conversation-ended tombstone in + /// `attach_ambient_session_and_maybe_tombstone` so an + /// `ActiveUnattachable` run isn't visually marked as ended. + fn hydrate_remote_child_transcript_in_place( + &mut self, + pane_id: PaneId, + child_id: AIConversationId, + task_id: AmbientAgentTaskId, + server_token: ServerConversationToken, + task_is_terminal: bool, + ctx: &mut ViewContext, + ) { + let history_handle = BlocklistAIHistoryModel::handle(ctx); + let future = history_handle.update(ctx, |history_model, ctx| { + history_model.load_conversation_by_server_token(&server_token, ctx) + }); + ctx.spawn(future, move |group, conversation, ctx| { + // Guard against a stale target while the fetch was in flight: + // the pane id must still be the canonical one for `child_id` + // AND the pane's terminal view must still be displaying it. + let still_canonical = group + .child_agent_panes + .get(&child_id) + .copied() + .is_some_and(|p| p == pane_id && group.has_pane_id(p)); + if !still_canonical { + return; + } + let terminal_view_active_conversation = group + .terminal_view_from_pane_id(pane_id, ctx) + .and_then(|tv| tv.as_ref(ctx).active_conversation_id(ctx)); + if terminal_view_active_conversation != Some(child_id) { + return; + } + + match conversation { + Some(CloudConversationData::Oz(cloud)) => { + let tasks: Vec = cloud + .all_tasks() + .filter_map(|task| task.source().cloned()) + .collect(); + let cloud_conversation = *cloud; + let merge_result = + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| { + history.hydrate_remote_child_placeholder_with_cloud_transcript( + child_id, + tasks, + cloud_conversation, + ) + }); + match merge_result { + Ok(merged) => { + if let Some(terminal_view) = + group.terminal_view_from_pane_id(pane_id, ctx) + { + terminal_view.update(ctx, |view, ctx| { + view.restore_conversation_after_view_creation( + RestoredAIConversation::new(merged), + true, + RestoreConversationEntryBehavior::PreserveAgentViewState, + ctx, + ); + }); + } + } + Err(err) => { + log::warn!( + "hydrate_remote_child_placeholder_with_cloud_transcript failed for {child_id:?}: {err:#}" + ); + } + } + } + Some(CloudConversationData::CLIAgent(_)) | None => { + // Non-Oz transcript or fetch failure — the post-match + // call handles attach + conditional tombstone. + } + } + + // Uniform post-match step so the `task_is_terminal` gate + // applies to all three branches above. + group.attach_ambient_session_and_maybe_tombstone( + pane_id, + child_id, + task_id, + task_is_terminal, + ctx, + ); + }); + } + + /// Post-match step for `hydrate_remote_child_transcript_in_place`: + /// attach the live ambient session and insert the conversation-ended + /// tombstone iff `task_is_terminal`. Centralised so the gate stays + /// consistent across the Ok-merge / Err-merge / non-Oz fallback arms. + fn attach_ambient_session_and_maybe_tombstone( + &mut self, + pane_id: PaneId, + child_id: AIConversationId, + task_id: AmbientAgentTaskId, + task_is_terminal: bool, + ctx: &mut ViewContext, + ) { + self.apply_existing_ambient_task_to_pane(pane_id, child_id, task_id, ctx); + if !task_is_terminal { + return; + } + if let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) { + terminal_view.update(ctx, |view, ctx| { + view.insert_conversation_ended_tombstone_with_resolved_cta(ctx); + }); + } + } + + /// Drains entries from `pending_remote_child_hydrations` for which task + /// data is now available, hydrating each hidden child pane in place. + pub(in crate::pane_group) fn process_pending_remote_child_hydrations( + &mut self, + ctx: &mut ViewContext, + ) { + if self.pending_remote_child_hydrations.is_empty() { + return; + } + + let ready_tasks: Vec<_> = self + .pending_remote_child_hydrations + .keys() + .filter(|task_id| { + AgentConversationsModel::as_ref(ctx) + .get_task_data(task_id) + .is_some() + }) + .copied() + .collect(); + + for task_id in ready_tasks { + let Some(placeholder_conversation_id) = + self.pending_remote_child_hydrations.remove(&task_id) + else { + continue; + }; + self.attempt_remote_child_hydration(placeholder_conversation_id, task_id, ctx); + } + } +} diff --git a/app/src/pane_group/child_agent.rs b/app/src/pane_group/child_agent/mod.rs similarity index 99% rename from app/src/pane_group/child_agent.rs rename to app/src/pane_group/child_agent/mod.rs index 4817b1e3b4..472ab4f5b0 100644 --- a/app/src/pane_group/child_agent.rs +++ b/app/src/pane_group/child_agent/mod.rs @@ -1,3 +1,6 @@ +pub(in crate::pane_group) mod hydration; +mod restoration; + use std::collections::HashMap; use std::ffi::OsString; use std::path::PathBuf; diff --git a/app/src/pane_group/child_agent/restoration.rs b/app/src/pane_group/child_agent/restoration.rs new file mode 100644 index 0000000000..f8e6dd789f --- /dev/null +++ b/app/src/pane_group/child_agent/restoration.rs @@ -0,0 +1,419 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use session_sharing_protocol::common::SessionId; +use uuid::Uuid; +use warpui::{SingletonEntity, ViewContext}; + +use super::{apply_hidden_child_agent_task_context, HiddenChildAgentTaskContext}; +use crate::ai::agent::conversation::{AIConversation, AIConversationId}; +use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; +use crate::ai::blocklist::BlocklistAIHistoryModel; +use crate::ai::restored_conversations::RestoredAgentConversations; +use crate::pane_group::{ + AmbientAgentViewModelHandleExt, PaneGroup, PaneId, TerminalPane, TerminalViewResources, +}; +use crate::terminal::shared_session::IsSharedSessionCreator; +use crate::terminal::view::load_ai_conversation::{ + RestoreConversationEntryBehavior, RestoredAIConversation, +}; + +impl PaneGroup { + /// Lazily restores hidden child panes for the given parent conversation. + /// + /// Unlike the old startup sweep, this runs only when the parent agent view + /// is actually restored or entered. Children that already belong to some + /// other pane or tab are left alone. + pub(in crate::pane_group) fn restore_missing_child_agent_panes_for_parent( + &mut self, + parent_conversation_id: AIConversationId, + parent_pane_id: PaneId, + ctx: &mut ViewContext, + ) { + let child_ids = BlocklistAIHistoryModel::as_ref(ctx) + .child_conversation_ids_of(&parent_conversation_id) + .to_vec(); + + for child_id in child_ids { + if self + .child_agent_panes + .get(&child_id) + .is_some_and(|pane_id| self.has_pane_id(*pane_id)) + { + continue; + } + + if self.is_conversation_owned_outside_pane(child_id, parent_pane_id, ctx) { + continue; + } + + let child_conversation = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&child_id) + .cloned() + .or_else(|| { + RestoredAgentConversations::handle(ctx) + .update(ctx, |store, _| store.take_conversation(&child_id)) + }); + let Some(child_conversation) = child_conversation else { + log::warn!("Child conversation {child_id:?} not found in memory or restored store"); + continue; + }; + + self.create_hidden_child_agent_pane(child_conversation, parent_pane_id, ctx); + } + } + + /// Restores hidden child panes if this terminal pane is already showing a + /// fullscreen agent view. This covers restored or replaced panes whose + /// terminal view entered agent view before pane-group attachment finished. + pub(in crate::pane_group) fn restore_missing_child_agent_panes_for_terminal_pane_if_needed( + &mut self, + pane_id: PaneId, + ctx: &mut ViewContext, + ) { + let Some(terminal_pane_id) = pane_id.as_terminal_pane_id() else { + return; + }; + let Some(parent_conversation_id) = self + .terminal_view_from_pane_id(terminal_pane_id, ctx) + .and_then(|terminal_view| { + let terminal_view = terminal_view.as_ref(ctx); + let agent_view_state = terminal_view + .agent_view_controller() + .as_ref(ctx) + .agent_view_state(); + if agent_view_state.is_fullscreen() { + agent_view_state.active_conversation_id() + } else { + None + } + }) + else { + return; + }; + + self.restore_missing_child_agent_panes_for_parent( + parent_conversation_id, + terminal_pane_id.into(), + ctx, + ); + } + + /// Ensures `child_conversation_id` has a hidden child pane if it still + /// belongs under a parent conversation in this pane group. + /// + /// Returns true if the conversation is already reachable through an + /// existing pane or if lazy restoration successfully materialized the child + /// pane. + pub(in crate::pane_group) fn ensure_hidden_child_agent_pane_for_conversation( + &mut self, + child_conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) -> bool { + if self + .child_agent_panes + .get(&child_conversation_id) + .is_some_and(|pane_id| self.has_pane_id(*pane_id)) + { + return true; + } + + let parent_conversation_id = + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { + history_model + .conversation(&child_conversation_id) + .and_then(|conversation| { + history_model.resolved_parent_conversation_id_for_conversation(conversation) + }) + .or_else(|| { + RestoredAgentConversations::handle(ctx).read(ctx, |store, _| { + store.get_conversation(&child_conversation_id).and_then( + |conversation| { + history_model.resolved_parent_conversation_id_for_conversation( + conversation, + ) + }, + ) + }) + }) + }); + + let Some(parent_conversation_id) = parent_conversation_id else { + return self + .terminal_view_id_for_owned_conversation(child_conversation_id, ctx) + .is_some(); + }; + + let child_owner_terminal_view_id = + self.terminal_view_id_for_owned_conversation(child_conversation_id, ctx); + let Some(parent_pane_id) = self.pane_id_for_owned_conversation(parent_conversation_id, ctx) + else { + return child_owner_terminal_view_id.is_some(); + }; + + if self.is_conversation_owned_outside_pane(child_conversation_id, parent_pane_id, ctx) { + return true; + } + + self.restore_missing_child_agent_panes_for_parent( + parent_conversation_id, + parent_pane_id, + ctx, + ); + + self.child_agent_panes + .get(&child_conversation_id) + .is_some_and(|pane_id| self.has_pane_id(*pane_id)) + || self.is_conversation_owned_outside_pane(child_conversation_id, parent_pane_id, ctx) + } + + /// Creates a hidden child agent pane for an existing child conversation, + /// restoring the conversation and tracking it in `child_agent_panes`. + pub(in crate::pane_group) fn create_hidden_child_agent_pane( + &mut self, + child_conversation: AIConversation, + parent_pane_id: PaneId, + ctx: &mut ViewContext, + ) { + let child_id = child_conversation.id(); + + // Viewer-side child clicked before `OrchestrationViewerModel` + // surfaced a `session_id`: render a loading placeholder; the real + // pane gets swapped in by `ensure_shared_session_viewer_child_pane`. + if child_conversation.is_viewing_shared_session() { + let resources = TerminalViewResources { + tips_completed: self.tips_completed.clone(), + server_api: self.server_api.clone(), + model_event_sender: self.model_event_sender.clone(), + }; + let view_size = Self::estimated_view_bounds(ctx).size(); + let (loading_view, loading_manager) = Self::create_loading_terminal_manager_and_view( + resources, + view_size, + ctx.window_id(), + ctx, + ); + let pane_data = TerminalPane::new( + Uuid::new_v4().as_bytes().to_vec(), + loading_manager, + loading_view.clone(), + self.model_event_sender.clone(), + ctx, + ); + let new_pane_id = pane_data.terminal_pane_id(); + if self + .attach_child_pane_off_tree(Box::new(pane_data), ctx) + .is_none() + { + log::error!( + "create_hidden_child_agent_pane: failed to attach loading placeholder for \ + viewer-side child {child_id:?}" + ); + return; + } + + // Restore the conversation and enter agent view so the pill bar + // renders (its gate requires `is_fullscreen()`). The output area + // stays a loading spinner because the loading view's + // `ConversationTranscriptViewerStatus::Loading` short-circuits + // the block list render in `TerminalView::render`. + loading_view.update(ctx, |terminal_view, ctx| { + terminal_view.restore_conversation_after_view_creation( + RestoredAIConversation::new(child_conversation), + true, + RestoreConversationEntryBehavior::PreserveAgentViewState, + ctx, + ); + terminal_view.enter_agent_view( + None, + Some(child_id), + AgentViewEntryOrigin::SharedSessionSelection, + ctx, + ); + }); + + self.child_agent_panes.insert(child_id, new_pane_id.into()); + return; + } + + if child_conversation.is_remote_child() { + let Some(task_id) = child_conversation.task_id() else { + log::warn!( + "Cannot restore remote child conversation {child_id:?} without a task ID" + ); + return; + }; + self.hydrate_task_backed_hidden_child_pane( + child_conversation, + parent_pane_id, + task_id, + ctx, + ); + return; + } + let child_task_context = + child_conversation + .task_id() + .map(|task_id| HiddenChildAgentTaskContext { + task_id, + working_dir: child_conversation + .current_working_directory() + .or_else(|| child_conversation.initial_working_directory()) + .map(PathBuf::from), + }); + // Restored hidden child panes don't inherit the host's shared + // session — the host's share decision is handled at original + // dispatch time, not on subsequent restores. + let new_pane_id = self.insert_terminal_pane_hidden_for_child_agent( + parent_pane_id, + HashMap::new(), + IsSharedSessionCreator::No, + ctx, + ); + + if let Some(new_terminal_view) = self.terminal_view_from_pane_id(new_pane_id, ctx) { + if let Some(task_context) = child_task_context.as_ref() { + apply_hidden_child_agent_task_context(&new_terminal_view, task_context, ctx); + } + new_terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view.restore_conversation_after_view_creation( + RestoredAIConversation::new(child_conversation), + true, + RestoreConversationEntryBehavior::PreserveAgentViewState, + ctx, + ); + terminal_view.enter_agent_view( + None, + Some(child_id), + AgentViewEntryOrigin::ChildAgent, + ctx, + ); + }); + + self.child_agent_panes.insert(child_id, new_pane_id.into()); + } else { + log::error!("Failed to get terminal view for child agent pane {child_id:?}"); + self.discard_pane(new_pane_id.into(), ctx); + } + } + + /// Materializes a hidden shared-session viewer pane for a viewer- + /// discovered child agent. Triggered by + /// `Event::EnsureSharedSessionViewerChildPane`, which + /// `OrchestrationViewerModel` emits on the parent's view the first + /// time it observes a `session_id` for a child. The new pane gets its + /// own `BlocklistAIController` and viewer-side `Network` so child + /// traffic doesn't cross the parent's single-stream state. + pub(in crate::pane_group) fn ensure_shared_session_viewer_child_pane( + &mut self, + child_conversation_id: AIConversationId, + child_session_id: SessionId, + ctx: &mut ViewContext, + ) { + // Race recovery: a pill click before materialization had a + // `session_id` falls through to `create_hidden_child_agent_pane`, + // which leaves a loading placeholder in `child_agent_panes`. The + // emission gate in `OrchestrationViewerModel` guarantees this + // helper runs at most once per child per model lifetime, so any + // existing entry must be that fallback — safe to discard. + let fallback_was_swapped_anchor = if let Some(prior_pane_id) = self + .child_agent_panes + .get(&child_conversation_id) + .copied() + .filter(|pane_id| self.has_pane_id(*pane_id)) + { + let anchor = self.panes.original_pane_for_replacement(prior_pane_id); + self.discard_child_agent_pane_for_conversation(child_conversation_id, ctx); + anchor + } else { + None + }; + + let Some(child_conversation) = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&child_conversation_id) + .cloned() + else { + log::warn!( + "ensure_shared_session_viewer_child_pane: no local conversation {child_conversation_id:?}" + ); + return; + }; + let child_task_id = child_conversation.task_id(); + + let resources = TerminalViewResources { + tips_completed: self.tips_completed.clone(), + server_api: self.server_api.clone(), + model_event_sender: self.model_event_sender.clone(), + }; + let view_size = Self::estimated_view_bounds(ctx).size(); + // Per-child viewer: parent's model already discovers descendants, and + // hidden child viewers aren't snapshotted, so `is_cloud_mode` stays + // `false` (no `ambient_agent_view_model` needed for snapshot round-trip). + let (new_terminal_view, terminal_manager) = Self::create_shared_session_viewer( + child_session_id, + resources, + view_size, + false, // enable_orchestration_polling + false, // is_cloud_mode + ctx, + ); + + let pane_data = TerminalPane::new( + Uuid::new_v4().as_bytes().to_vec(), + terminal_manager, + new_terminal_view.clone(), + self.model_event_sender.clone(), + ctx, + ); + let new_pane_id = pane_data.terminal_pane_id(); + if self + .attach_child_pane_off_tree(Box::new(pane_data), ctx) + .is_none() + { + log::error!( + "ensure_shared_session_viewer_child_pane: failed to attach pane for conv={child_conversation_id:?}" + ); + return; + } + + new_terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view.suppress_initial_conversation_details_panel_auto_open(); + terminal_view.restore_conversation_after_view_creation( + RestoredAIConversation::new(child_conversation), + true, + RestoreConversationEntryBehavior::PreserveAgentViewState, + ctx, + ); + terminal_view.enter_agent_view( + None, + Some(child_conversation_id), + AgentViewEntryOrigin::SharedSessionSelection, + ctx, + ); + // Shared-session viewer is `is_cloud_mode=false`, so + // `ambient_agent_view_model()` is typically `None`. Update + // opportunistically; the network's `JoinedSuccessfully` is the + // authoritative source for ambient agent state. + if let Some(ambient_agent_view_model) = terminal_view + .ambient_agent_view_model() + .into_optional_handle() + .cloned() + { + ambient_agent_view_model.update(ctx, |model, ctx| { + model.set_conversation_id(Some(child_conversation_id)); + if let Some(task_id) = child_task_id { + model.enter_viewing_existing_session(task_id, ctx); + } + }); + } + }); + + self.child_agent_panes + .insert(child_conversation_id, new_pane_id.into()); + // If the discarded fallback was occupying a tree slot via temporary + // replacement, re-swap so the user lands on the new pane. + if let Some(anchor) = fallback_was_swapped_anchor { + self.swap_active_pane_to_conversation(anchor, child_conversation_id, ctx); + } + } +} diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index b1dc2e5b0b..7c51ca45f6 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -43,16 +43,13 @@ use warpui::{ }; 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::{ - AmbientAgentLiveSessionState, AmbientAgentTask, AmbientAgentTaskId, -}; +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; @@ -166,12 +163,13 @@ use crate::workspace::{ }; use crate::{cmd_or_ctrl_shift, report_if_error, send_telemetry_from_ctx}; +mod ambient_pane_restoration; mod child_agent; pub mod focus_state; pub mod pane; pub mod tree; pub mod working_directories; -use child_agent::{apply_hidden_child_agent_task_context, HiddenChildAgentTaskContext}; +use ambient_pane_restoration::AmbientRestoreKind; use focus_state::PaneGroupFocusState; #[cfg(test)] @@ -1060,69 +1058,6 @@ type InitialLayoutCallback = Box< ) -> (PaneData, InitialFocus), >; -/// The restoration path for an ambient agent pane. -enum AmbientRestoreKind { - /// Active shared session - SharedSession { session_id: SessionId }, - /// Conversation data isn't loaded yet — show a loading pane and - /// defer the real restoration to the pending-restoration subscription - /// (which waits for the data to be loaded async). - PendingRestoration { task_id: AmbientAgentTaskId }, - /// If there's no task ID to restore, we open a fresh cloud mode pane - /// (this is a valid state from when a user quits with an empty cloud mode pane). - NewCloudConversation, -} - -/// How to hydrate a restored hidden remote-child pane given its -/// [`AmbientAgentTask`]. See [`decide_remote_child_hydration_action`]. -#[derive(Debug, Clone, PartialEq, Eq)] -enum RemoteChildHydrationAction { - /// Attachable live session — join it in place. - LiveAttach, - /// No live session but a server conversation token is available; - /// `task_is_terminal` controls whether the post-merge step inserts a - /// conversation-ended tombstone (only terminal runs do). - LoadTranscript { - server_token: ServerConversationToken, - task_is_terminal: bool, - }, - /// Neither live nor cloud transcript available; fall through to - /// `attach_ambient_session_and_maybe_tombstone`. `task_is_terminal` - /// gates the tombstone so an `ActiveUnattachable` run with no server - /// token isn't visually marked as ended. - Fallback { task_is_terminal: bool }, -} - -/// Pure decision function backing [`PaneGroup::attempt_remote_child_hydration`]. -/// Free-standing so it's unit-testable without a `PaneGroup`. -fn decide_remote_child_hydration_action(task: &AmbientAgentTask) -> RemoteChildHydrationAction { - let live_session_state = task.active_live_session_state(); - if matches!( - live_session_state, - AmbientAgentLiveSessionState::Attachable { .. } - ) { - return RemoteChildHydrationAction::LiveAttach; - } - - let task_is_terminal = matches!(live_session_state, AmbientAgentLiveSessionState::Inactive); - - // Empty/whitespace tokens would drive a no-op cloud fetch followed by - // a misleading tombstone; route them to `Fallback` instead. - let server_token = task - .conversation_id() - .map(str::trim) - .filter(|t| !t.is_empty()) - .map(|t| ServerConversationToken::new(t.to_string())); - - match server_token { - Some(server_token) => RemoteChildHydrationAction::LoadTranscript { - server_token, - task_is_terminal, - }, - None => RemoteChildHydrationAction::Fallback { task_is_terminal }, - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AIDocumentPaneVisibilityAction { /// Ensure the requested AI document pane is visible. @@ -3219,404 +3154,6 @@ impl PaneGroup { }) } - /// Lazily restores hidden child panes for the given parent conversation. - /// - /// Unlike the old startup sweep, this runs only when the parent agent view - /// is actually restored or entered. Children that already belong to some - /// other pane or tab are left alone. - fn restore_missing_child_agent_panes_for_parent( - &mut self, - parent_conversation_id: AIConversationId, - parent_pane_id: PaneId, - ctx: &mut ViewContext, - ) { - let child_ids = BlocklistAIHistoryModel::as_ref(ctx) - .child_conversation_ids_of(&parent_conversation_id) - .to_vec(); - - for child_id in child_ids { - if self - .child_agent_panes - .get(&child_id) - .is_some_and(|pane_id| self.has_pane_id(*pane_id)) - { - continue; - } - - if self.is_conversation_owned_outside_pane(child_id, parent_pane_id, ctx) { - continue; - } - - let child_conversation = BlocklistAIHistoryModel::as_ref(ctx) - .conversation(&child_id) - .cloned() - .or_else(|| { - RestoredAgentConversations::handle(ctx) - .update(ctx, |store, _| store.take_conversation(&child_id)) - }); - let Some(child_conversation) = child_conversation else { - log::warn!("Child conversation {child_id:?} not found in memory or restored store"); - continue; - }; - - self.create_hidden_child_agent_pane(child_conversation, parent_pane_id, ctx); - } - } - - /// Restores hidden child panes if this terminal pane is already showing a - /// fullscreen agent view. This covers restored or replaced panes whose - /// terminal view entered agent view before pane-group attachment finished. - fn restore_missing_child_agent_panes_for_terminal_pane_if_needed( - &mut self, - pane_id: PaneId, - ctx: &mut ViewContext, - ) { - let Some(terminal_pane_id) = pane_id.as_terminal_pane_id() else { - return; - }; - let Some(parent_conversation_id) = self - .terminal_view_from_pane_id(terminal_pane_id, ctx) - .and_then(|terminal_view| { - let terminal_view = terminal_view.as_ref(ctx); - let agent_view_state = terminal_view - .agent_view_controller() - .as_ref(ctx) - .agent_view_state(); - if agent_view_state.is_fullscreen() { - agent_view_state.active_conversation_id() - } else { - None - } - }) - else { - return; - }; - - self.restore_missing_child_agent_panes_for_parent( - parent_conversation_id, - terminal_pane_id.into(), - ctx, - ); - } - - /// Ensures `child_conversation_id` has a hidden child pane if it still - /// belongs under a parent conversation in this pane group. - /// - /// Returns true if the conversation is already reachable through an - /// existing pane or if lazy restoration successfully materialized the child - /// pane. - fn ensure_hidden_child_agent_pane_for_conversation( - &mut self, - child_conversation_id: AIConversationId, - ctx: &mut ViewContext, - ) -> bool { - if self - .child_agent_panes - .get(&child_conversation_id) - .is_some_and(|pane_id| self.has_pane_id(*pane_id)) - { - return true; - } - - let parent_conversation_id = - BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { - history_model - .conversation(&child_conversation_id) - .and_then(|conversation| { - history_model.resolved_parent_conversation_id_for_conversation(conversation) - }) - .or_else(|| { - RestoredAgentConversations::handle(ctx).read(ctx, |store, _| { - store.get_conversation(&child_conversation_id).and_then( - |conversation| { - history_model.resolved_parent_conversation_id_for_conversation( - conversation, - ) - }, - ) - }) - }) - }); - - let Some(parent_conversation_id) = parent_conversation_id else { - return self - .terminal_view_id_for_owned_conversation(child_conversation_id, ctx) - .is_some(); - }; - - let child_owner_terminal_view_id = - self.terminal_view_id_for_owned_conversation(child_conversation_id, ctx); - let Some(parent_pane_id) = self.pane_id_for_owned_conversation(parent_conversation_id, ctx) - else { - return child_owner_terminal_view_id.is_some(); - }; - - if self.is_conversation_owned_outside_pane(child_conversation_id, parent_pane_id, ctx) { - return true; - } - - self.restore_missing_child_agent_panes_for_parent( - parent_conversation_id, - parent_pane_id, - ctx, - ); - - self.child_agent_panes - .get(&child_conversation_id) - .is_some_and(|pane_id| self.has_pane_id(*pane_id)) - || self.is_conversation_owned_outside_pane(child_conversation_id, parent_pane_id, ctx) - } - - /// Creates a hidden child agent pane for an existing child conversation, - /// restoring the conversation and tracking it in `child_agent_panes`. - fn create_hidden_child_agent_pane( - &mut self, - child_conversation: AIConversation, - parent_pane_id: PaneId, - ctx: &mut ViewContext, - ) { - let child_id = child_conversation.id(); - - // Viewer-side child clicked before `OrchestrationViewerModel` - // surfaced a `session_id`: render a loading placeholder; the real - // pane gets swapped in by `ensure_shared_session_viewer_child_pane`. - if child_conversation.is_viewing_shared_session() { - let resources = TerminalViewResources { - tips_completed: self.tips_completed.clone(), - server_api: self.server_api.clone(), - model_event_sender: self.model_event_sender.clone(), - }; - let view_size = Self::estimated_view_bounds(ctx).size(); - let (loading_view, loading_manager) = Self::create_loading_terminal_manager_and_view( - resources, - view_size, - ctx.window_id(), - ctx, - ); - let pane_data = TerminalPane::new( - Uuid::new_v4().as_bytes().to_vec(), - loading_manager, - loading_view.clone(), - self.model_event_sender.clone(), - ctx, - ); - let new_pane_id = pane_data.terminal_pane_id(); - if self - .attach_child_pane_off_tree(Box::new(pane_data), ctx) - .is_none() - { - log::error!( - "create_hidden_child_agent_pane: failed to attach loading placeholder for \ - viewer-side child {child_id:?}" - ); - return; - } - - // Restore the conversation and enter agent view so the pill bar - // renders (its gate requires `is_fullscreen()`). The output area - // stays a loading spinner because the loading view's - // `ConversationTranscriptViewerStatus::Loading` short-circuits - // the block list render in `TerminalView::render`. - loading_view.update(ctx, |terminal_view, ctx| { - terminal_view.restore_conversation_after_view_creation( - RestoredAIConversation::new(child_conversation), - true, - RestoreConversationEntryBehavior::PreserveAgentViewState, - ctx, - ); - terminal_view.enter_agent_view( - None, - Some(child_id), - AgentViewEntryOrigin::SharedSessionSelection, - ctx, - ); - }); - - self.child_agent_panes.insert(child_id, new_pane_id.into()); - return; - } - - if child_conversation.is_remote_child() { - let Some(task_id) = child_conversation.task_id() else { - log::warn!( - "Cannot restore remote child conversation {child_id:?} without a task ID" - ); - return; - }; - self.hydrate_task_backed_hidden_child_pane( - child_conversation, - parent_pane_id, - task_id, - ctx, - ); - return; - } - let child_task_context = - child_conversation - .task_id() - .map(|task_id| HiddenChildAgentTaskContext { - task_id, - working_dir: child_conversation - .current_working_directory() - .or_else(|| child_conversation.initial_working_directory()) - .map(PathBuf::from), - }); - // Restored hidden child panes don't inherit the host's shared - // session — the host's share decision is handled at original - // dispatch time, not on subsequent restores. - let new_pane_id = self.insert_terminal_pane_hidden_for_child_agent( - parent_pane_id, - HashMap::new(), - IsSharedSessionCreator::No, - ctx, - ); - - if let Some(new_terminal_view) = self.terminal_view_from_pane_id(new_pane_id, ctx) { - if let Some(task_context) = child_task_context.as_ref() { - apply_hidden_child_agent_task_context(&new_terminal_view, task_context, ctx); - } - new_terminal_view.update(ctx, |terminal_view, ctx| { - terminal_view.restore_conversation_after_view_creation( - RestoredAIConversation::new(child_conversation), - true, - RestoreConversationEntryBehavior::PreserveAgentViewState, - ctx, - ); - terminal_view.enter_agent_view( - None, - Some(child_id), - AgentViewEntryOrigin::ChildAgent, - ctx, - ); - }); - - self.child_agent_panes.insert(child_id, new_pane_id.into()); - } else { - log::error!("Failed to get terminal view for child agent pane {child_id:?}"); - self.discard_pane(new_pane_id.into(), ctx); - } - } - - /// Materializes a hidden shared-session viewer pane for a viewer- - /// discovered child agent. Triggered by - /// `Event::EnsureSharedSessionViewerChildPane`, which - /// `OrchestrationViewerModel` emits on the parent's view the first - /// time it observes a `session_id` for a child. The new pane gets its - /// own `BlocklistAIController` and viewer-side `Network` so child - /// traffic doesn't cross the parent's single-stream state. - fn ensure_shared_session_viewer_child_pane( - &mut self, - child_conversation_id: AIConversationId, - child_session_id: SessionId, - ctx: &mut ViewContext, - ) { - // Race recovery: a pill click before materialization had a - // `session_id` falls through to `create_hidden_child_agent_pane`, - // which leaves a loading placeholder in `child_agent_panes`. The - // emission gate in `OrchestrationViewerModel` guarantees this - // helper runs at most once per child per model lifetime, so any - // existing entry must be that fallback — safe to discard. - let fallback_was_swapped_anchor = if let Some(prior_pane_id) = self - .child_agent_panes - .get(&child_conversation_id) - .copied() - .filter(|pane_id| self.has_pane_id(*pane_id)) - { - let anchor = self.panes.original_pane_for_replacement(prior_pane_id); - self.discard_child_agent_pane_for_conversation(child_conversation_id, ctx); - anchor - } else { - None - }; - - let Some(child_conversation) = BlocklistAIHistoryModel::as_ref(ctx) - .conversation(&child_conversation_id) - .cloned() - else { - log::warn!( - "ensure_shared_session_viewer_child_pane: no local conversation {child_conversation_id:?}" - ); - return; - }; - let child_task_id = child_conversation.task_id(); - - let resources = TerminalViewResources { - tips_completed: self.tips_completed.clone(), - server_api: self.server_api.clone(), - model_event_sender: self.model_event_sender.clone(), - }; - let view_size = Self::estimated_view_bounds(ctx).size(); - // Per-child viewer: parent's model already discovers descendants, and - // hidden child viewers aren't snapshotted, so `is_cloud_mode` stays - // `false` (no `ambient_agent_view_model` needed for snapshot round-trip). - let (new_terminal_view, terminal_manager) = Self::create_shared_session_viewer( - child_session_id, - resources, - view_size, - false, // enable_orchestration_polling - false, // is_cloud_mode - ctx, - ); - - let pane_data = TerminalPane::new( - Uuid::new_v4().as_bytes().to_vec(), - terminal_manager, - new_terminal_view.clone(), - self.model_event_sender.clone(), - ctx, - ); - let new_pane_id = pane_data.terminal_pane_id(); - if self - .attach_child_pane_off_tree(Box::new(pane_data), ctx) - .is_none() - { - log::error!( - "ensure_shared_session_viewer_child_pane: failed to attach pane for conv={child_conversation_id:?}" - ); - return; - } - - new_terminal_view.update(ctx, |terminal_view, ctx| { - terminal_view.suppress_initial_conversation_details_panel_auto_open(); - terminal_view.restore_conversation_after_view_creation( - RestoredAIConversation::new(child_conversation), - true, - RestoreConversationEntryBehavior::PreserveAgentViewState, - ctx, - ); - terminal_view.enter_agent_view( - None, - Some(child_conversation_id), - AgentViewEntryOrigin::SharedSessionSelection, - ctx, - ); - // Shared-session viewer is `is_cloud_mode=false`, so - // `ambient_agent_view_model()` is typically `None`. Update - // opportunistically; the network's `JoinedSuccessfully` is the - // authoritative source for ambient agent state. - if let Some(ambient_agent_view_model) = terminal_view - .ambient_agent_view_model() - .into_optional_handle() - .cloned() - { - ambient_agent_view_model.update(ctx, |model, ctx| { - model.set_conversation_id(Some(child_conversation_id)); - if let Some(task_id) = child_task_id { - model.enter_viewing_existing_session(task_id, ctx); - } - }); - } - }); - - self.child_agent_panes - .insert(child_conversation_id, new_pane_id.into()); - // If the discarded fallback was occupying a tree slot via temporary - // replacement, re-swap so the user lands on the new pane. - if let Some(anchor) = fallback_was_swapped_anchor { - self.swap_active_pane_to_conversation(anchor, child_conversation_id, ctx); - } - } - /// Helper that creates the initial [`PaneData`] and [`InitialFocus`] given a terminal view. /// This is a common case in creating a new pane group with a single terminal session. fn terminal_pane_data( @@ -3678,25 +3215,6 @@ impl PaneGroup { (terminal_view, terminal_manager) } - /// Stores the pending ambient agent restorations, triggers async fetches for - /// their task data, and sets up a single long-lived subscription that will - /// process each pane as its task data arrives. - fn register_pending_ambient_restorations( - &mut self, - pending: Vec<(AmbientAgentTaskId, PaneId)>, - ctx: &mut ViewContext, - ) { - for (task_id, _) in &pending { - AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { - model.get_or_async_fetch_task_data(task_id, ctx); - }); - } - - self.pending_ambient_agent_conversation_restorations = pending.into_iter().collect(); - - self.ensure_pending_ambient_restoration_subscription(ctx); - } - /// Installs the long-lived AgentConversationsModel subscription used by /// both `pending_ambient_agent_conversation_restorations` and /// `pending_remote_child_hydrations` if it has not been installed yet. @@ -3731,474 +3249,6 @@ impl PaneGroup { self.process_pending_remote_child_hydrations(ctx); } - /// Drains entries from `pending_ambient_agent_conversation_restorations` - /// for which task data is now available, replacing or hydrating the - /// corresponding panes. - fn process_pending_ambient_restorations(&mut self, ctx: &mut ViewContext) { - if self - .pending_ambient_agent_conversation_restorations - .is_empty() - { - return; - } - - let ready_tasks: Vec<_> = self - .pending_ambient_agent_conversation_restorations - .keys() - .filter(|task_id| { - AgentConversationsModel::as_ref(ctx) - .get_task_data(task_id) - .is_some() - }) - .copied() - .collect(); - - let resources = TerminalViewResources { - tips_completed: self.tips_completed.clone(), - server_api: self.server_api.clone(), - model_event_sender: self.model_event_sender.clone(), - }; - let view_size = Self::estimated_view_bounds(ctx).size(); - - for task_id in ready_tasks { - let Some(pane_id) = self - .pending_ambient_agent_conversation_restorations - .remove(&task_id) - else { - continue; - }; - let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else { - continue; - }; - - match AgentConversationsModel::resolve_open_action( - AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( - task.task_id, - )), - None, - ctx, - ) { - Some(WorkspaceAction::OpenOrAttachAmbientAgentConversation { - session_id, - task_id: _, - }) => { - let (view, terminal_manager) = Self::create_shared_session_viewer( - session_id, - resources.clone(), - view_size, - true, // enable_orchestration_polling - true, // is_cloud_mode - ctx, - ); - let new_pane = TerminalPane::new( - Uuid::new_v4().as_bytes().to_vec(), - terminal_manager, - view, - self.model_event_sender.clone(), - ctx, - ); - self.replace_pane(pane_id, new_pane, false, ctx); - } - Some(WorkspaceAction::OpenConversationTranscriptViewer { - conversation_id, - ambient_agent_task_id, - }) => { - if let Some(target_view) = self.terminal_view_from_pane_id(pane_id, ctx) { - Self::fetch_and_load_transcript( - target_view, - conversation_id, - ambient_agent_task_id, - ctx, - ); - } else { - self.pending_ambient_agent_conversation_restorations - .insert(task_id, pane_id); - } - } - _ => { - self.replace_pane_with_new_cloud_conversation(pane_id, ctx); - } - } - } - } - - /// Drains entries from `pending_remote_child_hydrations` for which task - /// data is now available, hydrating each hidden child pane in place. - fn process_pending_remote_child_hydrations(&mut self, ctx: &mut ViewContext) { - if self.pending_remote_child_hydrations.is_empty() { - return; - } - - let ready_tasks: Vec<_> = self - .pending_remote_child_hydrations - .keys() - .filter(|task_id| { - AgentConversationsModel::as_ref(ctx) - .get_task_data(task_id) - .is_some() - }) - .copied() - .collect(); - - for task_id in ready_tasks { - let Some(placeholder_conversation_id) = - self.pending_remote_child_hydrations.remove(&task_id) - else { - continue; - }; - self.attempt_remote_child_hydration(placeholder_conversation_id, task_id, ctx); - } - } - - /// Task-backed restore path for the `is_remote_child` branch of - /// `create_hidden_child_agent_pane`. Always creates the hidden ambient - /// pane, registers it in `child_agent_panes` keyed by the placeholder's - /// local `AIConversationId`, then dispatches via - /// `attempt_remote_child_hydration` (or queues a pending entry while - /// task data is fetched). - /// - /// Idempotent: skipped when the placeholder already has a live tracked - /// pane, so repeat calls from `restore_missing_child_agent_panes_for_parent` - /// — including while the initial async hydration is still in flight — - /// don't create a duplicate hidden pane and orphan the first one. - fn hydrate_task_backed_hidden_child_pane( - &mut self, - child_conversation: AIConversation, - parent_pane_id: PaneId, - task_id: AmbientAgentTaskId, - ctx: &mut ViewContext, - ) { - let child_id = child_conversation.id(); - - // Idempotency guard — see fn doc. - if let Some(existing_pane_id) = self.child_agent_panes.get(&child_id).copied() { - if self.has_pane_id(existing_pane_id) { - return; - } - } - - let new_pane_id = - self.insert_ambient_agent_pane_hidden_for_child_agent(parent_pane_id, ctx); - - let Some(new_terminal_view) = self.terminal_view_from_pane_id(new_pane_id, ctx) else { - log::error!("Failed to get terminal view for remote child agent pane {child_id:?}"); - self.discard_pane(new_pane_id.into(), ctx); - return; - }; - - // Restore the placeholder so the pane has parent linkage + agent - // name before task-backed hydration runs. - let mut restored = false; - new_terminal_view.update(ctx, |terminal_view, ctx| { - terminal_view.restore_conversation_after_view_creation( - RestoredAIConversation::new(child_conversation), - true, - RestoreConversationEntryBehavior::PreserveAgentViewState, - ctx, - ); - terminal_view.enter_agent_view( - None, - Some(child_id), - AgentViewEntryOrigin::CloudAgent, - ctx, - ); - restored = terminal_view - .ambient_agent_view_model() - .into_optional_handle() - .is_some(); - }); - - if !restored { - log::error!( - "Failed to restore remote child agent pane {child_id:?}: missing ambient agent view model" - ); - self.discard_pane(new_pane_id.into(), ctx); - return; - } - - // Placeholder's local id stays the canonical `child_agent_panes` - // key across live-attach and transcript hydration. - self.child_agent_panes.insert(child_id, new_pane_id.into()); - - let task_now = AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { - model.get_or_async_fetch_task_data(&task_id, ctx) - }); - - if task_now.is_none() { - // Task data not yet cached: queue a pending hydration and - // attempt a live-attach in the meantime so streaming runs are - // not stalled while waiting on the fetch. - self.pending_remote_child_hydrations - .insert(task_id, child_id); - self.ensure_pending_ambient_restoration_subscription(ctx); - self.apply_existing_ambient_task_to_pane(new_pane_id.into(), child_id, task_id, ctx); - return; - } - - self.attempt_remote_child_hydration(child_id, task_id, ctx); - } - - /// Dispatches the hydration action chosen by - /// [`decide_remote_child_hydration_action`]. Inspects the - /// [`AmbientAgentTask`] directly because `resolve_open_action` collapses - /// the navigate-to-local and hydrate-cloud-transcript intents into one - /// variant once `conversations_by_id` carries the placeholder. - fn attempt_remote_child_hydration( - &mut self, - child_id: AIConversationId, - task_id: AmbientAgentTaskId, - ctx: &mut ViewContext, - ) { - let Some(pane_id) = self - .child_agent_panes - .get(&child_id) - .copied() - .filter(|pane_id| self.has_pane_id(*pane_id)) - else { - return; - }; - - let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else { - // Defensive: callers only reach here after `get_task_data` - // returned `Some`. If it's gone now, leave the pending entry - // alone so the next `TasksUpdated` can re-drive. - return; - }; - - match decide_remote_child_hydration_action(&task) { - RemoteChildHydrationAction::LiveAttach => { - self.apply_existing_ambient_task_to_pane(pane_id, child_id, task_id, ctx); - } - RemoteChildHydrationAction::LoadTranscript { - server_token, - task_is_terminal, - } => { - self.hydrate_remote_child_transcript_in_place( - pane_id, - child_id, - task_id, - server_token, - task_is_terminal, - ctx, - ); - } - RemoteChildHydrationAction::Fallback { task_is_terminal } => { - // No live session, no server token: attach to the - // (possibly empty) ambient session, then insert the - // conversation-ended tombstone iff the run is terminal so - // an `ActiveUnattachable` child isn't visually ended. - self.attach_ambient_session_and_maybe_tombstone( - pane_id, - child_id, - task_id, - task_is_terminal, - ctx, - ); - } - } - } - - /// Attaches the hidden child pane's ambient agent view model to the - /// live ambient session for `task_id`. Wrapper around - /// `AmbientAgentViewModel::enter_viewing_existing_session` that also - /// sets the active conversation id. - fn apply_existing_ambient_task_to_pane( - &mut self, - pane_id: PaneId, - child_id: AIConversationId, - task_id: AmbientAgentTaskId, - ctx: &mut ViewContext, - ) { - let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) else { - return; - }; - terminal_view.update(ctx, |terminal_view, ctx| { - let Some(ambient_agent_view_model) = terminal_view - .ambient_agent_view_model() - .into_optional_handle() - .cloned() - else { - return; - }; - ambient_agent_view_model.update(ctx, |model, ctx| { - model.set_conversation_id(Some(child_id)); - model.enter_viewing_existing_session(task_id, ctx); - }); - }); - } - - /// Fetches the cloud transcript identified by `server_token`, hydrates - /// the placeholder via - /// `hydrate_remote_child_placeholder_with_cloud_transcript`, and - /// re-restores the merged conversation into the pane. - /// `task_is_terminal` gates the conversation-ended tombstone in - /// `attach_ambient_session_and_maybe_tombstone` so an - /// `ActiveUnattachable` run isn't visually marked as ended. - fn hydrate_remote_child_transcript_in_place( - &mut self, - pane_id: PaneId, - child_id: AIConversationId, - task_id: AmbientAgentTaskId, - server_token: ServerConversationToken, - task_is_terminal: bool, - ctx: &mut ViewContext, - ) { - let history_handle = BlocklistAIHistoryModel::handle(ctx); - let future = history_handle.update(ctx, |history_model, ctx| { - history_model.load_conversation_by_server_token(&server_token, ctx) - }); - ctx.spawn(future, move |group, conversation, ctx| { - // Guard against a stale target while the fetch was in flight: - // the pane id must still be the canonical one for `child_id` - // AND the pane's terminal view must still be displaying it. - let still_canonical = group - .child_agent_panes - .get(&child_id) - .copied() - .is_some_and(|p| p == pane_id && group.has_pane_id(p)); - if !still_canonical { - return; - } - let terminal_view_active_conversation = group - .terminal_view_from_pane_id(pane_id, ctx) - .and_then(|tv| tv.as_ref(ctx).active_conversation_id(ctx)); - if terminal_view_active_conversation != Some(child_id) { - return; - } - - match conversation { - Some(CloudConversationData::Oz(cloud)) => { - let tasks: Vec = cloud - .all_tasks() - .filter_map(|task| task.source().cloned()) - .collect(); - let cloud_conversation = *cloud; - let merge_result = - BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| { - history.hydrate_remote_child_placeholder_with_cloud_transcript( - child_id, - tasks, - cloud_conversation, - ) - }); - match merge_result { - Ok(merged) => { - if let Some(terminal_view) = - group.terminal_view_from_pane_id(pane_id, ctx) - { - terminal_view.update(ctx, |view, ctx| { - view.restore_conversation_after_view_creation( - RestoredAIConversation::new(merged), - true, - RestoreConversationEntryBehavior::PreserveAgentViewState, - ctx, - ); - }); - } - } - Err(err) => { - log::warn!( - "hydrate_remote_child_placeholder_with_cloud_transcript failed for {child_id:?}: {err:#}" - ); - } - } - } - Some(CloudConversationData::CLIAgent(_)) | None => { - // Non-Oz transcript or fetch failure — the post-match - // call handles attach + conditional tombstone. - } - } - - // Uniform post-match step so the `task_is_terminal` gate - // applies to all three branches above. - group.attach_ambient_session_and_maybe_tombstone( - pane_id, - child_id, - task_id, - task_is_terminal, - ctx, - ); - }); - } - - /// Post-match step for `hydrate_remote_child_transcript_in_place`: - /// attach the live ambient session and insert the conversation-ended - /// tombstone iff `task_is_terminal`. Centralised so the gate stays - /// consistent across the Ok-merge / Err-merge / non-Oz fallback arms. - fn attach_ambient_session_and_maybe_tombstone( - &mut self, - pane_id: PaneId, - child_id: AIConversationId, - task_id: AmbientAgentTaskId, - task_is_terminal: bool, - ctx: &mut ViewContext, - ) { - self.apply_existing_ambient_task_to_pane(pane_id, child_id, task_id, ctx); - if !task_is_terminal { - return; - } - if let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) { - terminal_view.update(ctx, |view, ctx| { - view.insert_conversation_ended_tombstone_with_resolved_cta(ctx); - }); - } - } - - /// Fetches conversation data and loads it into the given transcript viewer. - fn fetch_and_load_transcript( - target_view: ViewHandle, - server_conversation_token: ServerConversationToken, - ambient_agent_task_id: Option, - ctx: &mut ViewContext, - ) { - let history_model_handle = BlocklistAIHistoryModel::handle(ctx); - - let future = history_model_handle.update(ctx, |history_model, ctx| { - history_model.load_conversation_by_server_token(&server_conversation_token, ctx) - }); - ctx.spawn(future, move |group, conversation, ctx| { - if let Some(conversation) = conversation { - group.load_data_into_transcript_viewer( - target_view, - conversation, - ambient_agent_task_id, - ctx, - ); - } else if let Some(pane_id) = - group.find_pane_id_for_terminal_view(target_view.id(), ctx) - { - log::error!( - "Failed to restore ambient agent pane, replacing with new cloud conversation" - ); - group.replace_pane_with_new_cloud_conversation(pane_id, ctx); - } - }); - } - - /// Replaces a pane with a new cloud conversation. - fn replace_pane_with_new_cloud_conversation( - &mut self, - pane_id: PaneId, - ctx: &mut ViewContext, - ) { - let resources = TerminalViewResources { - tips_completed: self.tips_completed.clone(), - server_api: self.server_api.clone(), - model_event_sender: self.model_event_sender.clone(), - }; - let view_size = Self::estimated_view_bounds(ctx).size(); - let (view, terminal_manager) = - Self::create_ambient_agent_terminal(resources, view_size, ctx); - let new_pane = TerminalPane::new( - Uuid::new_v4().as_bytes().to_vec(), - terminal_manager, - view, - self.model_event_sender.clone(), - ctx, - ); - self.replace_pane(pane_id, new_pane, false, ctx); - } - /// Initial layout for a [`PaneGroup`] with a single ambient agent pane. fn initial_ambient_agent_pane( resources: TerminalViewResources, diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 163e6bce37..1738e8e918 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -21,6 +21,9 @@ use warpui::windowing::WindowManager; use warpui::{App, ModelHandle}; use watcher::HomeDirectoryWatcher; +use super::child_agent::hydration::{ + decide_remote_child_hydration_action, RemoteChildHydrationAction, +}; use super::child_agent::{ create_hidden_child_agent_conversation, HiddenChildAgentConversationRequest, HiddenChildAgentTaskContext, @@ -35,7 +38,8 @@ use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; use crate::ai::ambient_agents::task::TaskPrincipalInfo; use crate::ai::ambient_agents::{ - AgentSource, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState, + AgentSource, AmbientAgentLiveSessionState, AmbientAgentTask, AmbientAgentTaskId, + AmbientAgentTaskState, }; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; use crate::ai::blocklist::history_model::CloudConversationData;