first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<TerminalView>,
|
||||
server_conversation_token: ServerConversationToken,
|
||||
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
use std::{collections::HashMap, ffi::OsString};
|
||||
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;
|
||||
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;
|
||||
|
||||
@@ -15,6 +25,54 @@ pub(crate) struct HiddenChildAgentConversation {
|
||||
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,
|
||||
@@ -54,6 +112,7 @@ 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| {
|
||||
@@ -61,6 +120,7 @@ fn start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
name,
|
||||
parent_conversation_id,
|
||||
orchestration_harness,
|
||||
ctx,
|
||||
)
|
||||
})
|
||||
@@ -68,14 +128,24 @@ fn start_new_child_conversation(
|
||||
|
||||
pub(crate) fn create_hidden_child_agent_conversation(
|
||||
group: &mut PaneGroup,
|
||||
parent_pane_id: PaneId,
|
||||
name: String,
|
||||
parent_conversation_id: AIConversationId,
|
||||
env_vars: HashMap<OsString, OsString>,
|
||||
request: HiddenChildAgentConversationRequest,
|
||||
ctx: &mut ViewContext<PaneGroup>,
|
||||
) -> Option<HiddenChildAgentConversation> {
|
||||
let new_pane_id =
|
||||
group.insert_terminal_pane_hidden_for_child_agent(parent_pane_id, env_vars, ctx);
|
||||
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);
|
||||
@@ -84,9 +154,17 @@ pub(crate) fn create_hidden_child_agent_conversation(
|
||||
|
||||
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, ctx);
|
||||
let conversation_id = start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
name,
|
||||
parent_conversation_id,
|
||||
orchestration_harness,
|
||||
ctx,
|
||||
);
|
||||
|
||||
group
|
||||
.child_agent_panes
|
||||
@@ -104,6 +182,7 @@ fn create_error_child_agent_conversation_context(
|
||||
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 {
|
||||
@@ -113,10 +192,15 @@ fn create_error_child_agent_conversation_context(
|
||||
..
|
||||
}) = create_hidden_child_agent_conversation(
|
||||
group,
|
||||
parent_pane_id,
|
||||
name.clone(),
|
||||
parent_conversation_id,
|
||||
HashMap::new(),
|
||||
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));
|
||||
@@ -124,34 +208,54 @@ fn create_error_child_agent_conversation_context(
|
||||
|
||||
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, ctx);
|
||||
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,
|
||||
parent_pane_id: PaneId,
|
||||
name: String,
|
||||
parent_conversation_id: AIConversationId,
|
||||
error_message: String,
|
||||
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;
|
||||
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(
|
||||
@@ -164,12 +268,13 @@ pub(crate) fn create_error_child_agent_conversation(
|
||||
}
|
||||
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
|
||||
history_model.update_conversation_status_with_error_message(
|
||||
history_model.update_conversation_status_with_error(
|
||||
terminal_view_id,
|
||||
conversation_id,
|
||||
ConversationStatus::Error,
|
||||
Some(error_message),
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use warpui::{AppContext, Entity, ModelContext, ModelHandle};
|
||||
|
||||
use super::pane::{PaneId, TerminalPaneId};
|
||||
use super::{PaneState, SplitPaneState};
|
||||
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle};
|
||||
|
||||
/// Centralized focus state for a pane group.
|
||||
/// This model tracks which pane is focused, which session is active,
|
||||
|
||||
+1681
-604
File diff suppressed because it is too large
Load Diff
+2015
-101
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,13 @@
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
ai::ai_document_view::{AIDocumentEvent, AIDocumentView},
|
||||
ai::document::ai_document_model::AIDocumentModel,
|
||||
app_state::{AIDocumentPaneSnapshot, LeafContents},
|
||||
};
|
||||
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::ai::ai_document_view::{AIDocumentEvent, AIDocumentView};
|
||||
use crate::ai::document::ai_document_model::AIDocumentModel;
|
||||
use crate::app_state::{AIDocumentPaneSnapshot, LeafContents};
|
||||
|
||||
pub struct AIDocumentPane {
|
||||
view: ViewHandle<PaneView<AIDocumentView>>,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
ai::facts::{AIFactManager, AIFactView, AIFactViewEvent},
|
||||
app_state::{AIFactPaneSnapshot, LeafContents},
|
||||
};
|
||||
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::ai::facts::{AIFactManager, AIFactView, AIFactViewEvent};
|
||||
use crate::app_state::{AIFactPaneSnapshot, LeafContents};
|
||||
|
||||
pub struct AIFactPane {
|
||||
view: ViewHandle<PaneView<AIFactView>>,
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::inline_action::code_diff_view::{CodeDiffView, CodeDiffViewEvent},
|
||||
app_state::{CodePaneSnapShot, CodePaneTabSnapshot, LeafContents},
|
||||
code::editor_management::{CodeManager, CodeSource},
|
||||
pane_group::PaneGroup,
|
||||
};
|
||||
|
||||
use super::code_diff_pane_model::CodeDiffPaneModel;
|
||||
use super::{
|
||||
code_diff_pane_model::CodeDiffPaneModel, DetachType, PaneConfiguration, PaneContent, PaneEvent,
|
||||
PaneId, PaneView, ShareableLink, ShareableLinkError,
|
||||
DetachType, PaneConfiguration, PaneContent, PaneEvent, PaneId, PaneView, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::ai::blocklist::inline_action::code_diff_view::{CodeDiffView, CodeDiffViewEvent};
|
||||
use crate::app_state::{CodePaneSnapShot, CodePaneTabSnapshot, LeafContents};
|
||||
use crate::code::editor_management::{CodeManager, CodeSource};
|
||||
use crate::pane_group::PaneGroup;
|
||||
|
||||
pub struct CodeDiffPane {
|
||||
view: ViewHandle<PaneView<CodeDiffView>>,
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct CodeDiffPaneModel {}
|
||||
impl CodeDiffPaneModel {
|
||||
pub fn new(view: ViewHandle<CodeDiffView>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
// Subscribe to the CodeDiffView events
|
||||
ctx.subscribe_to_view(&view, |_model, event, ctx| ctx.emit(event.clone()));
|
||||
ctx.subscribe_to_view(&view, |_model, _, event, ctx| ctx.emit(event.clone()));
|
||||
|
||||
Self {}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
use galaxy_util::path::LineAndColumnArg;
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
app_state::{CodePaneSnapShot, CodePaneTabSnapshot, LeafContents},
|
||||
code::{
|
||||
editor_management::{CodeEditorStatus, CodeManager, CodeSource},
|
||||
view::{CodeView, CodeViewEvent},
|
||||
},
|
||||
pane_group::PaneGroup,
|
||||
};
|
||||
|
||||
use super::{
|
||||
DetachType, PaneConfiguration, PaneContent, PaneId, PaneView, ShareableLink, ShareableLinkError,
|
||||
};
|
||||
use crate::app_state::{CodePaneSnapShot, CodePaneTabSnapshot, LeafContents};
|
||||
use crate::code::editor_management::{CodeEditorStatus, CodeManager, CodeSource};
|
||||
use crate::code::view::{CodeView, CodeViewEvent};
|
||||
use crate::pane_group::PaneGroup;
|
||||
|
||||
pub struct CodePane {
|
||||
view: ViewHandle<PaneView<CodeView>>,
|
||||
@@ -66,13 +61,13 @@ impl PaneContent for CodePane {
|
||||
|
||||
fn pre_attach(&self, group: &PaneGroup, ctx: &mut ViewContext<PaneGroup>) -> bool {
|
||||
let source = self.file_view(ctx).as_ref(ctx).source().clone();
|
||||
let Some(path) = source.path() else {
|
||||
let Some(location) = source.location() else {
|
||||
return true;
|
||||
};
|
||||
let pane_group_id = ctx.view_id();
|
||||
|
||||
let existing_locator = CodeManager::handle(ctx).read(ctx, |manager, _ctx| {
|
||||
manager.get_locator_for_path_in_tab(pane_group_id, path.as_path())
|
||||
manager.get_locator_for_location_in_tab(pane_group_id, &location)
|
||||
});
|
||||
|
||||
// If the file is already open in the same tab, don't restore it, just focus it (and jump).
|
||||
@@ -83,7 +78,7 @@ impl PaneContent for CodePane {
|
||||
_ => None,
|
||||
};
|
||||
code_pane.file_view(ctx).update(ctx, |code_view, ctx| {
|
||||
code_view.open_or_focus_existing(Some(path.clone()), line_col, ctx);
|
||||
code_view.open_or_focus_existing(Some(location.clone()), line_col, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,13 +90,11 @@ impl PaneContent for CodePane {
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
self.file_view(ctx).update(ctx, |code_view, ctx| {
|
||||
if let Some(path) = source.path() {
|
||||
let line_col = match &source {
|
||||
CodeSource::Link { range_start, .. } => *range_start,
|
||||
_ => None,
|
||||
};
|
||||
code_view.open_or_focus_existing(Some(path), line_col, ctx);
|
||||
}
|
||||
let line_col = match &source {
|
||||
CodeSource::Link { range_start, .. } => *range_start,
|
||||
_ => None,
|
||||
};
|
||||
code_view.open_or_focus_existing(Some(location.clone()), line_col, ctx);
|
||||
});
|
||||
|
||||
true
|
||||
@@ -124,29 +117,30 @@ impl PaneContent for CodePane {
|
||||
CodeViewEvent::Pane(pane_event) => {
|
||||
pane_group.handle_pane_event(pane_id, pane_event, ctx)
|
||||
}
|
||||
CodeViewEvent::TabChanged { file_path, .. } => {
|
||||
if let Some(path) = file_path {
|
||||
CodeViewEvent::TabChanged { location, .. } => {
|
||||
if let Some(loc) = location {
|
||||
pane_group.active_file_model().update(ctx, |model, ctx| {
|
||||
model.active_file_changed(path.clone(), ctx);
|
||||
model.active_file_changed(loc.clone(), ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
CodeViewEvent::FileOpened { file_path, .. } => {
|
||||
CodeViewEvent::FileOpened { location, .. } => {
|
||||
pane_group.active_file_model().update(ctx, |model, ctx| {
|
||||
model.active_file_changed(file_path.clone(), ctx);
|
||||
model.active_file_changed(location.clone(), ctx);
|
||||
});
|
||||
|
||||
// Track the opened file in the OpenedFilesModel
|
||||
#[cfg(feature = "local_fs")]
|
||||
{
|
||||
use crate::code::opened_files::OpenedFilesModel;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
|
||||
if let Some(repo_path) =
|
||||
DetectedRepositories::as_ref(ctx).get_root_for_path(file_path)
|
||||
use crate::code::opened_files::OpenedFilesModel;
|
||||
|
||||
if let Some(repo_root) =
|
||||
DetectedRepositories::as_ref(ctx).get_root_for_path(location)
|
||||
{
|
||||
OpenedFilesModel::handle(ctx).update(ctx, |opened_files, ctx| {
|
||||
opened_files.file_opened(repo_path, file_path.clone(), ctx);
|
||||
opened_files.file_opened(repo_root, location, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -212,7 +206,9 @@ impl PaneContent for CodePane {
|
||||
|
||||
let tabs: Vec<CodePaneTabSnapshot> = (0..code_view_ref.tab_count())
|
||||
.filter_map(|i| code_view_ref.tab_at(i))
|
||||
.map(|tab| CodePaneTabSnapshot { path: tab.path() })
|
||||
.map(|tab| CodePaneTabSnapshot {
|
||||
path: tab.local_path(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let active_tab_index = code_view_ref.active_tab_index();
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
use warpui::{AppContext, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::ai::custom_model_router_editor::{CustomRouterEditorEvent, CustomRouterEditorView};
|
||||
use crate::ai::custom_model_routers::CustomModelRouter;
|
||||
use crate::app_state::LeafContents;
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
|
||||
pub struct CustomRouterEditorPane {
|
||||
view: ViewHandle<PaneView<CustomRouterEditorView>>,
|
||||
pane_configuration: ModelHandle<PaneConfiguration>,
|
||||
}
|
||||
|
||||
impl CustomRouterEditorPane {
|
||||
/// Create a new router editor pane.
|
||||
///
|
||||
/// Pass `existing = None` for creating a new router, or
|
||||
/// `existing = Some(router)` for editing an existing one.
|
||||
pub fn new<V: View>(existing: Option<CustomModelRouter>, ctx: &mut ViewContext<V>) -> Self {
|
||||
let editor_view =
|
||||
ctx.add_typed_action_view(|ctx| CustomRouterEditorView::new(existing, ctx));
|
||||
Self::from_view(editor_view, ctx)
|
||||
}
|
||||
|
||||
pub fn from_view(
|
||||
editor_view: ViewHandle<CustomRouterEditorView>,
|
||||
ctx: &mut AppContext,
|
||||
) -> Self {
|
||||
let pane_configuration = editor_view.as_ref(ctx).pane_configuration();
|
||||
|
||||
let view = ctx.add_typed_action_view(editor_view.window_id(ctx), |ctx| {
|
||||
let pane_id = PaneId::from_custom_router_editor_pane_ctx(ctx);
|
||||
PaneView::new(pane_id, editor_view, (), pane_configuration.clone(), ctx)
|
||||
});
|
||||
|
||||
Self {
|
||||
view,
|
||||
pane_configuration,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn custom_router_editor_view(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
) -> ViewHandle<CustomRouterEditorView> {
|
||||
self.view.as_ref(ctx).child(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
impl PaneContent for CustomRouterEditorPane {
|
||||
fn id(&self) -> PaneId {
|
||||
PaneId::from_custom_router_editor_pane_view(&self.view)
|
||||
}
|
||||
|
||||
fn attach(
|
||||
&self,
|
||||
_group: &PaneGroup,
|
||||
focus_handle: PaneFocusHandle,
|
||||
ctx: &mut ViewContext<PaneGroup>,
|
||||
) {
|
||||
self.view
|
||||
.update(ctx, |view, ctx| view.set_focus_handle(focus_handle, ctx));
|
||||
|
||||
let editor_view = self.custom_router_editor_view(ctx);
|
||||
let pane_id = self.id();
|
||||
|
||||
ctx.subscribe_to_view(&editor_view, move |pane_group, _, event, ctx| {
|
||||
let CustomRouterEditorEvent::Pane(pane_event) = event;
|
||||
pane_group.handle_pane_event(pane_id, pane_event, ctx);
|
||||
});
|
||||
ctx.subscribe_to_view(&self.view, move |group, _, event, ctx| {
|
||||
group.handle_pane_view_event(pane_id, event, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn detach(
|
||||
&self,
|
||||
_group: &PaneGroup,
|
||||
_detach_type: DetachType,
|
||||
ctx: &mut ViewContext<PaneGroup>,
|
||||
) {
|
||||
let editor_view = self.custom_router_editor_view(ctx);
|
||||
ctx.unsubscribe_to_view(&editor_view);
|
||||
ctx.unsubscribe_to_view(&self.view);
|
||||
}
|
||||
|
||||
fn snapshot(&self, _app: &AppContext) -> LeafContents {
|
||||
LeafContents::CustomRouterEditor
|
||||
}
|
||||
|
||||
fn has_application_focus(&self, ctx: &mut ViewContext<PaneGroup>) -> bool {
|
||||
self.view.is_self_or_child_focused(ctx)
|
||||
}
|
||||
|
||||
fn focus(&self, ctx: &mut ViewContext<PaneGroup>) {
|
||||
self.custom_router_editor_view(ctx)
|
||||
.update(ctx, |view, ctx| view.focus(ctx));
|
||||
}
|
||||
|
||||
fn shareable_link(
|
||||
&self,
|
||||
_ctx: &mut ViewContext<PaneGroup>,
|
||||
) -> Result<ShareableLink, ShareableLinkError> {
|
||||
Ok(ShareableLink::Base)
|
||||
}
|
||||
|
||||
fn pane_configuration(&self) -> ModelHandle<PaneConfiguration> {
|
||||
self.pane_configuration.clone()
|
||||
}
|
||||
|
||||
fn is_pane_being_dragged(&self, ctx: &AppContext) -> bool {
|
||||
self.view.as_ref(ctx).is_being_dragged()
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,19 @@
|
||||
use anyhow::Context;
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
app_state::{EnvVarCollectionPaneSnapshot, LeafContents},
|
||||
drive::items::WarpDriveItemId,
|
||||
env_vars::{
|
||||
manager::{EnvVarCollectionManager, EnvVarCollectionSource},
|
||||
view::env_var_collection::{EnvVarCollectionEvent, EnvVarCollectionView},
|
||||
EnvVarCollectionType,
|
||||
},
|
||||
pane_group::focus_state::PaneFocusHandle,
|
||||
server::ids::SyncId,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::app_state::{EnvVarCollectionPaneSnapshot, LeafContents};
|
||||
use crate::drive::items::WarpDriveItemId;
|
||||
use crate::env_vars::manager::{EnvVarCollectionManager, EnvVarCollectionSource};
|
||||
use crate::env_vars::view::env_var_collection::{EnvVarCollectionEvent, EnvVarCollectionView};
|
||||
use crate::env_vars::EnvVarCollectionType;
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
pub struct EnvVarCollectionPane {
|
||||
view: ViewHandle<PaneView<EnvVarCollectionView>>,
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
use galaxyui::{AppContext, ModelHandle, ViewContext, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
app_state::{EnvironmentManagementPaneSnapshot, LeafContents},
|
||||
pane_group::focus_state::PaneFocusHandle,
|
||||
settings_view::{
|
||||
environments_page::{EnvironmentsPage, EnvironmentsPageView},
|
||||
settings_page::{PaneEventWrapper, SettingsPageEvent},
|
||||
update_environment_form::GithubAuthRedirectTarget,
|
||||
},
|
||||
};
|
||||
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneEvent, PaneGroup, PaneId,
|
||||
ShareableLink, ShareableLinkError,
|
||||
DetachType, PaneConfiguration, PaneContent, PaneEvent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::ai::ambient_agents::github_auth_url::GithubAuthRedirectTarget;
|
||||
use crate::app_state::{EnvironmentManagementPaneSnapshot, LeafContents};
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::settings_view::environments_page::{EnvironmentsPage, EnvironmentsPageView};
|
||||
use crate::settings_view::settings_page::{PaneEventWrapper, SettingsPageEvent};
|
||||
|
||||
pub struct EnvironmentManagementPane {
|
||||
view: ViewHandle<PaneView<EnvironmentsPageView>>,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use warpui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::{
|
||||
ai::execution_profiles::editor::{
|
||||
ExecutionProfileEditorManager, ExecutionProfileEditorView, ExecutionProfileEditorViewEvent,
|
||||
},
|
||||
ai::execution_profiles::profiles::ClientProfileId,
|
||||
app_state::LeafContents,
|
||||
use crate::ai::execution_profiles::editor::{
|
||||
ExecutionProfileEditorManager, ExecutionProfileEditorView, ExecutionProfileEditorViewEvent,
|
||||
};
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
use crate::ai::execution_profiles::profiles::ClientProfileId;
|
||||
use crate::app_state::LeafContents;
|
||||
|
||||
pub struct ExecutionProfileEditorPane {
|
||||
view: ViewHandle<PaneView<ExecutionProfileEditorView>>,
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxyui::{AppContext, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use super::notebook_pane::subscribe_to_link_model;
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::app_state::{LeafContents, NotebookPaneSnapshot};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::code::editor_management::CodeSource;
|
||||
use crate::{
|
||||
app_state::{LeafContents, NotebookPaneSnapshot},
|
||||
notebooks::file::{FileNotebookEvent, FileNotebookView},
|
||||
terminal::model::session::Session,
|
||||
workflows::WorkflowSelectionSource,
|
||||
workspace::ActiveSession,
|
||||
};
|
||||
|
||||
use super::{
|
||||
notebook_pane::subscribe_to_link_model, view::PaneView, DetachType, PaneConfiguration,
|
||||
PaneContent, PaneGroup, PaneId, ShareableLink, ShareableLinkError,
|
||||
};
|
||||
use crate::notebooks::file::{FileNotebookEvent, FileNotebookView};
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::workflows::WorkflowSelectionSource;
|
||||
|
||||
pub struct FilePane {
|
||||
view: ViewHandle<PaneView<FileNotebookView>>,
|
||||
@@ -38,11 +37,11 @@ impl FilePane {
|
||||
}
|
||||
|
||||
/// Create a new file notebook pane for the given path and optional target session. If `path`
|
||||
/// is `None` or the target session is remote, the pane is created but left empty. If `path` is
|
||||
/// `Some`, but there's no target session, the pane is created using the next focused local
|
||||
/// session.
|
||||
/// is `None`, the pane is created but left empty. For local paths without a target session,
|
||||
/// the pane waits for a local session to become active. Remote paths are loaded directly
|
||||
/// via the remote server.
|
||||
pub fn new<V: View>(
|
||||
path: Option<PathBuf>,
|
||||
path: Option<LocalOrRemotePath>,
|
||||
target_session: Option<Arc<Session>>,
|
||||
#[cfg(feature = "local_fs")] code_source: Option<CodeSource>,
|
||||
ctx: &mut ViewContext<V>,
|
||||
@@ -53,20 +52,7 @@ impl FilePane {
|
||||
view.set_code_source(code_source);
|
||||
|
||||
if let Some(path) = path {
|
||||
if let Some(target_session) = target_session {
|
||||
// If the target session is Some, but non-local, do not fall back - the path is
|
||||
// remote, so we can't reliably use the fallback behavior.
|
||||
if target_session.is_local() {
|
||||
view.open_local(path, Some(target_session), ctx);
|
||||
}
|
||||
} else {
|
||||
// If the active session is None or remote, the pane will wait for a local
|
||||
// session to be activated.
|
||||
let session = ActiveSession::as_ref(ctx)
|
||||
.session(ctx.window_id())
|
||||
.filter(|session| session.is_local());
|
||||
view.open_local(path, session, ctx);
|
||||
}
|
||||
view.open(path, target_session, ctx);
|
||||
}
|
||||
|
||||
view
|
||||
@@ -150,6 +136,8 @@ impl PaneContent for FilePane {
|
||||
}
|
||||
|
||||
fn snapshot(&self, app: &AppContext) -> LeafContents {
|
||||
// Only persist local file paths in session snapshots; remote files
|
||||
// are not restorable across sessions.
|
||||
let path = self.file_view(app).as_ref(app).local_path();
|
||||
LeafContents::Notebook(NotebookPaneSnapshot::LocalFileNotebook { path })
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
use galaxyui::{AppContext, ModelHandle, View, ViewContext, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
app_state::LeafContents,
|
||||
pane_group::{
|
||||
pane::{get_started_view::GetStartedView, ShareableLink, ShareableLinkError},
|
||||
BackingView, PaneConfiguration, PaneContent, PaneGroup, PaneView,
|
||||
},
|
||||
};
|
||||
|
||||
use super::PaneId;
|
||||
use crate::app_state::LeafContents;
|
||||
use crate::pane_group::pane::get_started_view::GetStartedView;
|
||||
use crate::pane_group::pane::{ShareableLink, ShareableLinkError};
|
||||
use crate::pane_group::{BackingView, PaneConfiguration, PaneContent, PaneGroup, PaneView};
|
||||
|
||||
pub struct GetStartedPane {
|
||||
view: ViewHandle<PaneView<GetStartedView>>,
|
||||
|
||||
@@ -1,37 +1,31 @@
|
||||
use galaxy_core::ui::{self, appearance::Appearance, color::blend::Blend as _};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::color::blend::Blend as _;
|
||||
use galaxy_core::ui::{self};
|
||||
use galaxyui::elements::{
|
||||
Align, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Icon,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement as _, Radius,
|
||||
};
|
||||
use galaxyui::keymap::EditableBinding;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment};
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Align, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Icon,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement as _, Radius,
|
||||
},
|
||||
keymap::EditableBinding,
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
button::{ButtonVariant, TextAndIcon, TextAndIconAlignment},
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity as _, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use crate::{
|
||||
coding_entrypoints::{
|
||||
clone_repo_view::{CloneRepoEvent, CloneRepoView},
|
||||
create_project_view::{CreateProjectEvent, CreateProjectView},
|
||||
project_buttons::{ProjectButtons, ProjectButtonsEvent},
|
||||
},
|
||||
pane_group::{
|
||||
focus_state::PaneFocusHandle, pane::view, BackingView, PaneConfiguration, PaneEvent,
|
||||
},
|
||||
send_telemetry_from_ctx,
|
||||
terminal::TerminalView,
|
||||
util::bindings::{keybinding_name_to_display_string, BindingGroup, CustomAction},
|
||||
view_components::DismissibleToast,
|
||||
workspace::ToastStack,
|
||||
workspace::{Workspace, WorkspaceAction},
|
||||
TelemetryEvent,
|
||||
};
|
||||
use crate::coding_entrypoints::clone_repo_view::{CloneRepoEvent, CloneRepoView};
|
||||
use crate::coding_entrypoints::create_project_view::{CreateProjectEvent, CreateProjectView};
|
||||
use crate::coding_entrypoints::project_buttons::{ProjectButtons, ProjectButtonsEvent};
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::pane::view;
|
||||
use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent};
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::util::bindings::{keybinding_name_to_display_string, BindingGroup, CustomAction};
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::{ToastStack, Workspace, WorkspaceAction};
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use galaxyui::keymap::macros::*;
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
use std::{collections::HashMap, ffi::OsString, path::PathBuf, sync::Arc};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_managed_secrets::ManagedSecretValue;
|
||||
use shell_words::quote as shell_quote;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ai::{
|
||||
agent_sdk::{
|
||||
driver::AgentDriverError, task_env_vars, validate_cli_installed, ClaudeHarness,
|
||||
ThirdPartyHarness,
|
||||
},
|
||||
ambient_agents::{task::HarnessConfig, AgentConfigSnapshot, AmbientAgentTaskId},
|
||||
use crate::ai::agent_sdk::driver::harness::claude_code::prepare_claude_environment_config;
|
||||
use crate::ai::agent_sdk::driver::harness::{
|
||||
harness_kind, harness_model_env_vars, remove_claude_externally_managed_listener_env_vars,
|
||||
HarnessKind,
|
||||
};
|
||||
use crate::ai::agent_sdk::driver::AgentDriverError;
|
||||
use crate::ai::agent_sdk::{task_env_vars, validate_cli_installed};
|
||||
use crate::ai::ambient_agents::task::{
|
||||
normalize_orchestrator_agent_name, HarnessConfig, HarnessModelConfig,
|
||||
};
|
||||
use crate::ai::ambient_agents::{AgentConfigSnapshot, AmbientAgentTaskId};
|
||||
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
|
||||
use crate::server::server_api::ai::AIClient;
|
||||
use crate::terminal::cli_agent_sessions::plugin_manager::plugin_manager_for;
|
||||
use crate::terminal::cli_agent_sessions::plugin_manager::{
|
||||
plugin_manager_for, CliAgentPluginManager,
|
||||
};
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -24,6 +34,38 @@ pub(super) struct PreparedLocalHarnessLaunch {
|
||||
pub task_id: AmbientAgentTaskId,
|
||||
}
|
||||
|
||||
async fn ensure_local_claude_child_plugins(manager: &dyn CliAgentPluginManager) {
|
||||
// Most environments should follow the standard Claude plugin setup path so
|
||||
// hidden local children retain the same notification support as regular
|
||||
// Claude sessions. The exception is local marketplace override testing:
|
||||
// installing/updating the notification plugin re-adds the public
|
||||
// claude-code-warp marketplace, which clobbers a developer's local
|
||||
// claude-code-warp-internal override used for oz-harness-support testing.
|
||||
if !manager.has_local_marketplace_override() {
|
||||
let plugin_result = if manager.needs_update() {
|
||||
manager.update().await
|
||||
} else if !manager.is_installed() {
|
||||
manager.install().await
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if let Err(error) = plugin_result {
|
||||
log::warn!("Claude notification plugin setup failed for child harness: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
let platform_plugin_result = if manager.platform_plugin_needs_update() {
|
||||
manager.update_platform_plugin().await
|
||||
} else if !manager.is_platform_plugin_installed() {
|
||||
manager.install_platform_plugin().await
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if let Err(error) = platform_plugin_result {
|
||||
log::warn!("Claude platform plugin setup failed for child harness: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn normalize_local_child_harness(harness_type: &str) -> Option<Harness> {
|
||||
Harness::parse_local_child_harness(harness_type)
|
||||
}
|
||||
@@ -42,6 +84,39 @@ pub(super) fn validate_local_harness_shell(shell_type: Option<ShellType>) -> Res
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_CLAUDE_CHILD_ORCHESTRATION_INSTRUCTIONS: &str = r#"You are a local Claude Code child agent launched by a lead agent in Warp.
|
||||
|
||||
Coordinate with the lead agent through the Oz CLI messaging environment:
|
||||
- Your run id is in OZ_RUN_ID.
|
||||
- The lead agent id is in OZ_PARENT_RUN_ID.
|
||||
- The Oz CLI command is in OZ_CLI.
|
||||
|
||||
If OZ_CLI, OZ_RUN_ID, or OZ_PARENT_RUN_ID is missing, report that blocker in your final response.
|
||||
Do not use Claude Code Agent or SendMessage tools to contact the lead agent; use the Oz CLI commands below.
|
||||
Do not ask to inspect help before messaging. The command shapes below are complete.
|
||||
|
||||
Send a message to the lead agent at start, when blocked, and when complete:
|
||||
"$OZ_CLI" run message send --sender-run-id "$OZ_RUN_ID" --to "$OZ_PARENT_RUN_ID" --subject "<subject>" --body "<body>"
|
||||
All four send arguments are required: --sender-run-id "$OZ_RUN_ID", --to "$OZ_PARENT_RUN_ID", --subject, and --body.
|
||||
Do not pass "$OZ_PARENT_RUN_ID" as a positional argument to send.
|
||||
|
||||
After sending a message, and before ending or standing by, check recent inbox messages:
|
||||
"$OZ_CLI" run message list "$OZ_RUN_ID" --limit 25
|
||||
|
||||
The plugin may already have read incoming messages while staging them, so do not rely on --unread.
|
||||
If recent messages from "$OZ_PARENT_RUN_ID" are present and you have not handled them, read them and use the latest lead-agent mailbox message as task context:
|
||||
"$OZ_CLI" run message read "$MESSAGE_ID"
|
||||
|
||||
If a surfaced message requires acknowledgement, mark it delivered:
|
||||
"$OZ_CLI" run message mark-delivered "$MESSAGE_ID"
|
||||
"#;
|
||||
|
||||
pub(super) fn local_claude_child_prompt(task_prompt: &str) -> String {
|
||||
format!(
|
||||
"{LOCAL_CLAUDE_CHILD_ORCHESTRATION_INSTRUCTIONS}\nTask:\n{}",
|
||||
task_prompt
|
||||
)
|
||||
}
|
||||
pub(super) fn build_local_claude_child_command(prompt: &str) -> String {
|
||||
let session_id = Uuid::new_v4();
|
||||
let quoted_prompt = shell_quote(prompt);
|
||||
@@ -56,25 +131,48 @@ pub(super) fn build_local_opencode_child_command(prompt: &str) -> String {
|
||||
let quoted_prompt = shell_quote(prompt);
|
||||
format!("opencode --prompt {quoted_prompt}")
|
||||
}
|
||||
pub(super) fn build_local_codex_child_command(prompt: &str) -> String {
|
||||
let quoted_prompt = shell_quote(prompt);
|
||||
format!("codex --dangerously-bypass-approvals-and-sandbox {quoted_prompt}")
|
||||
}
|
||||
|
||||
fn local_child_task_config(harness: Harness) -> Option<AgentConfigSnapshot> {
|
||||
pub(super) fn local_child_task_config(
|
||||
harness: Harness,
|
||||
agent_name: Option<String>,
|
||||
) -> Option<AgentConfigSnapshot> {
|
||||
let agent_name = agent_name
|
||||
.as_deref()
|
||||
.and_then(normalize_orchestrator_agent_name);
|
||||
match harness {
|
||||
Harness::Oz | Harness::OpenCode | Harness::Gemini | Harness::Unknown => None,
|
||||
Harness::Claude => Some(AgentConfigSnapshot {
|
||||
harness: Some(HarnessConfig::from_harness_type(harness)),
|
||||
..Default::default()
|
||||
}),
|
||||
Harness::Oz | Harness::Unknown => None,
|
||||
Harness::Claude | Harness::OpenCode | Harness::Gemini | Harness::Codex => {
|
||||
Some(AgentConfigSnapshot {
|
||||
name: agent_name,
|
||||
harness: Some(HarnessConfig::from_harness_type(harness)),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn prepare_local_harness_child_launch(
|
||||
prompt: String,
|
||||
harness_type: String,
|
||||
model_id: Option<String>,
|
||||
parent_run_id: Option<String>,
|
||||
agent_name: Option<String>,
|
||||
shell_type: Option<ShellType>,
|
||||
startup_directory: Option<PathBuf>,
|
||||
ai_client: Arc<dyn AIClient>,
|
||||
) -> Result<PreparedLocalHarnessLaunch, String> {
|
||||
let harness_model_config =
|
||||
model_id
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(|model_id| HarnessModelConfig {
|
||||
model_id,
|
||||
reasoning_level: None,
|
||||
});
|
||||
let Some(harness) = normalize_local_child_harness(&harness_type) else {
|
||||
let harness_name = harness_type.trim();
|
||||
return Err(if harness_name.is_empty() {
|
||||
@@ -83,6 +181,9 @@ pub(super) async fn prepare_local_harness_child_launch(
|
||||
format!("Unsupported local child harness '{harness_name}'.")
|
||||
});
|
||||
};
|
||||
if let Some(message) = local_harness_product_disabled_message(harness) {
|
||||
return Err(message.to_string());
|
||||
}
|
||||
validate_local_harness_shell(shell_type)?;
|
||||
let command = match harness {
|
||||
Harness::Oz => unreachable!("normalize_local_child_harness filters out Oz"),
|
||||
@@ -91,32 +192,46 @@ pub(super) async fn prepare_local_harness_child_launch(
|
||||
let working_dir = startup_directory
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.ok_or_else(|| {
|
||||
"Could not resolve a working directory for the local Claude child.".to_string()
|
||||
format!(
|
||||
"Could not resolve a working directory for the local {} child.",
|
||||
harness.display_name()
|
||||
)
|
||||
})?;
|
||||
let claude_harness = ClaudeHarness;
|
||||
claude_harness
|
||||
let HarnessKind::ThirdParty(third_party_harness) =
|
||||
harness_kind(harness).map_err(|error: AgentDriverError| error.to_string())?
|
||||
else {
|
||||
unreachable!("Claude resolves to a third-party harness")
|
||||
};
|
||||
third_party_harness
|
||||
.validate()
|
||||
.map_err(|error: AgentDriverError| error.to_string())?;
|
||||
// Local child harness panes inherit the user's existing local Claude
|
||||
// auth/session state. We still prepare Claude's config files here,
|
||||
// Local child harness panes inherit the user's existing local
|
||||
// auth/session state. We still prepare harness config files here,
|
||||
// but there are no Warp-managed secrets to materialize into the
|
||||
// hidden child pane.
|
||||
let managed_secrets: HashMap<String, ManagedSecretValue> = HashMap::new();
|
||||
claude_harness
|
||||
.prepare_environment_config(&working_dir, None, &managed_secrets)
|
||||
.map_err(|error: AgentDriverError| error.to_string())?;
|
||||
if let Some(manager) = plugin_manager_for(claude_harness.cli_agent()) {
|
||||
if let Err(error) = manager.install().await {
|
||||
log::warn!("Claude plugin installation failed for child harness: {error}");
|
||||
}
|
||||
if let Err(error) = manager.install_platform_plugin().await {
|
||||
log::warn!(
|
||||
"Claude platform plugin installation failed for child harness: {error}"
|
||||
);
|
||||
}
|
||||
prepare_claude_environment_config(&working_dir, &HashMap::new())
|
||||
.map_err(|error| error.to_string())?;
|
||||
if let Some(manager) = plugin_manager_for(third_party_harness.cli_agent()) {
|
||||
ensure_local_claude_child_plugins(manager.as_ref()).await;
|
||||
}
|
||||
|
||||
build_local_claude_child_command(&prompt)
|
||||
build_local_claude_child_command(&local_claude_child_prompt(&prompt))
|
||||
}
|
||||
Harness::Codex => {
|
||||
let HarnessKind::ThirdParty(third_party_harness) =
|
||||
harness_kind(harness).map_err(|error: AgentDriverError| error.to_string())?
|
||||
else {
|
||||
unreachable!("Codex resolves to a third-party harness")
|
||||
};
|
||||
third_party_harness
|
||||
.validate()
|
||||
.map_err(|error: AgentDriverError| error.to_string())?;
|
||||
|
||||
// Local Codex child panes must rely on the user's existing local
|
||||
// auth/session state. Do not run the shared Codex environment prep
|
||||
// here: it can seed OPENAI_API_KEY into ~/.codex/auth.json and
|
||||
// rewrite ~/.codex/config.toml for the whole machine.
|
||||
build_local_codex_child_command(&prompt)
|
||||
}
|
||||
Harness::OpenCode => {
|
||||
validate_cli_installed("opencode", Some("https://opencode.ai/docs"))
|
||||
@@ -131,7 +246,7 @@ pub(super) async fn prepare_local_harness_child_launch(
|
||||
prompt.clone(),
|
||||
None,
|
||||
parent_run_id.clone(),
|
||||
local_child_task_config(harness),
|
||||
local_child_task_config(harness, agent_name),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
@@ -141,9 +256,25 @@ pub(super) async fn prepare_local_harness_child_launch(
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut env_vars = task_env_vars(Some(&task_id), parent_run_id.as_deref(), harness);
|
||||
if harness == Harness::Claude {
|
||||
// Local Claude child panes are launched directly in hidden terminals,
|
||||
// not through AgentDriver's ClaudeHarnessRunner. Let the Claude plugin
|
||||
// manage its own listener instead of waiting for a non-existent
|
||||
// external MessageBridge.
|
||||
remove_claude_externally_managed_listener_env_vars(&mut env_vars);
|
||||
}
|
||||
// Propagate the selected model to Claude Code via ANTHROPIC_MODEL.
|
||||
// Codex local children never receive a model override — the UI
|
||||
// ensures model_id is empty for local Codex.
|
||||
env_vars.extend(harness_model_env_vars(
|
||||
harness,
|
||||
harness_model_config.as_ref(),
|
||||
));
|
||||
|
||||
Ok(PreparedLocalHarnessLaunch {
|
||||
command,
|
||||
env_vars: task_env_vars(Some(&task_id), parent_run_id.as_deref(), harness),
|
||||
env_vars,
|
||||
run_id: task_id.to_string(),
|
||||
task_id,
|
||||
})
|
||||
|
||||
@@ -1,11 +1,87 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
|
||||
use super::{
|
||||
build_local_claude_child_command, build_local_opencode_child_command,
|
||||
normalize_local_child_harness, validate_local_harness_shell,
|
||||
build_local_claude_child_command, build_local_codex_child_command,
|
||||
build_local_opencode_child_command, local_child_task_config, local_claude_child_prompt,
|
||||
normalize_local_child_harness, prepare_local_harness_child_launch,
|
||||
validate_local_harness_shell,
|
||||
};
|
||||
use crate::ai::agent_sdk::driver::OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV;
|
||||
use crate::ai::ambient_agents::task::{normalize_orchestrator_agent_name, HarnessConfig};
|
||||
use crate::ai::local_harness_setup::LOCAL_CODEX_HARNESS_DISABLED_MESSAGE;
|
||||
use crate::server::server_api::ai::MockAIClient;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
struct EnvVarGuard {
|
||||
key: &'static str,
|
||||
original: Option<OsString>,
|
||||
}
|
||||
#[test]
|
||||
fn local_claude_child_prompt_includes_oz_cli_messaging_instructions() {
|
||||
let prompt = local_claude_child_prompt("List files");
|
||||
|
||||
assert!(prompt.contains("OZ_CLI"));
|
||||
assert!(prompt.contains("OZ_RUN_ID"));
|
||||
assert!(prompt.contains("OZ_PARENT_RUN_ID"));
|
||||
assert!(prompt.contains("run message send --sender-run-id"));
|
||||
assert!(prompt.contains("All four send arguments are required"));
|
||||
assert!(prompt.contains("Do not pass \"$OZ_PARENT_RUN_ID\" as a positional argument to send"));
|
||||
assert!(prompt.contains("run message list \"$OZ_RUN_ID\" --limit 25"));
|
||||
assert!(prompt.contains("do not rely on --unread"));
|
||||
assert!(!prompt.contains("--unread --limit"));
|
||||
assert!(prompt.contains("Do not use Claude Code Agent or SendMessage tools"));
|
||||
assert!(prompt.ends_with("Task:\nList files"));
|
||||
}
|
||||
|
||||
impl EnvVarGuard {
|
||||
fn set(key: &'static str, value: impl Into<OsString>) -> Self {
|
||||
let original = std::env::var_os(key);
|
||||
std::env::set_var(key, value.into());
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvVarGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(original) = &self.original {
|
||||
std::env::set_var(self.key, original);
|
||||
} else {
|
||||
std::env::remove_var(self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_fake_cli(bin_dir: &std::path::Path, name: &str) {
|
||||
let executable_name = if cfg!(windows) {
|
||||
format!("{name}.cmd")
|
||||
} else {
|
||||
name.to_string()
|
||||
};
|
||||
let executable_path = bin_dir.join(executable_name);
|
||||
let script = if cfg!(windows) {
|
||||
"@echo off\r\n"
|
||||
} else {
|
||||
"#!/bin/sh\n"
|
||||
};
|
||||
|
||||
fs::write(&executable_path, script).unwrap();
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mut permissions = fs::metadata(&executable_path).unwrap().permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&executable_path, permissions).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_local_child_harness_accepts_supported_aliases() {
|
||||
assert_eq!(
|
||||
@@ -32,12 +108,13 @@ fn normalize_local_child_harness_accepts_supported_aliases() {
|
||||
normalize_local_child_harness("open_code"),
|
||||
Some(Harness::OpenCode)
|
||||
);
|
||||
assert_eq!(normalize_local_child_harness("codex"), Some(Harness::Codex));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_local_child_harness_rejects_unsupported_values() {
|
||||
assert_eq!(normalize_local_child_harness("oz"), None);
|
||||
assert_eq!(normalize_local_child_harness("codex"), None);
|
||||
assert_eq!(normalize_local_child_harness("gemini"), None);
|
||||
assert_eq!(normalize_local_child_harness(""), None);
|
||||
}
|
||||
|
||||
@@ -81,3 +158,268 @@ fn build_local_opencode_child_command_quotes_the_prompt() {
|
||||
"opencode --prompt 'hello world'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_local_codex_child_command_quotes_the_prompt() {
|
||||
assert_eq!(
|
||||
build_local_codex_child_command("hello world"),
|
||||
"codex --dangerously-bypass-approvals-and-sandbox 'hello world'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_child_task_config_records_supported_third_party_harnesses() {
|
||||
for harness in [Harness::Claude, Harness::OpenCode, Harness::Codex] {
|
||||
assert_eq!(
|
||||
local_child_task_config(harness, None),
|
||||
Some(crate::ai::ambient_agents::task::AgentConfigSnapshot {
|
||||
harness: Some(HarnessConfig::from_harness_type(harness)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_child_task_config_stamps_orchestrator_name() {
|
||||
for harness in [Harness::Claude, Harness::OpenCode, Harness::Codex] {
|
||||
assert_eq!(
|
||||
local_child_task_config(harness, Some("frontend-tests".to_string())),
|
||||
Some(crate::ai::ambient_agents::task::AgentConfigSnapshot {
|
||||
name: Some("frontend-tests".to_string()),
|
||||
harness: Some(HarnessConfig::from_harness_type(harness)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_child_task_config_trims_whitespace_only_name() {
|
||||
assert_eq!(
|
||||
local_child_task_config(Harness::Claude, Some(" frontend-tests ".to_string())),
|
||||
Some(crate::ai::ambient_agents::task::AgentConfigSnapshot {
|
||||
name: Some("frontend-tests".to_string()),
|
||||
harness: Some(HarnessConfig::from_harness_type(Harness::Claude)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
local_child_task_config(Harness::Claude, Some(" ".to_string())),
|
||||
Some(crate::ai::ambient_agents::task::AgentConfigSnapshot {
|
||||
name: None,
|
||||
harness: Some(HarnessConfig::from_harness_type(Harness::Claude)),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_child_task_config_returns_none_for_oz_and_unknown() {
|
||||
assert!(local_child_task_config(Harness::Oz, Some("name".to_string())).is_none());
|
||||
assert!(local_child_task_config(Harness::Unknown, Some("name".to_string())).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_orchestrator_agent_name_trims_and_drops_empty() {
|
||||
assert_eq!(
|
||||
normalize_orchestrator_agent_name("frontend-tests"),
|
||||
Some("frontend-tests".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_orchestrator_agent_name(" frontend-tests "),
|
||||
Some("frontend-tests".to_string())
|
||||
);
|
||||
assert_eq!(normalize_orchestrator_agent_name(""), None);
|
||||
assert_eq!(normalize_orchestrator_agent_name(" "), None);
|
||||
assert_eq!(normalize_orchestrator_agent_name("\t\n "), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn prepare_local_codex_child_launch_rejects_without_rewriting_global_codex_state() {
|
||||
let fake_home = TempDir::new().unwrap();
|
||||
let fake_bin_dir = TempDir::new().unwrap();
|
||||
let working_dir = fake_home.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
write_fake_cli(fake_bin_dir.path(), "codex");
|
||||
|
||||
let _home = EnvVarGuard::set("HOME", fake_home.path().as_os_str().to_os_string());
|
||||
let _path = EnvVarGuard::set("PATH", fake_bin_dir.path().as_os_str().to_os_string());
|
||||
|
||||
let mut ai_client = MockAIClient::new();
|
||||
ai_client.expect_create_agent_task().times(0);
|
||||
|
||||
let result = prepare_local_harness_child_launch(
|
||||
"hello world".to_string(),
|
||||
"codex".to_string(),
|
||||
None,
|
||||
Some("parent-run".to_string()),
|
||||
None,
|
||||
Some(ShellType::Zsh),
|
||||
Some(working_dir),
|
||||
Arc::new(ai_client),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => panic!("disabled local codex should be rejected"),
|
||||
Err(err) => assert_eq!(err, LOCAL_CODEX_HARNESS_DISABLED_MESSAGE),
|
||||
}
|
||||
assert!(!fake_home.path().join(".codex").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn prepare_local_codex_child_launch_succeeds_when_testing_flag_is_enabled() {
|
||||
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
|
||||
let fake_home = TempDir::new().unwrap();
|
||||
let fake_bin_dir = TempDir::new().unwrap();
|
||||
let working_dir = fake_home.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
write_fake_cli(fake_bin_dir.path(), "codex");
|
||||
|
||||
let _home = EnvVarGuard::set("HOME", fake_home.path().as_os_str().to_os_string());
|
||||
let _path = EnvVarGuard::set("PATH", fake_bin_dir.path().as_os_str().to_os_string());
|
||||
|
||||
let mut ai_client = MockAIClient::new();
|
||||
ai_client
|
||||
.expect_create_agent_task()
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Ok("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()));
|
||||
|
||||
let prepared = prepare_local_harness_child_launch(
|
||||
"hello world".to_string(),
|
||||
"codex".to_string(),
|
||||
Some("ignored-model".to_string()),
|
||||
Some("parent-run".to_string()),
|
||||
None,
|
||||
Some(ShellType::Zsh),
|
||||
Some(working_dir),
|
||||
Arc::new(ai_client),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.command,
|
||||
"codex --dangerously-bypass-approvals-and-sandbox 'hello world'"
|
||||
);
|
||||
assert!(!prepared
|
||||
.env_vars
|
||||
.contains_key(&OsString::from("ANTHROPIC_MODEL")));
|
||||
assert_eq!(prepared.run_id, "550e8400-e29b-41d4-a716-446655440000");
|
||||
assert!(!fake_home.path().join(".codex").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn prepare_local_claude_child_merges_anthropic_model_env_var() {
|
||||
let fake_home = TempDir::new().unwrap();
|
||||
let fake_bin_dir = TempDir::new().unwrap();
|
||||
let working_dir = fake_home.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
write_fake_cli(fake_bin_dir.path(), "claude");
|
||||
|
||||
let _home = EnvVarGuard::set("HOME", fake_home.path().as_os_str().to_os_string());
|
||||
let _claude_home = EnvVarGuard::set(
|
||||
"CLAUDE_HOME",
|
||||
fake_home.path().join(".claude").as_os_str().to_os_string(),
|
||||
);
|
||||
let _path = EnvVarGuard::set("PATH", fake_bin_dir.path().as_os_str().to_os_string());
|
||||
|
||||
let mut ai_client = MockAIClient::new();
|
||||
ai_client
|
||||
.expect_create_agent_task()
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Ok("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()));
|
||||
|
||||
let prepared = prepare_local_harness_child_launch(
|
||||
"hello world".to_string(),
|
||||
"claude".to_string(),
|
||||
Some("opus".to_string()),
|
||||
Some("parent-run".to_string()),
|
||||
None,
|
||||
Some(ShellType::Zsh),
|
||||
Some(working_dir),
|
||||
Arc::new(ai_client),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.env_vars.get(&OsString::from("ANTHROPIC_MODEL")),
|
||||
Some(&OsString::from("opus"))
|
||||
);
|
||||
assert!(!prepared
|
||||
.env_vars
|
||||
.contains_key(&OsString::from(OZ_MESSAGE_LISTENER_MANAGED_EXTERNALLY_ENV)));
|
||||
assert!(!prepared
|
||||
.env_vars
|
||||
.contains_key(&OsString::from("OZ_PARENT_LISTENER_MANAGED_EXTERNALLY")));
|
||||
assert!(prepared
|
||||
.command
|
||||
.contains("run message send --sender-run-id"));
|
||||
assert!(prepared.command.contains("OZ_PARENT_RUN_ID"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn prepare_local_claude_child_no_anthropic_model_when_empty() {
|
||||
let fake_home = TempDir::new().unwrap();
|
||||
let fake_bin_dir = TempDir::new().unwrap();
|
||||
let working_dir = fake_home.path().join("workspace");
|
||||
fs::create_dir_all(&working_dir).unwrap();
|
||||
write_fake_cli(fake_bin_dir.path(), "claude");
|
||||
|
||||
let _home = EnvVarGuard::set("HOME", fake_home.path().as_os_str().to_os_string());
|
||||
let _claude_home = EnvVarGuard::set(
|
||||
"CLAUDE_HOME",
|
||||
fake_home.path().join(".claude").as_os_str().to_os_string(),
|
||||
);
|
||||
let _path = EnvVarGuard::set("PATH", fake_bin_dir.path().as_os_str().to_os_string());
|
||||
|
||||
let mut ai_client = MockAIClient::new();
|
||||
ai_client
|
||||
.expect_create_agent_task()
|
||||
.times(1)
|
||||
.returning(|_, _, _, _| Ok("550e8400-e29b-41d4-a716-446655440000".parse().unwrap()));
|
||||
|
||||
let prepared = prepare_local_harness_child_launch(
|
||||
"hello world".to_string(),
|
||||
"claude".to_string(),
|
||||
None,
|
||||
Some("parent-run".to_string()),
|
||||
None,
|
||||
Some(ShellType::Zsh),
|
||||
Some(working_dir),
|
||||
Arc::new(ai_client),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!prepared
|
||||
.env_vars
|
||||
.contains_key(&OsString::from("ANTHROPIC_MODEL")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_local_harness_child_launch_rejects_disabled_codex_before_shell_validation() {
|
||||
let ai_client = Arc::new(MockAIClient::new());
|
||||
let result = prepare_local_harness_child_launch(
|
||||
"hello world".to_string(),
|
||||
"codex".to_string(),
|
||||
None,
|
||||
Some("parent-run".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
ai_client,
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => panic!("disabled local codex should be rejected"),
|
||||
Err(err) => assert_eq!(err, LOCAL_CODEX_HARNESS_DISABLED_MESSAGE),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub(super) mod ai_fact_pane;
|
||||
pub(super) mod code_diff_pane;
|
||||
pub(super) mod code_diff_pane_model;
|
||||
pub(super) mod code_pane;
|
||||
pub(super) mod custom_router_editor_pane;
|
||||
pub(super) mod env_var_collection_pane;
|
||||
pub(crate) mod environment_management_pane;
|
||||
pub(super) mod execution_profile_editor_pane;
|
||||
@@ -26,55 +27,49 @@ pub(super) mod notebook_pane;
|
||||
pub(super) mod settings_pane;
|
||||
pub(super) mod terminal_pane;
|
||||
pub mod view;
|
||||
pub(super) mod welcome_pane;
|
||||
pub(crate) mod welcome_view;
|
||||
pub mod workflow_pane;
|
||||
|
||||
use std::{any::Any, fmt::Display};
|
||||
use std::any::Any;
|
||||
use std::fmt::Display;
|
||||
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::pane::get_started_view::GetStartedView;
|
||||
use crate::view_components::action_button::ActionButton;
|
||||
use crate::{
|
||||
ai::execution_profiles::editor::ExecutionProfileEditorView,
|
||||
ai::{
|
||||
ai_document_view::AIDocumentView, blocklist::inline_action::code_diff_view::CodeDiffView,
|
||||
facts::AIFactView,
|
||||
},
|
||||
code::view::CodeView,
|
||||
drive::sharing::ShareableObject,
|
||||
env_vars::view::env_var_collection::EnvVarCollectionView,
|
||||
menu::MenuItem,
|
||||
notebooks::{file::FileNotebookView, notebook::NotebookView},
|
||||
server::network_log_view::NetworkLogView,
|
||||
server::telemetry::SharingDialogSource,
|
||||
settings::PaneSettings,
|
||||
settings_view::{environments_page::EnvironmentsPageView, SettingsView},
|
||||
terminal::{available_shells::AvailableShell, TerminalView},
|
||||
workflows::workflow_view::WorkflowView,
|
||||
};
|
||||
use galaxy_core::HostId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
use galaxy_util::remote_path::RemotePath;
|
||||
use galaxyui::elements::{DispatchEventResult, EventHandler, MouseInBehavior};
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::{
|
||||
elements::{DispatchEventResult, EventHandler, MouseInBehavior},
|
||||
presenter::ChildView,
|
||||
Action, AppContext, Element, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity,
|
||||
View, ViewContext, ViewHandle, WeakModelHandle,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
pub use self::view::PaneHeaderAction;
|
||||
pub use self::view::PaneHeaderCustomAction;
|
||||
pub use self::view::PaneView;
|
||||
pub use self::view::PaneViewEvent;
|
||||
|
||||
use welcome_view::WelcomeView;
|
||||
|
||||
pub use self::view::{PaneHeaderAction, PaneHeaderCustomAction, PaneView, PaneViewEvent};
|
||||
use super::{ActivationReason, LeafContents, PaneGroup, PaneGroupAction};
|
||||
use crate::ai::ai_document_view::AIDocumentView;
|
||||
use crate::ai::blocklist::inline_action::code_diff_view::CodeDiffView;
|
||||
use crate::ai::execution_profiles::editor::ExecutionProfileEditorView;
|
||||
use crate::ai::facts::AIFactView;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::code::buffer_location::LocalOrRemotePath;
|
||||
use crate::code::view::CodeView;
|
||||
use crate::drive::sharing::ShareableObject;
|
||||
use crate::env_vars::view::env_var_collection::EnvVarCollectionView;
|
||||
use crate::menu::MenuItem;
|
||||
use crate::notebooks::file::FileNotebookView;
|
||||
use crate::notebooks::notebook::NotebookView;
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::pane::get_started_view::GetStartedView;
|
||||
use crate::server::network_log_view::NetworkLogView;
|
||||
use crate::server::telemetry::SharingDialogSource;
|
||||
use crate::settings::PaneSettings;
|
||||
use crate::settings_view::environments_page::EnvironmentsPageView;
|
||||
use crate::settings_view::SettingsView;
|
||||
use crate::terminal::available_shells::AvailableShell;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::view_components::action_button::ActionButton;
|
||||
use crate::workflows::workflow_view::WorkflowView;
|
||||
|
||||
pub(super) fn init(app: &mut AppContext) {
|
||||
self::view::init(app);
|
||||
welcome_view::init(app);
|
||||
get_started_view::init(app);
|
||||
}
|
||||
|
||||
@@ -147,10 +142,10 @@ pub(crate) enum IPaneType {
|
||||
Settings,
|
||||
AIFact,
|
||||
AIDocument,
|
||||
CustomRouterEditor,
|
||||
ExecutionProfileEditor,
|
||||
GetStarted,
|
||||
NetworkLog,
|
||||
Welcome,
|
||||
DeferredPlaceholder,
|
||||
/// A pane type only for tests.
|
||||
#[cfg(test)]
|
||||
@@ -171,10 +166,10 @@ impl Display for IPaneType {
|
||||
IPaneType::Settings => write!(f, "Settings"),
|
||||
IPaneType::AIFact => write!(f, "AI Fact"),
|
||||
IPaneType::AIDocument => write!(f, "AI Document"),
|
||||
IPaneType::CustomRouterEditor => write!(f, "Custom Router Editor"),
|
||||
IPaneType::ExecutionProfileEditor => write!(f, "Execution Profile Editor"),
|
||||
IPaneType::GetStarted => write!(f, "GetStarted"),
|
||||
IPaneType::NetworkLog => write!(f, "Network Log"),
|
||||
IPaneType::Welcome => write!(f, "Welcome"),
|
||||
IPaneType::DeferredPlaceholder => write!(f, "Placeholder"),
|
||||
#[cfg(test)]
|
||||
IPaneType::Dummy => write!(f, "Dummy"),
|
||||
@@ -256,6 +251,13 @@ impl PaneId {
|
||||
Self::new_from_ctx(IPaneType::AIDocument, ctx)
|
||||
}
|
||||
|
||||
/// Creates a [`PaneId`] from a [`ViewContext<PaneView<CustomRouterEditorView>>`]
|
||||
pub fn from_custom_router_editor_pane_ctx(
|
||||
ctx: &ViewContext<PaneView<crate::ai::custom_model_router_editor::CustomRouterEditorView>>,
|
||||
) -> Self {
|
||||
Self::new_from_ctx(IPaneType::CustomRouterEditor, ctx)
|
||||
}
|
||||
|
||||
/// Creates a [`PaneId`] from a [`ViewContext<PaneView<ExecutionProfileEditorView>>`]
|
||||
pub fn from_execution_profile_editor_pane_ctx(
|
||||
ctx: &ViewContext<PaneView<ExecutionProfileEditorView>>,
|
||||
@@ -263,10 +265,6 @@ impl PaneId {
|
||||
Self::new_from_ctx(IPaneType::ExecutionProfileEditor, ctx)
|
||||
}
|
||||
|
||||
pub fn from_welcome_pane_ctx(ctx: &ViewContext<PaneView<WelcomeView>>) -> Self {
|
||||
Self::new_from_ctx(IPaneType::Welcome, ctx)
|
||||
}
|
||||
|
||||
pub fn from_get_started_pane_ctx(ctx: &ViewContext<PaneView<GetStartedView>>) -> Self {
|
||||
Self::new_from_ctx(IPaneType::GetStarted, ctx)
|
||||
}
|
||||
@@ -350,6 +348,13 @@ impl PaneId {
|
||||
Self::new(IPaneType::AIDocument, ai_document_pane_view)
|
||||
}
|
||||
|
||||
/// Creates a [`PaneId`] from a [`PaneView<CustomRouterEditorView>`] entity ID.
|
||||
pub fn from_custom_router_editor_pane_view(
|
||||
view: &ViewHandle<PaneView<crate::ai::custom_model_router_editor::CustomRouterEditorView>>,
|
||||
) -> Self {
|
||||
Self::new(IPaneType::CustomRouterEditor, view)
|
||||
}
|
||||
|
||||
/// Creates a [`PaneId`] from a [`PaneView<ExecutionProfileEditorView>`] entity ID.
|
||||
pub fn from_execution_profile_editor_pane_view(
|
||||
execution_profile_editor_pane_view: &ViewHandle<PaneView<ExecutionProfileEditorView>>,
|
||||
@@ -366,10 +371,6 @@ impl PaneId {
|
||||
Self::new(IPaneType::GetStarted, get_started_pane_view)
|
||||
}
|
||||
|
||||
pub fn from_welcome_pane_view(welcome_pane_view: &ViewHandle<PaneView<WelcomeView>>) -> Self {
|
||||
Self::new(IPaneType::Welcome, welcome_pane_view)
|
||||
}
|
||||
|
||||
/// Creates a [`PaneId`] from a [`PaneView<NetworkLogView>`] entity ID.
|
||||
pub fn from_network_log_pane_view(
|
||||
network_log_pane_view: &ViewHandle<PaneView<NetworkLogView>>,
|
||||
@@ -482,6 +483,10 @@ impl PaneId {
|
||||
IPaneType::AIDocument => {
|
||||
ChildView::<PaneView<AIDocumentView>>::with_id(self.0.pane_view_id).finish()
|
||||
}
|
||||
IPaneType::CustomRouterEditor => ChildView::<
|
||||
PaneView<crate::ai::custom_model_router_editor::CustomRouterEditorView>,
|
||||
>::with_id(self.0.pane_view_id)
|
||||
.finish(),
|
||||
IPaneType::ExecutionProfileEditor => {
|
||||
ChildView::<PaneView<ExecutionProfileEditorView>>::with_id(self.0.pane_view_id)
|
||||
.finish()
|
||||
@@ -492,9 +497,6 @@ impl PaneId {
|
||||
IPaneType::NetworkLog => {
|
||||
ChildView::<PaneView<NetworkLogView>>::with_id(self.0.pane_view_id).finish()
|
||||
}
|
||||
IPaneType::Welcome => {
|
||||
ChildView::<PaneView<WelcomeView>>::with_id(self.0.pane_view_id).finish()
|
||||
}
|
||||
IPaneType::DeferredPlaceholder => galaxyui::elements::Empty::new().finish(),
|
||||
#[cfg(test)]
|
||||
IPaneType::Dummy => galaxyui::elements::Empty::new().finish(),
|
||||
@@ -842,6 +844,14 @@ impl PaneConfiguration {
|
||||
ctx.emit(PaneConfigurationEvent::ToggleSharingDialog(source));
|
||||
}
|
||||
|
||||
pub fn open_sharing_qr_code(
|
||||
&mut self,
|
||||
source: SharingDialogSource,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
ctx.emit(PaneConfigurationEvent::OpenSharingQrCode(source));
|
||||
}
|
||||
|
||||
/// Notifies that the header content has changed and the pane header should re-render.
|
||||
/// Use this when the backing view's state has changed in a way that affects the header
|
||||
/// content returned by `render_header_content()`.
|
||||
@@ -867,6 +877,7 @@ pub enum PaneConfigurationEvent {
|
||||
RefreshPaneHeaderOverflowMenuItems,
|
||||
ShareableObjectChanged(Option<ShareableObject>),
|
||||
ToggleSharingDialog(SharingDialogSource),
|
||||
OpenSharingQrCode(SharingDialogSource),
|
||||
DimEvenIfFocusedUpdated,
|
||||
/// The header content has changed and should be re-rendered.
|
||||
/// This is used when the backing view's state changes in a way that
|
||||
@@ -1105,8 +1116,7 @@ pub enum PaneEvent {
|
||||
RepoChanged,
|
||||
/// A remote server resolved the repo root for a session in this pane.
|
||||
RemoteRepoNavigated {
|
||||
host_id: HostId,
|
||||
indexed_path: String,
|
||||
remote_path: RemotePath,
|
||||
},
|
||||
/// Split the current pane into two. If `initial_query` is `Some` fill the new pane's input with
|
||||
/// its value.
|
||||
@@ -1116,12 +1126,12 @@ pub enum PaneEvent {
|
||||
ClearHoveredTabIndex,
|
||||
#[cfg(feature = "local_fs")]
|
||||
ReplaceWithCodePane {
|
||||
path: std::path::PathBuf,
|
||||
path: LocalOrRemotePath,
|
||||
source: Option<crate::code::editor_management::CodeSource>,
|
||||
},
|
||||
#[cfg(feature = "local_fs")]
|
||||
ReplaceWithFilePane {
|
||||
path: std::path::PathBuf,
|
||||
path: LocalOrRemotePath,
|
||||
source: Option<crate::code::editor_management::CodeSource>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle};
|
||||
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::app_state::LeafContents;
|
||||
use crate::server::network_log_pane_manager::NetworkLogPaneManager;
|
||||
use crate::server::network_log_view::{NetworkLogView, NetworkLogViewEvent};
|
||||
use crate::workspace::PaneViewLocator;
|
||||
|
||||
use super::{
|
||||
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
|
||||
pub struct NetworkLogPane {
|
||||
view: ViewHandle<PaneView<NetworkLogView>>,
|
||||
pane_configuration: ModelHandle<PaneConfiguration>,
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
use anyhow::Context;
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
use anyhow::Context;
|
||||
use url::Url;
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
|
||||
|
||||
use crate::{
|
||||
app_state::{LeafContents, NotebookPaneSnapshot},
|
||||
cloud_object::Space,
|
||||
drive::{items::WarpDriveItemId, CloudObjectTypeAndId, OpenGalaxyDriveObjectSettings},
|
||||
notebooks::{
|
||||
link::{LinkEvent, NotebookLinks},
|
||||
manager::{NotebookManager, NotebookSource},
|
||||
notebook::{NotebookEvent, NotebookView},
|
||||
},
|
||||
server::ids::SyncId,
|
||||
workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType},
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
|
||||
use super::super::{DefaultSessionModeBehavior, Direction};
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
super::{DefaultSessionModeBehavior, Direction},
|
||||
view::PaneView,
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::app_state::{LeafContents, NotebookPaneSnapshot};
|
||||
use crate::cloud_object::Space;
|
||||
use crate::drive::items::WarpDriveItemId;
|
||||
use crate::drive::{CloudObjectTypeAndId, OpenWarpDriveObjectSettings};
|
||||
use crate::notebooks::link::{LinkEvent, NotebookLinks};
|
||||
use crate::notebooks::manager::{NotebookManager, NotebookSource};
|
||||
use crate::notebooks::notebook::{NotebookEvent, NotebookView};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
pub struct NotebookPane {
|
||||
view: ViewHandle<PaneView<NotebookView>>,
|
||||
@@ -179,7 +175,7 @@ pub(super) fn subscribe_to_link_model(
|
||||
LinkEvent::OpenFileNotebook { path, session } => {
|
||||
// Opening local files is delegated to the parent workspace.
|
||||
ctx.emit(crate::pane_group::Event::OpenFileInWarp {
|
||||
path: path.clone(),
|
||||
path: crate::code::buffer_location::LocalOrRemotePath::Local(path.clone()),
|
||||
session: session.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHandle, WindowId};
|
||||
|
||||
use crate::{
|
||||
app_state::{LeafContents, SettingsPaneSnapshot},
|
||||
settings_view::{
|
||||
pane_manager::SettingsPaneManager, SettingsSection, SettingsView, SettingsViewEvent,
|
||||
},
|
||||
};
|
||||
|
||||
use super::view::PaneView;
|
||||
use super::{
|
||||
view::PaneView, DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::app_state::{LeafContents, SettingsPaneSnapshot};
|
||||
use crate::settings_view::pane_manager::SettingsPaneManager;
|
||||
use crate::settings_view::{SettingsSection, SettingsView, SettingsViewEvent};
|
||||
|
||||
pub struct SettingsPane {
|
||||
view: ViewHandle<PaneView<SettingsView>>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
//! Tests for [`inherit_share_for_local_child`]. These verify the pure
|
||||
//! branching independent of the PaneGroup dispatch code.
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn new_task_id() -> AmbientAgentTaskId {
|
||||
Uuid::new_v4().to_string().parse().unwrap()
|
||||
}
|
||||
|
||||
fn user_source(task_id: Option<&str>) -> SharedSessionSource {
|
||||
SharedSessionSource::user(task_id.map(str::to_owned))
|
||||
}
|
||||
|
||||
fn ambient_source(task_id: Option<&str>) -> SharedSessionSource {
|
||||
SharedSessionSource::ambient_agent(task_id.map(str::to_owned))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherit_share_returns_no_when_host_is_not_sharing() {
|
||||
let result = inherit_share_for_local_child(None, new_task_id());
|
||||
assert!(matches!(result, IsSharedSessionCreator::No));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherit_share_returns_no_when_host_user_share_has_no_task_id() {
|
||||
let host = user_source(None);
|
||||
let result = inherit_share_for_local_child(Some(&host), new_task_id());
|
||||
assert!(
|
||||
matches!(result, IsSharedSessionCreator::No),
|
||||
"hosts without a stamped task_id must NOT cascade; the viewer cannot enumerate \
|
||||
children via REST without a task_id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherit_share_returns_no_when_host_ambient_share_has_no_task_id() {
|
||||
let host = ambient_source(None);
|
||||
let result = inherit_share_for_local_child(Some(&host), new_task_id());
|
||||
assert!(matches!(result, IsSharedSessionCreator::No));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherit_share_cascades_user_source_for_manually_shared_local_orchestrator() {
|
||||
let host = user_source(Some("parent-task-id"));
|
||||
let child_task_id = new_task_id();
|
||||
let expected_child_str = child_task_id.to_string();
|
||||
match inherit_share_for_local_child(Some(&host), child_task_id) {
|
||||
IsSharedSessionCreator::Yes {
|
||||
source:
|
||||
SharedSessionSource {
|
||||
source_type: SessionSourceType::User,
|
||||
source_task_id: Some(task_id),
|
||||
},
|
||||
} => {
|
||||
assert_eq!(
|
||||
task_id, expected_child_str,
|
||||
"the cascaded child must carry its own task_id in the sidecar, not the host's"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected IsSharedSessionCreator::Yes with unit User variant carrying child task_id in \
|
||||
the sidecar, got {other:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherit_share_cascades_ambient_source_for_cloud_orchestrator() {
|
||||
let host = ambient_source(Some("parent-task-id"));
|
||||
let child_task_id = new_task_id();
|
||||
let expected_child_str = child_task_id.to_string();
|
||||
match inherit_share_for_local_child(Some(&host), child_task_id) {
|
||||
IsSharedSessionCreator::Yes {
|
||||
source:
|
||||
SharedSessionSource {
|
||||
source_type:
|
||||
SessionSourceType::AmbientAgent {
|
||||
task_id: Some(task_id),
|
||||
},
|
||||
source_task_id,
|
||||
},
|
||||
} => {
|
||||
assert_eq!(task_id, expected_child_str);
|
||||
assert_eq!(
|
||||
source_task_id.as_deref(),
|
||||
Some(expected_child_str.as_str()),
|
||||
"the sidecar must mirror the cascaded child's task_id so viewers can read one \
|
||||
field for both `User` and `AmbientAgent` shares"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"expected IsSharedSessionCreator::Yes with AmbientAgent variant carrying child \
|
||||
task_id, got {other:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,3 @@
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::buttons::{icon_button, icon_button_with_color};
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
use super::super::header_content::HeaderRenderContext;
|
||||
use super::{ActionPayload, PaneHeaderAction};
|
||||
|
||||
use galaxy_core::ui::icons::ICON_DIMENSIONS;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
@@ -16,6 +9,12 @@ use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::Element;
|
||||
|
||||
use super::super::header_content::HeaderRenderContext;
|
||||
use super::{ActionPayload, PaneHeaderAction};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::buttons::{icon_button, icon_button_with_color};
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
/// Horizontal padding applied inside each edge column of the three-column header.
|
||||
pub const HEADER_EDGE_PADDING: f32 = 4.;
|
||||
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
use sharing::SharedPaneContent;
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
menu::{Menu, MenuItem},
|
||||
pane_group::{
|
||||
focus_state::{PaneFocusHandle, PaneGroupFocusEvent},
|
||||
pane::{
|
||||
view::StandardHeader, ActionOrigin, PaneConfiguration, PaneConfigurationEvent,
|
||||
PaneStack, PaneStackEvent, ToolbeltButton,
|
||||
},
|
||||
BackingView, Direction, PaneDragDropLocation, PaneId, TabBarHoverIndex,
|
||||
},
|
||||
send_telemetry_from_ctx,
|
||||
server::telemetry::{SharingDialogSource, TelemetryEvent},
|
||||
settings::CodeSettings,
|
||||
tab::tab_position_id,
|
||||
terminal::view::TerminalAction,
|
||||
view_components::{FeaturePopup, NewFeaturePopupEvent, NewFeaturePopupLabel},
|
||||
workspace::{TabBarLocation, VerticalTabsPaneDropTargetData},
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use sharing::SharedPaneContent;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::settings::Setting;
|
||||
use warpui::elements::{
|
||||
AcceptedByDropTarget, Align, Border, ChildAnchor, Clipped, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Dismiss, Draggable, DraggableState, Empty, Flex, Hoverable,
|
||||
Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor,
|
||||
ParentElement, ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds,
|
||||
Radius, SavePosition, Shrinkable, Stack, Text,
|
||||
};
|
||||
|
||||
use crate::workspace::TabBarDropTargetData;
|
||||
|
||||
use super::header_content::{HeaderContent, HeaderRenderContext, StandardHeaderOptions};
|
||||
|
||||
use galaxy_core::{features::FeatureFlag, settings::Setting};
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
AcceptedByDropTarget, Align, Border, ChildAnchor, Clipped, ConstrainedBox, Container,
|
||||
CornerRadius, CrossAxisAlignment, Dismiss, Draggable, DraggableState, Empty, Flex,
|
||||
Hoverable, Icon, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning,
|
||||
ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor,
|
||||
PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable, Stack, Text,
|
||||
},
|
||||
presenter::ChildView,
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
@@ -43,7 +22,26 @@ use pathfinder_geometry::{
|
||||
vector::{vec2f, Vector2F},
|
||||
};
|
||||
|
||||
use super::header_content::{HeaderContent, HeaderRenderContext, StandardHeaderOptions};
|
||||
use super::PaneDropTargetData;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::menu::{Menu, MenuItem};
|
||||
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusEvent};
|
||||
use crate::pane_group::pane::view::StandardHeader;
|
||||
use crate::pane_group::pane::{
|
||||
ActionOrigin, PaneConfiguration, PaneConfigurationEvent, PaneStack, PaneStackEvent,
|
||||
ToolbeltButton,
|
||||
};
|
||||
use crate::pane_group::{
|
||||
BackingView, Direction, PaneDragDropLocation, PaneId, TabBarAxis, TabBarHoverIndex,
|
||||
};
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::server::telemetry::{SharingDialogSource, TelemetryEvent};
|
||||
use crate::settings::CodeSettings;
|
||||
use crate::tab::tab_position_id;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::view_components::{FeaturePopup, NewFeaturePopupEvent, NewFeaturePopupLabel};
|
||||
use crate::workspace::{TabBarDropTargetData, TabBarLocation, VerticalTabsPaneDropTargetData};
|
||||
|
||||
mod sharing;
|
||||
|
||||
@@ -75,6 +73,9 @@ pub enum Event<A: ActionPayload, B: ActionPayload> {
|
||||
origin: ActionOrigin,
|
||||
tab_hover_index: TabBarHoverIndex,
|
||||
hidden_pane_preview_direction: Direction,
|
||||
/// Drag cursor rect, forwarded to the workspace so it can resolve which
|
||||
/// tab group a `BeforeTab` insertion lands in.
|
||||
drag_position: RectF,
|
||||
},
|
||||
/// The pane header was dragged over some part of the terminal that is not the pane group
|
||||
/// or tab bar
|
||||
@@ -108,10 +109,10 @@ pub enum PaneHeaderAction<A: ActionPayload, B: ActionPayload> {
|
||||
origin: ActionOrigin,
|
||||
drag_location: PaneDragDropLocation,
|
||||
drag_position: RectF,
|
||||
/// Precomputed by drop targets that already know the exact hover state,
|
||||
/// such as vertical tabs. When absent, the hover index is derived from
|
||||
/// the drag geometry and tab bar location.
|
||||
precomputed_tab_hover_index: Option<TabBarHoverIndex>,
|
||||
/// Axis for a tab-bar drag, so the header derives the hover index from
|
||||
/// cursor geometry along the right axis. `None` for non-tab-bar drag
|
||||
/// locations.
|
||||
tab_bar_axis: Option<TabBarAxis>,
|
||||
},
|
||||
PaneHeaderDropped {
|
||||
origin: ActionOrigin,
|
||||
@@ -327,33 +328,69 @@ impl<P: BackingView> PaneHeader<P> {
|
||||
self.is_visible_in_pane_group
|
||||
}
|
||||
|
||||
/// Based on the drag position and tab bar location, returns whether or not the given drag
|
||||
/// is over a tab, or between two tabs. This is done by splitting the tabs into quadrants and seeing
|
||||
/// what quadrant the center of the dragged element lives.
|
||||
/// Based on the drag position and tab bar location, returns whether the drag
|
||||
/// is over a tab or between two tabs, by splitting the hovered tab into
|
||||
/// quarters along the active axis (X for the horizontal bar, Y for the
|
||||
/// vertical panel): the leading quarter inserts before, the middle half
|
||||
/// merges onto the tab, and the trailing quarter inserts after.
|
||||
fn calculate_tab_focus_hover_index(
|
||||
drag_position: &RectF,
|
||||
tab_bar_location: &TabBarLocation,
|
||||
axis: TabBarAxis,
|
||||
ctx: &ViewContext<Self>,
|
||||
) -> TabBarHoverIndex {
|
||||
let is_vertical = matches!(axis, TabBarAxis::Vertical);
|
||||
match tab_bar_location {
|
||||
TabBarLocation::TabIndex(idx) => {
|
||||
if let Some(tab_rect) = ctx.element_position_by_id(tab_position_id(*idx)) {
|
||||
let tab_center_x = tab_rect.center().x();
|
||||
let tab_quarter_x = (tab_center_x + tab_rect.lower_left().x()) / 2.;
|
||||
let tab_three_quarters_x = (tab_center_x + tab_rect.lower_right().x()) / 2.;
|
||||
if drag_position.center().x() < tab_quarter_x {
|
||||
TabBarHoverIndex::BeforeTab(*idx)
|
||||
} else if drag_position.center().x() < tab_three_quarters_x {
|
||||
TabBarHoverIndex::OverTab(*idx)
|
||||
let Some(tab_rect) = ctx.element_position_by_id(tab_position_id(*idx)) else {
|
||||
// If for some reason we can't retrieve the tab position, fall
|
||||
// back to the per-axis default: the vertical panel inserts
|
||||
// before the tab, the horizontal bar merges onto it.
|
||||
return if is_vertical {
|
||||
TabBarHoverIndex::BeforeTab {
|
||||
index: *idx,
|
||||
group: None,
|
||||
}
|
||||
} else {
|
||||
TabBarHoverIndex::BeforeTab(*idx + 1)
|
||||
}
|
||||
TabBarHoverIndex::OverTab(*idx)
|
||||
};
|
||||
};
|
||||
// Project the tab rect and drag cursor onto the active axis.
|
||||
let (drag, center, near, far) = if is_vertical {
|
||||
(
|
||||
drag_position.center().y(),
|
||||
tab_rect.center().y(),
|
||||
tab_rect.min_y(),
|
||||
tab_rect.max_y(),
|
||||
)
|
||||
} else {
|
||||
// If for some reason we can't retrieve the tab position, just default to the index
|
||||
(
|
||||
drag_position.center().x(),
|
||||
tab_rect.center().x(),
|
||||
tab_rect.min_x(),
|
||||
tab_rect.max_x(),
|
||||
)
|
||||
};
|
||||
let tab_quarter = (center + near) / 2.;
|
||||
let tab_three_quarters = (center + far) / 2.;
|
||||
if drag < tab_quarter {
|
||||
TabBarHoverIndex::BeforeTab {
|
||||
index: *idx,
|
||||
group: None,
|
||||
}
|
||||
} else if drag < tab_three_quarters {
|
||||
TabBarHoverIndex::OverTab(*idx)
|
||||
} else {
|
||||
TabBarHoverIndex::BeforeTab {
|
||||
index: *idx + 1,
|
||||
group: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
TabBarLocation::AfterTabIndex(tab_count) => TabBarHoverIndex::BeforeTab(*tab_count),
|
||||
TabBarLocation::AfterTabIndex(tab_count) => TabBarHoverIndex::BeforeTab {
|
||||
index: *tab_count,
|
||||
group: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -821,9 +858,9 @@ impl<P: BackingView> View for PaneHeader<P> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Based on the drag position and target pane, calcuates which direction the pane should move.
|
||||
/// Based on the drag position and target pane, calculates which direction the pane should move.
|
||||
///
|
||||
/// We determine the split by dividing the pane into four quadrants, each refering to a split direction:
|
||||
/// We determine the split by dividing the pane into four quadrants, each referring to a split direction:
|
||||
/// +--------+
|
||||
/// |\ up /|
|
||||
/// | \ / |
|
||||
@@ -898,26 +935,27 @@ impl<P: BackingView> TypedActionView for PaneHeader<P> {
|
||||
origin,
|
||||
drag_location,
|
||||
drag_position,
|
||||
precomputed_tab_hover_index,
|
||||
tab_bar_axis,
|
||||
} => match drag_location {
|
||||
PaneDragDropLocation::TabBar(tab_bar_location) => {
|
||||
if matches!(origin, ActionOrigin::Pane) {
|
||||
self.is_visible_in_pane_group = false;
|
||||
}
|
||||
let axis = tab_bar_axis.unwrap_or(TabBarAxis::Horizontal);
|
||||
let tab_hover_index = Self::calculate_tab_focus_hover_index(
|
||||
drag_position,
|
||||
tab_bar_location,
|
||||
axis,
|
||||
ctx,
|
||||
);
|
||||
ctx.emit(Event::DraggedOverTabBar {
|
||||
origin: *origin,
|
||||
tab_hover_index: precomputed_tab_hover_index.unwrap_or_else(|| {
|
||||
Self::calculate_tab_focus_hover_index(
|
||||
drag_position,
|
||||
tab_bar_location,
|
||||
ctx,
|
||||
)
|
||||
}),
|
||||
hidden_pane_preview_direction: if precomputed_tab_hover_index.is_some() {
|
||||
Direction::Up
|
||||
} else {
|
||||
Direction::Left
|
||||
tab_hover_index,
|
||||
hidden_pane_preview_direction: match axis {
|
||||
TabBarAxis::Vertical => Direction::Up,
|
||||
TabBarAxis::Horizontal => Direction::Left,
|
||||
},
|
||||
drag_position: *drag_position,
|
||||
});
|
||||
}
|
||||
PaneDragDropLocation::PaneGroup(target_id) => {
|
||||
@@ -1044,7 +1082,7 @@ pub fn render_pane_header_draggable<P: BackingView>(
|
||||
origin: ActionOrigin::Pane,
|
||||
drag_location: PaneDragDropLocation::PaneGroup(pane_drop_data.id),
|
||||
drag_position,
|
||||
precomputed_tab_hover_index: None,
|
||||
tab_bar_axis: None,
|
||||
});
|
||||
} else if let Some(data) =
|
||||
data.and_then(|data| data.as_any().downcast_ref::<TabBarDropTargetData>())
|
||||
@@ -1056,7 +1094,7 @@ pub fn render_pane_header_draggable<P: BackingView>(
|
||||
origin: ActionOrigin::Pane,
|
||||
drag_location: PaneDragDropLocation::TabBar(data.tab_bar_location),
|
||||
drag_position,
|
||||
precomputed_tab_hover_index: None,
|
||||
tab_bar_axis: Some(TabBarAxis::Horizontal),
|
||||
})
|
||||
} else if let Some(data) = data.and_then(|data| {
|
||||
data.as_any()
|
||||
@@ -1069,7 +1107,7 @@ pub fn render_pane_header_draggable<P: BackingView>(
|
||||
origin: ActionOrigin::Pane,
|
||||
drag_location: PaneDragDropLocation::TabBar(data.tab_bar_location),
|
||||
drag_position,
|
||||
precomputed_tab_hover_index: Some(data.tab_hover_index),
|
||||
tab_bar_axis: Some(TabBarAxis::Vertical),
|
||||
})
|
||||
} else {
|
||||
ctx.dispatch_typed_action(PaneHeaderAction::<
|
||||
@@ -1079,7 +1117,7 @@ pub fn render_pane_header_draggable<P: BackingView>(
|
||||
origin: ActionOrigin::Pane,
|
||||
drag_location: PaneDragDropLocation::Other,
|
||||
drag_position,
|
||||
precomputed_tab_hover_index: None,
|
||||
tab_bar_axis: None,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1194,5 +1232,5 @@ fn render_draggable_placeholder_element(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mod_test.rs"]
|
||||
#[path = "mod_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+19
-22
@@ -1,30 +1,27 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::{
|
||||
elements::Empty, platform::WindowStyle, App, AppContext, Element, Entity, TypedActionView,
|
||||
View, ViewContext,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::BlocklistAIHistoryModel,
|
||||
auth::AuthStateProvider,
|
||||
cloud_object::model::persistence::CloudModel,
|
||||
menu::MenuItemFields,
|
||||
pane_group::{focus_state::PaneFocusHandle, BackingView, PaneConfiguration, PaneId, PaneView},
|
||||
server::server_api::{object::MockObjectClient, ServerApiProvider},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::shared_session::permissions_manager::SessionPermissionsManager,
|
||||
test_util::settings::initialize_settings_for_tests,
|
||||
NetworkStatus, SyncQueue, TeamTesterStatus, UpdateManager, UserProfiles, UserWorkspaces,
|
||||
};
|
||||
use cloud_object_client::MockObjectClient;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use warpui::elements::Empty;
|
||||
use warpui::platform::WindowStyle;
|
||||
use warpui::{App, AppContext, Element, Entity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::{Event, OpenOverlay};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::menu::MenuItemFields;
|
||||
use crate::pane_group::focus_state::PaneFocusHandle;
|
||||
use crate::pane_group::{BackingView, PaneConfiguration, PaneId, PaneView};
|
||||
use crate::server::server_api::team::MockTeamClient;
|
||||
use crate::server::server_api::workspace::MockWorkspaceClient;
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::shared_session::permissions_manager::SessionPermissionsManager;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::{
|
||||
NetworkStatus, SyncQueue, TeamTesterStatus, UpdateManager, UserProfiles, UserWorkspaces,
|
||||
};
|
||||
|
||||
/// A dummy view that is also a backing pane view for testing purposes.
|
||||
struct TestView {
|
||||
@@ -3,29 +3,21 @@
|
||||
//! This is tightly coupled to the pane header so that different overlays (context menus, the
|
||||
//! sharing dialog, and so on) are correctly displayed.
|
||||
|
||||
use galaxy_core::{features::FeatureFlag, ui::appearance::Appearance};
|
||||
use galaxyui::{
|
||||
elements::{MouseStateHandle, ParentElement},
|
||||
platform::Cursor,
|
||||
ui_components::components::UiComponent,
|
||||
AppContext, Element, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::ConstrainedBox;
|
||||
|
||||
use crate::{
|
||||
drive::sharing::{
|
||||
dialog::{SharingDialog, SharingDialogEvent},
|
||||
ContentEditability, ShareableObject,
|
||||
},
|
||||
pane_group::BackingView,
|
||||
server::telemetry::SharingDialogSource,
|
||||
ui_components::buttons::{icon_button, icon_button_with_color},
|
||||
ui_components::icons::Icon,
|
||||
};
|
||||
use galaxyui::elements::{ConstrainedBox, MouseStateHandle, ParentElement};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{AppContext, Element, ViewContext, ViewHandle};
|
||||
|
||||
use super::{Event, OpenOverlay, PaneHeader, PaneHeaderAction};
|
||||
use crate::drive::sharing::dialog::{SharingDialog, SharingDialogEvent};
|
||||
use crate::drive::sharing::{ContentEditability, ShareableObject};
|
||||
use crate::pane_group::BackingView;
|
||||
use crate::server::telemetry::SharingDialogSource;
|
||||
use crate::ui_components::buttons::{icon_button, icon_button_with_color};
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
const UNSHARABLE_CONVERSATION_TOOLTIP: &str =
|
||||
"This conversation cannot be shared because it is not \
|
||||
@@ -143,6 +135,30 @@ impl<P: BackingView> PaneHeader<P> {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
pub fn open_shared_session_qr_code(
|
||||
&mut self,
|
||||
source: SharingDialogSource,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if !self.is_sharing_dialog_enabled(ctx) || !self.has_shareable_shared_session(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
let dialog_was_closed = self.open_overlay != OpenOverlay::SharingDialog;
|
||||
if self.open_overlay == OpenOverlay::OverflowMenu {
|
||||
ctx.emit(Event::PaneHeaderOverflowMenuToggled(false));
|
||||
}
|
||||
self.open_overlay = OpenOverlay::SharingDialog;
|
||||
ctx.focus(&self.shared_content.sharing_dialog);
|
||||
self.sharing_dialog().update(ctx, |dialog, ctx| {
|
||||
dialog.show_qr_code(ctx);
|
||||
if dialog_was_closed {
|
||||
dialog.report_open(source, ctx);
|
||||
}
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn handle_sharing_dialog_event(
|
||||
&mut self,
|
||||
event: &SharingDialogEvent,
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
//! specify their header content without worrying about draggable behavior.
|
||||
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::{
|
||||
elements::{DraggableState, MouseStateHandle},
|
||||
fonts::Properties,
|
||||
text_layout::ClipConfig,
|
||||
AppContext, Element,
|
||||
};
|
||||
use galaxyui::elements::{DraggableState, MouseStateHandle};
|
||||
use galaxyui::fonts::Properties;
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::{AppContext, Element};
|
||||
|
||||
/// Closure that renders sharing controls (share button, view-only indicator) for a pane header.
|
||||
/// Accepts optional icon color and button size overrides.
|
||||
|
||||
@@ -1,38 +1,34 @@
|
||||
pub mod header;
|
||||
pub mod header_content;
|
||||
|
||||
use crate::pane_group::pane::ActionOrigin;
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
pane_group::{Direction, SplitPaneState, TabBarHoverIndex},
|
||||
server::telemetry::SharingDialogSource,
|
||||
settings::{PaneSettings, PaneSettingsChangedEvent},
|
||||
util::bindings::CustomAction,
|
||||
};
|
||||
|
||||
use super::{
|
||||
BackingView, PaneConfiguration, PaneConfigurationEvent, PaneId, PaneStack, PaneStackEvent,
|
||||
};
|
||||
use header::PaneHeader;
|
||||
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, Container, DropTarget, DropTargetData, Flex, MainAxisSize, ParentElement,
|
||||
SavePosition, Shrinkable,
|
||||
},
|
||||
keymap::EditableBinding,
|
||||
presenter::ChildView,
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusEvent};
|
||||
|
||||
pub use header::PaneHeaderAction;
|
||||
pub use header::PaneHeaderAction::CustomAction as PaneHeaderCustomAction;
|
||||
pub use header_content::{
|
||||
HeaderContent, HeaderRenderContext, StandardHeader, StandardHeaderOptions,
|
||||
};
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use warpui::elements::{
|
||||
Border, Container, DropTarget, DropTargetData, Flex, MainAxisSize, ParentElement, SavePosition,
|
||||
Shrinkable,
|
||||
};
|
||||
use warpui::keymap::EditableBinding;
|
||||
use warpui::presenter::ChildView;
|
||||
use warpui::{
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use super::{
|
||||
BackingView, PaneConfiguration, PaneConfigurationEvent, PaneId, PaneStack, PaneStackEvent,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::pane_group::focus_state::{PaneFocusHandle, PaneGroupFocusEvent};
|
||||
use crate::pane_group::pane::ActionOrigin;
|
||||
use crate::pane_group::{Direction, SplitPaneState, TabBarHoverIndex};
|
||||
use crate::server::telemetry::SharingDialogSource;
|
||||
use crate::settings::{PaneSettings, PaneSettingsChangedEvent};
|
||||
use crate::util::bindings::CustomAction;
|
||||
|
||||
const HAS_SHARED_OBJECT_CONTEXT_KEY: &str = "PaneView_HasSharedObject";
|
||||
|
||||
@@ -60,6 +56,7 @@ pub enum PaneViewEvent {
|
||||
origin: ActionOrigin,
|
||||
tab_hover_index: TabBarHoverIndex,
|
||||
hidden_pane_preview_direction: Direction,
|
||||
drag_position: RectF,
|
||||
},
|
||||
PaneDraggedOutsideTabBarOrPaneGroup,
|
||||
PaneDragEnded,
|
||||
@@ -252,6 +249,11 @@ impl<P: BackingView> PaneView<P> {
|
||||
header.share_pane_contents(*source, ctx);
|
||||
});
|
||||
}
|
||||
PaneConfigurationEvent::OpenSharingQrCode(source) => {
|
||||
self.header.update(ctx, |header, ctx| {
|
||||
header.open_shared_session_qr_code(*source, ctx);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -314,6 +316,7 @@ impl<P: BackingView> PaneView<P> {
|
||||
origin,
|
||||
tab_hover_index,
|
||||
hidden_pane_preview_direction,
|
||||
drag_position,
|
||||
} => {
|
||||
// Adds a neutral background to the pane if it's being dragged over the workspace tab group.
|
||||
if matches!(origin, ActionOrigin::Pane) {
|
||||
@@ -324,6 +327,7 @@ impl<P: BackingView> PaneView<P> {
|
||||
origin: *origin,
|
||||
tab_hover_index: *tab_hover_index,
|
||||
hidden_pane_preview_direction: *hidden_pane_preview_direction,
|
||||
drag_position: *drag_position,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
@@ -432,6 +436,20 @@ impl<P: BackingView> View for PaneView<P> {
|
||||
}
|
||||
keymap_context
|
||||
}
|
||||
|
||||
fn child_view_ids(&self, app: &AppContext) -> Vec<EntityId> {
|
||||
// The backing views are owned by the `pane_stack` model, and only the
|
||||
// active (topmost) one is ever rendered (see `render`), so the
|
||||
// non-active views — and even the active one in a window that never
|
||||
// laid out — are invisible to the render-time parent graph. Report
|
||||
// all of them plus the header so the entire pane moves together when
|
||||
// it is transferred between windows; otherwise a backing view would
|
||||
// be orphaned in the source window and later trip a "circular view
|
||||
// reference" panic when accessed from its new window.
|
||||
let mut ids = vec![self.header.id()];
|
||||
ids.extend(self.pane_stack.as_ref(app).views().map(|view| view.id()));
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: BackingView> TypedActionView for PaneView<P> {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context;
|
||||
use url::Url;
|
||||
use warpui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
|
||||
|
||||
use super::{
|
||||
DetachType, PaneConfiguration, PaneContent, PaneGroup, PaneId, PaneView, ShareableLink,
|
||||
ShareableLinkError,
|
||||
};
|
||||
use crate::{
|
||||
app_state::{LeafContents, WorkflowPaneSnapshot},
|
||||
drive::{items::WarpDriveItemId, OpenGalaxyDriveObjectSettings},
|
||||
server::ids::SyncId,
|
||||
workflows::{
|
||||
manager::{WorkflowManager, WorkflowOpenSource},
|
||||
workflow_view::{WorkflowView, WorkflowViewEvent},
|
||||
WorkflowSelectionSource, WorkflowSource, WorkflowType, WorkflowViewMode,
|
||||
},
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use galaxyui::{AppContext, ModelHandle, SingletonEntity, ViewContext, ViewHandle};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use url::Url;
|
||||
use crate::app_state::{LeafContents, WorkflowPaneSnapshot};
|
||||
use crate::drive::items::WarpDriveItemId;
|
||||
use crate::drive::OpenWarpDriveObjectSettings;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::workflows::manager::{WorkflowManager, WorkflowOpenSource};
|
||||
use crate::workflows::workflow_view::{WorkflowView, WorkflowViewEvent};
|
||||
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType, WorkflowViewMode};
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
pub struct WorkflowPane {
|
||||
view: ViewHandle<PaneView<WorkflowView>>,
|
||||
|
||||
+123
-63
@@ -1,27 +1,22 @@
|
||||
use crate::app_state;
|
||||
use galaxyui::elements::{
|
||||
ChildAnchor, Container, DispatchEventResult, Empty, OffsetPositioning, ParentAnchor,
|
||||
ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds, SavePosition,
|
||||
Stack,
|
||||
};
|
||||
use galaxyui::AppContext;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ConstrainedBox, Element, EventHandler, Flex, Hoverable, MouseStateHandle, ParentElement,
|
||||
Rect, Shrinkable,
|
||||
},
|
||||
platform::Cursor,
|
||||
EntityId, ViewContext,
|
||||
};
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use std::collections::HashSet;
|
||||
use std::{fmt, iter, mem};
|
||||
|
||||
use super::{ActivationReason, PaneGroup, PaneId};
|
||||
use crate::pane_group::{get_minimum_pane_size, DraggedBorder, PaneGroupAction};
|
||||
use crate::themes::theme::GalaxyTheme;
|
||||
use pathfinder_geometry::rect::RectF;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::elements::{
|
||||
ChildAnchor, ConstrainedBox, Container, DispatchEventResult, Element, Empty, EventHandler,
|
||||
Flex, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, PositionedElementAnchor, PositionedElementOffsetBounds, Rect, SavePosition,
|
||||
Shrinkable, Stack,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::{AppContext, EntityId, ViewContext};
|
||||
|
||||
use super::{ActivationReason, PaneGroup, PaneId};
|
||||
use crate::app_state;
|
||||
use crate::pane_group::{get_minimum_pane_size, DraggedBorder, PaneGroupAction};
|
||||
use crate::themes::theme::WarpTheme;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tree_tests.rs"]
|
||||
@@ -217,9 +212,9 @@ impl PaneData {
|
||||
}
|
||||
|
||||
pub fn visible_pane_count(&self) -> usize {
|
||||
let total_panes = self.pane_ids().len();
|
||||
let hidden_count = self.num_hidden_panes();
|
||||
total_panes.saturating_sub(hidden_count)
|
||||
// Use `visible_pane_ids` directly; subtracting hidden count would
|
||||
// double-count temporary-replacement originals (hidden but off-tree).
|
||||
self.visible_pane_ids().len()
|
||||
}
|
||||
|
||||
pub fn has_horizontal_split(&self) -> bool {
|
||||
@@ -324,6 +319,11 @@ impl PaneData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `id` is hidden as a child agent pane.
|
||||
pub fn is_pane_hidden_for_child_agent(&self, id: PaneId) -> bool {
|
||||
pane_hidden_for_child_agent(&self.hidden_panes, &id)
|
||||
}
|
||||
|
||||
pub fn toggle_pane_visibility_for_job(&mut self, id: PaneId) -> bool {
|
||||
if pane_hidden_for_job(&self.hidden_panes, &id) {
|
||||
self.show_pane_for_job(id);
|
||||
@@ -375,6 +375,21 @@ impl PaneData {
|
||||
})
|
||||
}
|
||||
|
||||
/// Inverse of [`Self::original_pane_for_replacement`]: given a pane
|
||||
/// currently swapped out as a temporary replacement's original,
|
||||
/// return the replacement that took its slot.
|
||||
pub fn replacement_pane_for_original(&self, original_pane_id: PaneId) -> Option<PaneId> {
|
||||
self.hidden_panes.iter().find_map(|hidden_pane| {
|
||||
if hidden_pane.pane_id != original_pane_id {
|
||||
return None;
|
||||
}
|
||||
match hidden_pane.reason {
|
||||
HiddenPaneReason::TemporaryReplacement(replacement_id) => Some(replacement_id),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_hidden_closed_pane(&self, pane_id: &PaneId) -> bool {
|
||||
self.hidden_panes
|
||||
.iter()
|
||||
@@ -484,6 +499,11 @@ impl PaneData {
|
||||
.any(|hidden_pane| hidden_pane.pane_id == *pane_id)
|
||||
}
|
||||
|
||||
/// Returns true if `pane_id` is currently a leaf in the layout tree.
|
||||
pub fn is_pane_in_tree(&self, pane_id: PaneId) -> bool {
|
||||
self.root.contains_pane(pane_id)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
@@ -508,6 +528,10 @@ impl PaneData {
|
||||
self.root.adjust_pane_size(border_id, delta, ctx);
|
||||
}
|
||||
|
||||
pub fn reset_pane_sizes(&mut self, border_id: EntityId) -> bool {
|
||||
self.root.reset_pane_sizes(border_id)
|
||||
}
|
||||
|
||||
pub fn adjust_pane_size_by_id(
|
||||
&mut self,
|
||||
pane_id: PaneId,
|
||||
@@ -736,6 +760,13 @@ impl PaneNode {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_pane_sizes(&mut self, border_id: EntityId) -> bool {
|
||||
match self {
|
||||
PaneNode::Leaf(_) => false,
|
||||
PaneNode::Branch(branch) => branch.reset_pane_sizes(border_id),
|
||||
}
|
||||
}
|
||||
|
||||
/// The boolean value returned here indicates whether a resizing needs to
|
||||
/// be handled at a parent branch. For a leaf node, if the pane's id does not match,
|
||||
/// we returns false as its parent branch does not need to handle the resize.
|
||||
@@ -825,7 +856,7 @@ impl PaneNode {
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_pane(&self, pane_id: PaneId) -> bool {
|
||||
pub(crate) fn contains_pane(&self, pane_id: PaneId) -> bool {
|
||||
match self {
|
||||
PaneNode::Leaf(id) => *id == pane_id,
|
||||
PaneNode::Branch(branch) => branch.contains_pane(pane_id),
|
||||
@@ -1156,6 +1187,23 @@ impl PaneBranch {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn reset_pane_sizes(&mut self, border_id: EntityId) -> bool {
|
||||
if self.dividers.iter().any(|divider| divider.id == border_id) {
|
||||
for (flex, _) in &mut self.nodes {
|
||||
*flex = DEFAULT_FLEX_SIZE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
for (_, node) in &mut self.nodes {
|
||||
if node.reset_pane_sizes(border_id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
// Get the size of a branch by recursively adding the size of its children.
|
||||
pub fn size(&self, ctx: &mut ViewContext<PaneGroup>) -> Vector2F {
|
||||
match self.axis {
|
||||
@@ -1352,6 +1400,23 @@ fn create_divider_placeholder(direction: SplitDirection, position_id: &str) -> B
|
||||
SavePosition::new(placeholder, position_id).finish()
|
||||
}
|
||||
|
||||
fn divider_mouse_down_action(
|
||||
mouse_state: &MouseStateHandle,
|
||||
border_id: EntityId,
|
||||
direction: SplitDirection,
|
||||
position: Vector2F,
|
||||
) -> PaneGroupAction {
|
||||
if mouse_state.lock().unwrap().click_count() == Some(2) {
|
||||
PaneGroupAction::ResetPaneSizes(border_id)
|
||||
} else {
|
||||
PaneGroupAction::StartResizing(DraggedBorder {
|
||||
border_id,
|
||||
direction,
|
||||
previous_mouse_location: position,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn create_divider(
|
||||
direction: SplitDirection,
|
||||
item: &Divider,
|
||||
@@ -1369,21 +1434,19 @@ fn create_divider(
|
||||
};
|
||||
|
||||
let border_id = item.id;
|
||||
let mouse_state = item.mouse_state.clone();
|
||||
|
||||
Hoverable::new(item.mouse_state.clone(), |_| {
|
||||
EventHandler::new(match direction {
|
||||
SplitDirection::Horizontal => divider.with_width(get_divider_thickness()).finish(),
|
||||
SplitDirection::Vertical => divider.with_height(get_divider_thickness()).finish(),
|
||||
})
|
||||
.on_left_mouse_down(move |ctx, _, position| {
|
||||
ctx.dispatch_typed_action(PaneGroupAction::StartResizing(DraggedBorder {
|
||||
border_id,
|
||||
direction,
|
||||
previous_mouse_location: position,
|
||||
}));
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish()
|
||||
Hoverable::new(item.mouse_state.clone(), |_| match direction {
|
||||
SplitDirection::Horizontal => divider.with_width(get_divider_thickness()).finish(),
|
||||
SplitDirection::Vertical => divider.with_height(get_divider_thickness()).finish(),
|
||||
})
|
||||
.on_mouse_down(move |ctx, _, position| {
|
||||
ctx.dispatch_typed_action(divider_mouse_down_action(
|
||||
&mouse_state,
|
||||
border_id,
|
||||
direction,
|
||||
position,
|
||||
));
|
||||
})
|
||||
.with_cursor(cursor_shape)
|
||||
.with_propagate_drag()
|
||||
@@ -1407,31 +1470,28 @@ fn create_minimalist_divider(
|
||||
};
|
||||
|
||||
let border_id = item.id;
|
||||
let hoverable = Hoverable::new(item.mouse_state.clone(), |_| {
|
||||
let container = match direction {
|
||||
SplitDirection::Horizontal => {
|
||||
Container::new(divider.with_width(get_divider_thickness()).finish())
|
||||
.with_padding_left(DIVIDER_RESIZE_PADDING)
|
||||
.with_padding_right(DIVIDER_RESIZE_PADDING)
|
||||
.finish()
|
||||
}
|
||||
SplitDirection::Vertical => {
|
||||
Container::new(divider.with_height(get_divider_thickness()).finish())
|
||||
.with_padding_top(DIVIDER_RESIZE_PADDING)
|
||||
.with_padding_bottom(DIVIDER_RESIZE_PADDING)
|
||||
.finish()
|
||||
}
|
||||
};
|
||||
EventHandler::new(container)
|
||||
.on_left_mouse_down(move |ctx, _, position| {
|
||||
ctx.dispatch_typed_action(PaneGroupAction::StartResizing(DraggedBorder {
|
||||
border_id,
|
||||
direction,
|
||||
previous_mouse_location: position,
|
||||
}));
|
||||
DispatchEventResult::StopPropagation
|
||||
})
|
||||
.finish()
|
||||
let mouse_state = item.mouse_state.clone();
|
||||
let hoverable = Hoverable::new(item.mouse_state.clone(), |_| match direction {
|
||||
SplitDirection::Horizontal => {
|
||||
Container::new(divider.with_width(get_divider_thickness()).finish())
|
||||
.with_padding_left(DIVIDER_RESIZE_PADDING)
|
||||
.with_padding_right(DIVIDER_RESIZE_PADDING)
|
||||
.finish()
|
||||
}
|
||||
SplitDirection::Vertical => {
|
||||
Container::new(divider.with_height(get_divider_thickness()).finish())
|
||||
.with_padding_top(DIVIDER_RESIZE_PADDING)
|
||||
.with_padding_bottom(DIVIDER_RESIZE_PADDING)
|
||||
.finish()
|
||||
}
|
||||
})
|
||||
.on_mouse_down(move |ctx, _, position| {
|
||||
ctx.dispatch_typed_action(divider_mouse_down_action(
|
||||
&mouse_state,
|
||||
border_id,
|
||||
direction,
|
||||
position,
|
||||
));
|
||||
})
|
||||
.with_cursor(cursor_shape)
|
||||
.with_propagate_drag();
|
||||
|
||||
@@ -608,6 +608,89 @@ fn test_are_rects_overlapping_on_axis() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_pane_sizes_resets_containing_branch() {
|
||||
let panes = [
|
||||
PaneId::dummy_pane_id(),
|
||||
PaneId::dummy_pane_id(),
|
||||
PaneId::dummy_pane_id(),
|
||||
];
|
||||
let mut tree = PaneData::new(panes[0]);
|
||||
|
||||
tree.split(panes[0], panes[1], Direction::Right);
|
||||
tree.split(panes[1], panes[2], Direction::Right);
|
||||
|
||||
let root = tree.root.as_branch().expect("Should be a branch");
|
||||
let border_id = root.dividers[0].id;
|
||||
|
||||
let root = match &mut tree.root {
|
||||
PaneNode::Branch(root) => root,
|
||||
PaneNode::Leaf(_) => panic!("Should be a branch"),
|
||||
};
|
||||
root.nodes[0].0 = PaneFlex(0.2);
|
||||
root.nodes[1].0 = PaneFlex(0.5);
|
||||
root.nodes[2].0 = PaneFlex(0.3);
|
||||
|
||||
assert!(tree.reset_pane_sizes(border_id));
|
||||
|
||||
let root = tree.root.as_branch().expect("Should be a branch");
|
||||
assert_eq!(
|
||||
root.nodes
|
||||
.iter()
|
||||
.map(|(flex, _)| flex.0)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![DEFAULT_FLEX_VALUE, DEFAULT_FLEX_VALUE, DEFAULT_FLEX_VALUE]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reset_pane_sizes_only_resets_containing_branch() {
|
||||
let panes = [
|
||||
PaneId::dummy_pane_id(),
|
||||
PaneId::dummy_pane_id(),
|
||||
PaneId::dummy_pane_id(),
|
||||
];
|
||||
let mut tree = PaneData::new(panes[0]);
|
||||
|
||||
tree.split(panes[0], panes[1], Direction::Down);
|
||||
tree.split(panes[1], panes[2], Direction::Right);
|
||||
|
||||
let root = match &mut tree.root {
|
||||
PaneNode::Branch(root) => root,
|
||||
PaneNode::Leaf(_) => panic!("Should be a branch"),
|
||||
};
|
||||
root.nodes[0].0 = PaneFlex(0.25);
|
||||
root.nodes[1].0 = PaneFlex(0.75);
|
||||
|
||||
let nested = match &mut root.nodes[1].1 {
|
||||
PaneNode::Branch(nested) => nested,
|
||||
PaneNode::Leaf(_) => panic!("Should be a branch"),
|
||||
};
|
||||
nested.nodes[0].0 = PaneFlex(0.8);
|
||||
nested.nodes[1].0 = PaneFlex(0.2);
|
||||
let nested_border_id = nested.dividers[0].id;
|
||||
|
||||
assert!(tree.reset_pane_sizes(nested_border_id));
|
||||
|
||||
let root = tree.root.as_branch().expect("Should be a branch");
|
||||
assert_eq!(
|
||||
root.nodes
|
||||
.iter()
|
||||
.map(|(flex, _)| flex.0)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![0.25, 0.75]
|
||||
);
|
||||
let nested = root.node(1).as_branch().expect("Should be a branch");
|
||||
assert_eq!(
|
||||
nested
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(flex, _)| flex.0)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![DEFAULT_FLEX_VALUE, DEFAULT_FLEX_VALUE]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hide_and_show_child_agent_pane() {
|
||||
let panes = [PaneId::dummy_pane_id(), PaneId::dummy_pane_id()];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,20 @@ use std::path::PathBuf;
|
||||
|
||||
use galaxyui::{App, EntityId};
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::watcher::DirectoryWatcher;
|
||||
|
||||
use super::PaneGroupRepositoryRoots;
|
||||
use crate::code::buffer_location::LocalOrRemotePath;
|
||||
use crate::pane_group::WorkingDirectoriesModel;
|
||||
|
||||
fn local(path: &std::path::Path) -> LocalOrRemotePath {
|
||||
LocalOrRemotePath::Local(path.to_path_buf())
|
||||
}
|
||||
|
||||
fn local_str(path: &str) -> LocalOrRemotePath {
|
||||
LocalOrRemotePath::Local(PathBuf::from(path))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_working_directories_collapses_subroots_to_nearest_repo_root() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -40,27 +51,28 @@ fn refresh_working_directories_collapses_subroots_to_nearest_repo_root() {
|
||||
let terminal_2 = EntityId::new();
|
||||
|
||||
let working_directories_handle = app.add_model(|_| WorkingDirectoriesModel::new());
|
||||
let roots: Vec<PathBuf> = working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group_id,
|
||||
vec![
|
||||
(terminal_1, repo_a.to_string_lossy().to_string()),
|
||||
(terminal_2, repo_b.to_string_lossy().to_string()),
|
||||
],
|
||||
vec![],
|
||||
Some(terminal_1),
|
||||
ctx,
|
||||
);
|
||||
let roots: Vec<LocalOrRemotePath> =
|
||||
working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group_id,
|
||||
vec![
|
||||
(terminal_1, LocalOrRemotePath::Local(repo_a.clone())),
|
||||
(terminal_2, LocalOrRemotePath::Local(repo_b.clone())),
|
||||
],
|
||||
vec![],
|
||||
Some(terminal_1),
|
||||
ctx,
|
||||
);
|
||||
|
||||
model
|
||||
.most_recent_directories_for_pane_group(pane_group_id)
|
||||
.expect("pane group exists")
|
||||
.map(|dir| dir.path)
|
||||
.collect()
|
||||
});
|
||||
model
|
||||
.most_recent_directories_for_pane_group(pane_group_id)
|
||||
.expect("pane group exists")
|
||||
.map(|dir| dir.path)
|
||||
.collect()
|
||||
});
|
||||
|
||||
assert_eq!(roots.len(), 1);
|
||||
assert_eq!(roots[0], canonical_repo_root);
|
||||
assert_eq!(roots[0], local(&canonical_repo_root));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,31 +98,495 @@ fn refresh_working_directories_preserves_non_repo_paths_and_dedupes() {
|
||||
let terminal_3 = EntityId::new();
|
||||
|
||||
let working_directories_handle = app.add_model(|_| WorkingDirectoriesModel::new());
|
||||
let roots: HashSet<PathBuf> = working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group_id,
|
||||
vec![
|
||||
(terminal_1, dir_1.to_string_lossy().to_string()),
|
||||
(terminal_2, dir_2.to_string_lossy().to_string()),
|
||||
// Duplicate root should be deduped.
|
||||
(terminal_3, dir_1.to_string_lossy().to_string()),
|
||||
],
|
||||
vec![],
|
||||
Some(terminal_1),
|
||||
ctx,
|
||||
);
|
||||
let roots: HashSet<LocalOrRemotePath> =
|
||||
working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group_id,
|
||||
vec![
|
||||
(terminal_1, LocalOrRemotePath::Local(dir_1.clone())),
|
||||
(terminal_2, LocalOrRemotePath::Local(dir_2.clone())),
|
||||
// Duplicate root should be deduped.
|
||||
(terminal_3, LocalOrRemotePath::Local(dir_1.clone())),
|
||||
],
|
||||
vec![],
|
||||
Some(terminal_1),
|
||||
ctx,
|
||||
);
|
||||
|
||||
model
|
||||
.most_recent_directories_for_pane_group(pane_group_id)
|
||||
.expect("pane group exists")
|
||||
.map(|dir| dir.path)
|
||||
.collect()
|
||||
});
|
||||
model
|
||||
.most_recent_directories_for_pane_group(pane_group_id)
|
||||
.expect("pane group exists")
|
||||
.map(|dir| dir.path)
|
||||
.collect()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
roots,
|
||||
HashSet::from_iter([canonical_1, canonical_2]),
|
||||
HashSet::from_iter([local(&canonical_1), local(&canonical_2)]),
|
||||
"should preserve non-repo roots and dedupe exact paths"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Regression test for GH-10598: the code review panel's manually selected
|
||||
// repository must be remembered per pane group so it survives leaving and
|
||||
// returning to an Agent session.
|
||||
#[test]
|
||||
fn selected_review_repo_is_remembered_per_pane_group() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
|
||||
let pane_group_a = EntityId::new();
|
||||
let pane_group_b = EntityId::new();
|
||||
let repo_x = PathBuf::from("/repos/x");
|
||||
let repo_y = PathBuf::from("/repos/y");
|
||||
let repo_p = PathBuf::from("/repos/p");
|
||||
|
||||
let working_directories_handle = app.add_model(|_| WorkingDirectoriesModel::new());
|
||||
|
||||
// Initially nothing is saved for either pane group.
|
||||
working_directories_handle.update(&mut app, |model, _ctx| {
|
||||
assert!(model.get_selected_review_repo(pane_group_a).is_none());
|
||||
assert!(model.get_selected_review_repo(pane_group_b).is_none());
|
||||
});
|
||||
|
||||
// User selects repo Y in pane group A.
|
||||
working_directories_handle.update(&mut app, |model, _ctx| {
|
||||
model.set_selected_review_repo(pane_group_a, local(&repo_y));
|
||||
});
|
||||
|
||||
// The selection for A is remembered and is independent from B's.
|
||||
working_directories_handle.update(&mut app, |model, _ctx| {
|
||||
assert_eq!(
|
||||
model.get_selected_review_repo(pane_group_a).cloned(),
|
||||
Some(local(&repo_y)),
|
||||
"pane group A should remember its manual selection"
|
||||
);
|
||||
assert!(
|
||||
model.get_selected_review_repo(pane_group_b).is_none(),
|
||||
"pane group B should be untouched by selections in A"
|
||||
);
|
||||
});
|
||||
|
||||
// User selects repo P in pane group B; A's selection must not change.
|
||||
working_directories_handle.update(&mut app, |model, _ctx| {
|
||||
model.set_selected_review_repo(pane_group_b, local(&repo_p));
|
||||
assert_eq!(
|
||||
model.get_selected_review_repo(pane_group_a).cloned(),
|
||||
Some(local(&repo_y)),
|
||||
"selecting in B must not clobber A's saved selection"
|
||||
);
|
||||
assert_eq!(
|
||||
model.get_selected_review_repo(pane_group_b).cloned(),
|
||||
Some(local(&repo_p)),
|
||||
);
|
||||
});
|
||||
|
||||
// Updating A's selection overwrites the previous saved value for A.
|
||||
working_directories_handle.update(&mut app, |model, _ctx| {
|
||||
model.set_selected_review_repo(pane_group_a, local(&repo_x));
|
||||
assert_eq!(
|
||||
model.get_selected_review_repo(pane_group_a).cloned(),
|
||||
Some(local(&repo_x)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Regression test for GH-10598: closing a tab (i.e. destroying a pane group)
|
||||
// must clean up the saved code-review-panel selection so it cannot leak into
|
||||
// or be confused with a future pane group that happens to reuse an EntityId.
|
||||
#[test]
|
||||
fn selected_review_repo_is_cleared_when_pane_group_is_removed() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
|
||||
let pane_group_id = EntityId::new();
|
||||
let repo = PathBuf::from("/repos/x");
|
||||
|
||||
let working_directories_handle = app.add_model(|_| WorkingDirectoriesModel::new());
|
||||
|
||||
working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.set_selected_review_repo(pane_group_id, local(&repo));
|
||||
assert_eq!(
|
||||
model.get_selected_review_repo(pane_group_id).cloned(),
|
||||
Some(local(&repo)),
|
||||
);
|
||||
|
||||
model.remove_pane_group(pane_group_id, ctx);
|
||||
assert!(
|
||||
model.get_selected_review_repo(pane_group_id).is_none(),
|
||||
"removing a pane group must clear its saved review-panel selection"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── PaneGroupRepositoryRoots unit tests ──────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pane_group_repository_roots_insert_updates_both_maps() {
|
||||
let mut roots = PaneGroupRepositoryRoots::default();
|
||||
let pane_a = EntityId::new();
|
||||
let path = local_str("/repos/x");
|
||||
|
||||
assert!(roots.insert(pane_a, path.clone()));
|
||||
// Re-inserting the same (pane_group, path) is a no-op.
|
||||
assert!(!roots.insert(pane_a, path.clone()));
|
||||
|
||||
let forward = roots.get(pane_a).expect("pane group registered");
|
||||
assert!(forward.contains(&path), "forward map must contain the path");
|
||||
assert_eq!(
|
||||
roots.path_to_pane_groups.get(&path).cloned(),
|
||||
Some(HashSet::from_iter([pane_a])),
|
||||
"reverse map must reflect the inserted pane group"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_group_repository_roots_set_paths_returns_only_truly_orphaned_paths() {
|
||||
let mut roots = PaneGroupRepositoryRoots::default();
|
||||
let pane_a = EntityId::new();
|
||||
let pane_b = EntityId::new();
|
||||
let shared = local_str("/repos/shared");
|
||||
let only_a = local_str("/repos/only-a");
|
||||
|
||||
// Both pane groups reference `shared`; only A references `only_a`.
|
||||
let orphans_a = roots.set_paths(pane_a, vec![shared.clone(), only_a.clone()]);
|
||||
assert!(orphans_a.is_empty(), "first insert never produces orphans");
|
||||
let orphans_b = roots.set_paths(pane_b, vec![shared.clone()]);
|
||||
assert!(orphans_b.is_empty());
|
||||
|
||||
// A drops both of its paths.
|
||||
let orphans = roots.set_paths(pane_a, Vec::<LocalOrRemotePath>::new());
|
||||
|
||||
// `shared` is still referenced by B, so it must not be reported as orphaned.
|
||||
// `only_a` was only referenced by A, so it must be.
|
||||
assert_eq!(
|
||||
orphans,
|
||||
vec![only_a.clone()],
|
||||
"shared paths must not appear in the orphan list"
|
||||
);
|
||||
|
||||
// Reverse map: `shared` only references B; `only_a` is gone entirely.
|
||||
assert_eq!(
|
||||
roots.path_to_pane_groups.get(&shared).cloned(),
|
||||
Some(HashSet::from_iter([pane_b])),
|
||||
);
|
||||
assert!(
|
||||
!roots.path_to_pane_groups.contains_key(&only_a),
|
||||
"orphaned path must be evicted from the reverse map"
|
||||
);
|
||||
|
||||
// Forward map: A is now empty (entry retained), B still owns `shared`.
|
||||
let a_forward = roots.get(pane_a).expect("pane group A entry retained");
|
||||
assert!(a_forward.is_empty(), "A's forward set should be empty");
|
||||
let b_forward = roots.get(pane_b).expect("pane group B entry retained");
|
||||
assert!(b_forward.contains(&shared));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_group_repository_roots_set_paths_preserves_insertion_order() {
|
||||
let mut roots = PaneGroupRepositoryRoots::default();
|
||||
let pane = EntityId::new();
|
||||
let x = local_str("/repos/x");
|
||||
let y = local_str("/repos/y");
|
||||
let z = local_str("/repos/z");
|
||||
|
||||
// Initial set in order x, y.
|
||||
let _ = roots.set_paths(pane, vec![x.clone(), y.clone()]);
|
||||
|
||||
// Replace with y, z. y should keep its position; z is appended; x is removed.
|
||||
let _ = roots.set_paths(pane, vec![y.clone(), z.clone()]);
|
||||
|
||||
let forward: Vec<LocalOrRemotePath> = roots
|
||||
.get(pane)
|
||||
.expect("pane group present")
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
forward,
|
||||
vec![y, z],
|
||||
"existing items must keep their order; new items appended after"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_group_repository_roots_remove_pane_group_returns_orphans() {
|
||||
let mut roots = PaneGroupRepositoryRoots::default();
|
||||
let pane_a = EntityId::new();
|
||||
let pane_b = EntityId::new();
|
||||
let shared = local_str("/repos/shared");
|
||||
let only_a = local_str("/repos/only-a");
|
||||
|
||||
let _ = roots.set_paths(pane_a, vec![shared.clone(), only_a.clone()]);
|
||||
let _ = roots.set_paths(pane_b, vec![shared.clone()]);
|
||||
|
||||
// Removing A while B still references `shared` only orphans `only_a`.
|
||||
let orphans: HashSet<LocalOrRemotePath> = roots
|
||||
.remove_pane_group(pane_a)
|
||||
.expect("pane group A was present")
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(orphans, HashSet::from_iter([only_a.clone()]));
|
||||
assert!(roots.get(pane_a).is_none(), "pane group A entry is gone");
|
||||
|
||||
// Removing B now orphans `shared`.
|
||||
let orphans = roots
|
||||
.remove_pane_group(pane_b)
|
||||
.expect("pane group B was present");
|
||||
assert_eq!(orphans, vec![shared.clone()]);
|
||||
assert!(roots.path_to_pane_groups.is_empty());
|
||||
assert!(roots.pane_group_to_paths.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_group_repository_roots_remove_unknown_pane_group_is_noop() {
|
||||
let mut roots = PaneGroupRepositoryRoots::default();
|
||||
let missing = EntityId::new();
|
||||
assert!(roots.remove_pane_group(missing).is_none());
|
||||
}
|
||||
|
||||
// ── End-to-end cleanup behavior tests ────────────────────────────
|
||||
|
||||
/// Helper for end-to-end cleanup tests: registers the singletons required by
|
||||
/// `DiffStateModel::new_local` (the `DirectoryWatcher`), prepares a temp dir,
|
||||
/// seeds it as a detected repo root, and returns the canonical repo path along
|
||||
/// with a fresh `WorkingDirectoriesModel` handle.
|
||||
fn setup_repo(
|
||||
app: &mut warpui::App,
|
||||
detected_repos: &warpui::ModelHandle<DetectedRepositories>,
|
||||
) -> (
|
||||
tempfile::TempDir,
|
||||
PathBuf,
|
||||
PathBuf,
|
||||
warpui::ModelHandle<WorkingDirectoriesModel>,
|
||||
) {
|
||||
app.add_singleton_model(DirectoryWatcher::new_for_testing);
|
||||
|
||||
let temp_dir = tempfile::TempDir::new().expect("temp dir");
|
||||
let repo_path = temp_dir.path().join("repo");
|
||||
fs::create_dir_all(&repo_path).expect("create repo dir");
|
||||
let canonical_repo = dunce::canonicalize(&repo_path).expect("canonical repo");
|
||||
|
||||
detected_repos.update(app, |repos, _ctx| {
|
||||
let canonical = warp_util::standardized_path::StandardizedPath::from_local_canonicalized(
|
||||
canonical_repo.as_path(),
|
||||
)
|
||||
.expect("canonicalized path");
|
||||
repos.insert_test_repo_root(canonical);
|
||||
});
|
||||
|
||||
let working_directories_handle = app.add_model(|_| WorkingDirectoriesModel::new());
|
||||
(
|
||||
temp_dir,
|
||||
repo_path,
|
||||
canonical_repo,
|
||||
working_directories_handle,
|
||||
)
|
||||
}
|
||||
|
||||
/// Regression: closing pane group A while pane group B still references the
|
||||
/// same repo must NOT drop the shared `DiffStateModel`. Before the fix,
|
||||
/// `drop_unused_diff_state_models` removed the cache entry unconditionally for
|
||||
/// any repo that left A's set, even when B still relied on it.
|
||||
#[test]
|
||||
fn shared_diff_state_model_survives_when_other_pane_group_still_references_repo() {
|
||||
App::test((), |mut app| async move {
|
||||
let detected_repos = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
|
||||
let pane_group_a = EntityId::new();
|
||||
let pane_group_b = EntityId::new();
|
||||
let terminal_a = EntityId::new();
|
||||
let terminal_b = EntityId::new();
|
||||
|
||||
let (_temp_dir, repo_path, canonical_repo, working_directories_handle) =
|
||||
setup_repo(&mut app, &detected_repos);
|
||||
|
||||
// Both pane groups land in the same repo.
|
||||
working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group_a,
|
||||
vec![(terminal_a, LocalOrRemotePath::Local(repo_path.clone()))],
|
||||
vec![],
|
||||
Some(terminal_a),
|
||||
ctx,
|
||||
);
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group_b,
|
||||
vec![(terminal_b, LocalOrRemotePath::Local(repo_path.clone()))],
|
||||
vec![],
|
||||
Some(terminal_b),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Open the shared diff state model.
|
||||
let initial_id = working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model
|
||||
.get_or_create_diff_state_model(local(&canonical_repo), None, ctx)
|
||||
.expect("local diff state model must be created")
|
||||
.id()
|
||||
});
|
||||
|
||||
// Pane group A's terminals go away (close the tab path).
|
||||
working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group_a,
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
// Re-fetching should return the SAME cached model (no re-creation).
|
||||
let after_id = working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model
|
||||
.get_or_create_diff_state_model(local(&canonical_repo), None, ctx)
|
||||
.expect("local diff state model must still be present")
|
||||
.id()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
initial_id, after_id,
|
||||
"shared DiffStateModel must survive when another pane group still references the repo"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// When the last pane group referencing a repo navigates away, the shared
|
||||
/// `DiffStateModel` is dropped from the cache, so a subsequent
|
||||
/// `get_or_create_diff_state_model` creates a fresh model.
|
||||
#[test]
|
||||
fn diff_state_model_is_dropped_when_no_pane_group_references_repo() {
|
||||
App::test((), |mut app| async move {
|
||||
let detected_repos = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
|
||||
let pane_group = EntityId::new();
|
||||
let terminal = EntityId::new();
|
||||
|
||||
let (_temp_dir, repo_path, canonical_repo, working_directories_handle) =
|
||||
setup_repo(&mut app, &detected_repos);
|
||||
|
||||
working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group,
|
||||
vec![(terminal, LocalOrRemotePath::Local(repo_path.clone()))],
|
||||
vec![],
|
||||
Some(terminal),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let initial_id = working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model
|
||||
.get_or_create_diff_state_model(local(&canonical_repo), None, ctx)
|
||||
.expect("local diff state model must be created")
|
||||
.id()
|
||||
});
|
||||
|
||||
// Only pane group leaves the repo → model is orphaned and dropped.
|
||||
working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.refresh_working_directories_for_pane_group(pane_group, vec![], vec![], None, ctx);
|
||||
});
|
||||
|
||||
let after_id = working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model
|
||||
.get_or_create_diff_state_model(local(&canonical_repo), None, ctx)
|
||||
.expect("local diff state model must be re-created")
|
||||
.id()
|
||||
});
|
||||
|
||||
assert_ne!(
|
||||
initial_id, after_id,
|
||||
"DiffStateModel should be dropped and re-created when no pane group references the repo"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// `remove_pane_group` (explicit tab teardown) must respect the same refcount
|
||||
/// semantics: pane group B's shared `DiffStateModel` survives when A is closed.
|
||||
#[test]
|
||||
fn remove_pane_group_does_not_drop_diff_state_model_shared_with_other_pane_group() {
|
||||
App::test((), |mut app| async move {
|
||||
let detected_repos = app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
|
||||
let pane_group_a = EntityId::new();
|
||||
let pane_group_b = EntityId::new();
|
||||
let terminal_a = EntityId::new();
|
||||
let terminal_b = EntityId::new();
|
||||
|
||||
let (_temp_dir, repo_path, canonical_repo, working_directories_handle) =
|
||||
setup_repo(&mut app, &detected_repos);
|
||||
|
||||
working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group_a,
|
||||
vec![(terminal_a, LocalOrRemotePath::Local(repo_path.clone()))],
|
||||
vec![],
|
||||
Some(terminal_a),
|
||||
ctx,
|
||||
);
|
||||
model.refresh_working_directories_for_pane_group(
|
||||
pane_group_b,
|
||||
vec![(terminal_b, LocalOrRemotePath::Local(repo_path.clone()))],
|
||||
vec![],
|
||||
Some(terminal_b),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let initial_id = working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model
|
||||
.get_or_create_diff_state_model(local(&canonical_repo), None, ctx)
|
||||
.expect("local diff state model must be created")
|
||||
.id()
|
||||
});
|
||||
|
||||
// Tear down pane group A.
|
||||
working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model.remove_pane_group(pane_group_a, ctx);
|
||||
});
|
||||
|
||||
let after_id = working_directories_handle.update(&mut app, |model, ctx| {
|
||||
model
|
||||
.get_or_create_diff_state_model(local(&canonical_repo), None, ctx)
|
||||
.expect("local diff state model must still be present")
|
||||
.id()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
initial_id, after_id,
|
||||
"removing pane group A must not drop a model that pane group B still references"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_selected_review_repo_removes_only_the_targeted_pane_group_entry() {
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
|
||||
let pane_group_a = EntityId::new();
|
||||
let pane_group_b = EntityId::new();
|
||||
let repo_a = PathBuf::from("/repos/a");
|
||||
let repo_b = PathBuf::from("/repos/b");
|
||||
|
||||
let working_directories_handle = app.add_model(|_| WorkingDirectoriesModel::new());
|
||||
|
||||
working_directories_handle.update(&mut app, |model, _ctx| {
|
||||
model.set_selected_review_repo(pane_group_a, local(&repo_a));
|
||||
model.set_selected_review_repo(pane_group_b, local(&repo_b));
|
||||
|
||||
model.clear_selected_review_repo(pane_group_a);
|
||||
|
||||
assert!(model.get_selected_review_repo(pane_group_a).is_none());
|
||||
assert_eq!(
|
||||
model.get_selected_review_repo(pane_group_b).cloned(),
|
||||
Some(local(&repo_b)),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user