first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+394
View File
@@ -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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<warp_multi_agent_api::Task> = 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>,
) {
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<Self>,
) {
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);
}
}
}
+280
View File
@@ -0,0 +1,280 @@
pub(in crate::pane_group) mod hydration;
mod restoration;
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::PathBuf;
use galaxy_cli::agent::Harness;
use galaxyui::{EntityId, SingletonEntity, ViewContext, ViewHandle};
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::agent::RenderableAIError;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::attachment_utils::attachments_download_dir;
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
use crate::ai::blocklist::{BlocklistAIHistoryModel, StartAgentRequestId};
use crate::ai::llms::LLMPreferences;
use crate::pane_group::{PaneGroup, PaneId};
use crate::terminal::shared_session::IsSharedSessionCreator;
use crate::terminal::TerminalView;
use crate::AIExecutionProfilesModel;
pub(crate) struct HiddenChildAgentConversation {
pub terminal_view: ViewHandle<TerminalView>,
pub terminal_view_id: EntityId,
pub conversation_id: AIConversationId,
}
#[derive(Clone, Debug)]
pub(crate) struct HiddenChildAgentTaskContext {
pub task_id: AmbientAgentTaskId,
pub working_dir: Option<PathBuf>,
}
pub(crate) struct HiddenChildAgentConversationRequest {
pub parent_pane_id: PaneId,
pub name: String,
pub parent_conversation_id: AIConversationId,
pub orchestration_harness: Option<Harness>,
pub env_vars: HashMap<OsString, OsString>,
pub task_context: Option<HiddenChildAgentTaskContext>,
/// When `Yes`, the child pane's terminal is asked to share its session
/// using the embedded `SessionSourceType` once the shell bootstraps.
/// The dispatch helpers in `terminal_pane.rs` compute this from the host
/// terminal's own shared-session state.
pub is_shared_session_creator: IsSharedSessionCreator,
}
pub(crate) struct ErrorChildAgentConversationRequest {
pub parent_pane_id: PaneId,
pub name: String,
pub parent_conversation_id: AIConversationId,
pub request_id: Option<StartAgentRequestId>,
pub orchestration_harness: Option<Harness>,
pub error_message: String,
}
pub(crate) fn apply_hidden_child_agent_task_context(
terminal_view: &ViewHandle<TerminalView>,
task_context: &HiddenChildAgentTaskContext,
ctx: &mut ViewContext<PaneGroup>,
) {
let task_id = task_context.task_id;
let working_dir = task_context.working_dir.clone();
terminal_view.update(ctx, move |terminal_view, ctx| {
terminal_view
.ai_controller()
.update(ctx, |controller, ctx| {
controller.set_ambient_agent_task_id(Some(task_id), ctx);
if let Some(working_dir) = working_dir.as_deref() {
controller.set_attachments_download_dir(attachments_download_dir(working_dir));
}
});
});
}
fn propagate_parent_agent_settings(
group: &PaneGroup,
parent_pane_id: PaneId,
child_terminal_view_id: EntityId,
ctx: &mut ViewContext<PaneGroup>,
) {
let Some(parent_terminal_view) = group.terminal_view_from_pane_id(parent_pane_id, ctx) else {
log::warn!(
"Could not find parent terminal view for pane {parent_pane_id:?}; child will use default AI profile"
);
return;
};
let parent_view_id = parent_terminal_view.id();
let parent_profile_id = *AIExecutionProfilesModel::as_ref(ctx)
.active_profile(Some(parent_view_id), ctx)
.id();
AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles, ctx| {
profiles.set_active_profile(child_terminal_view_id, parent_profile_id, ctx);
});
let parent_base_model_id = LLMPreferences::as_ref(ctx)
.get_active_base_model(ctx, Some(parent_view_id))
.id
.clone();
LLMPreferences::handle(ctx).update(ctx, |llm_prefs, ctx| {
llm_prefs.update_preferred_agent_mode_llm(
&parent_base_model_id,
child_terminal_view_id,
ctx,
);
});
}
fn start_new_child_conversation(
terminal_view_id: EntityId,
name: String,
parent_conversation_id: AIConversationId,
orchestration_harness: Option<Harness>,
ctx: &mut ViewContext<PaneGroup>,
) -> AIConversationId {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.start_new_child_conversation(
terminal_view_id,
name,
parent_conversation_id,
orchestration_harness,
ctx,
)
})
}
pub(crate) fn create_hidden_child_agent_conversation(
group: &mut PaneGroup,
request: HiddenChildAgentConversationRequest,
ctx: &mut ViewContext<PaneGroup>,
) -> Option<HiddenChildAgentConversation> {
let HiddenChildAgentConversationRequest {
parent_pane_id,
name,
parent_conversation_id,
orchestration_harness,
env_vars,
task_context,
is_shared_session_creator,
} = request;
let new_pane_id = group.insert_terminal_pane_hidden_for_child_agent(
parent_pane_id,
env_vars,
is_shared_session_creator,
ctx,
);
let Some(new_terminal_view) = group.terminal_view_from_pane_id(new_pane_id, ctx) else {
log::error!("Failed to get terminal view for new StartAgent pane");
group.discard_pane(new_pane_id.into(), ctx);
return None;
};
let terminal_view_id = new_terminal_view.id();
propagate_parent_agent_settings(group, parent_pane_id, terminal_view_id, ctx);
if let Some(task_context) = task_context.as_ref() {
apply_hidden_child_agent_task_context(&new_terminal_view, task_context, ctx);
}
let conversation_id = start_new_child_conversation(
terminal_view_id,
name,
parent_conversation_id,
orchestration_harness,
ctx,
);
group
.child_agent_panes
.insert(conversation_id, new_pane_id.into());
Some(HiddenChildAgentConversation {
terminal_view: new_terminal_view,
terminal_view_id,
conversation_id,
})
}
fn create_error_child_agent_conversation_context(
group: &mut PaneGroup,
parent_pane_id: PaneId,
name: String,
parent_conversation_id: AIConversationId,
orchestration_harness: Option<Harness>,
ctx: &mut ViewContext<PaneGroup>,
) -> Option<(Option<ViewHandle<TerminalView>>, EntityId, AIConversationId)> {
if let Some(HiddenChildAgentConversation {
terminal_view,
terminal_view_id,
conversation_id,
..
}) = create_hidden_child_agent_conversation(
group,
HiddenChildAgentConversationRequest {
parent_pane_id,
name: name.clone(),
parent_conversation_id,
orchestration_harness,
env_vars: HashMap::new(),
task_context: None,
is_shared_session_creator: IsSharedSessionCreator::No,
},
ctx,
) {
return Some((Some(terminal_view), terminal_view_id, conversation_id));
}
let parent_terminal_view = group.terminal_view_from_pane_id(parent_pane_id, ctx)?;
let parent_terminal_view_id = parent_terminal_view.id();
let conversation_id = start_new_child_conversation(
parent_terminal_view_id,
name,
parent_conversation_id,
orchestration_harness,
ctx,
);
Some((None, parent_terminal_view_id, conversation_id))
}
pub(crate) fn create_error_child_agent_conversation(
group: &mut PaneGroup,
request: ErrorChildAgentConversationRequest,
ctx: &mut ViewContext<PaneGroup>,
) -> Option<AIConversationId> {
let ErrorChildAgentConversationRequest {
parent_pane_id,
name,
parent_conversation_id,
request_id,
orchestration_harness,
error_message,
} = request;
let Some((terminal_view, terminal_view_id, conversation_id)) =
create_error_child_agent_conversation_context(
group,
parent_pane_id,
name,
parent_conversation_id,
orchestration_harness,
ctx,
)
else {
log::error!(
"Failed to surface local child harness error for parent conversation {parent_conversation_id:?}: {error_message}"
);
return None;
};
if let Some(request_id) = request_id {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.record_new_conversation_request_complete(
request_id,
conversation_id,
ctx,
);
});
}
if let Some(terminal_view) = terminal_view {
terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.enter_agent_view(
None,
Some(conversation_id),
AgentViewEntryOrigin::ChildAgent,
ctx,
);
});
}
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
history_model.update_conversation_status_with_error(
terminal_view_id,
conversation_id,
ConversationStatus::Error,
Some(RenderableAIError::other(error_message, false)),
ctx,
);
});
Some(conversation_id)
}
@@ -0,0 +1,416 @@
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<Self>,
) {
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<Self>,
) {
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 controller = terminal_view.agent_view_controller().as_ref(ctx);
if controller.is_fullscreen() {
controller.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<Self>,
) -> 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<Self>,
) {
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<Self>,
) {
// 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);
}
}
}