first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user