417 lines
17 KiB
Rust
417 lines
17 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|