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

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -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);
}
});
}
}
+394
View File
@@ -0,0 +1,394 @@
use warpui::{SingletonEntity, ViewContext};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
use crate::ai::agent_conversations_model::AgentConversationsModel;
use crate::ai::ambient_agents::{
AmbientAgentLiveSessionState, AmbientAgentTask, AmbientAgentTaskId,
};
use crate::ai::blocklist::agent_view::AgentViewEntryOrigin;
use crate::ai::blocklist::history_model::CloudConversationData;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::pane_group::{AmbientAgentViewModelHandleExt, PaneGroup, PaneId};
use crate::terminal::view::load_ai_conversation::{
RestoreConversationEntryBehavior, RestoredAIConversation,
};
/// How to hydrate a restored hidden remote-child pane given its
/// [`AmbientAgentTask`]. See [`decide_remote_child_hydration_action`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::pane_group) enum RemoteChildHydrationAction {
/// Attachable live session — join it in place.
LiveAttach,
/// No live session but a server conversation token is available;
/// `task_is_terminal` controls whether the post-merge step inserts a
/// conversation-ended tombstone (only terminal runs do).
LoadTranscript {
server_token: ServerConversationToken,
task_is_terminal: bool,
},
/// Neither live nor cloud transcript available; fall through to
/// `attach_ambient_session_and_maybe_tombstone`. `task_is_terminal`
/// gates the tombstone so an `ActiveUnattachable` run with no server
/// token isn't visually marked as ended.
Fallback { task_is_terminal: bool },
}
/// Pure decision function backing [`PaneGroup::attempt_remote_child_hydration`].
/// Free-standing so it's unit-testable without a `PaneGroup`.
pub(in crate::pane_group) fn decide_remote_child_hydration_action(
task: &AmbientAgentTask,
) -> RemoteChildHydrationAction {
let live_session_state = task.active_live_session_state();
if matches!(
live_session_state,
AmbientAgentLiveSessionState::Attachable { .. }
) {
return RemoteChildHydrationAction::LiveAttach;
}
let task_is_terminal = matches!(live_session_state, AmbientAgentLiveSessionState::Inactive);
// Empty/whitespace tokens would drive a no-op cloud fetch followed by
// a misleading tombstone; route them to `Fallback` instead.
let server_token = task
.conversation_id()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(|t| ServerConversationToken::new(t.to_string()));
match server_token {
Some(server_token) => RemoteChildHydrationAction::LoadTranscript {
server_token,
task_is_terminal,
},
None => RemoteChildHydrationAction::Fallback { task_is_terminal },
}
}
impl PaneGroup {
/// Task-backed restore path for the `is_remote_child` branch of
/// `create_hidden_child_agent_pane`. Always creates the hidden ambient
/// pane, registers it in `child_agent_panes` keyed by the placeholder's
/// local `AIConversationId`, then dispatches via
/// `attempt_remote_child_hydration` (or queues a pending entry while
/// task data is fetched).
///
/// Idempotent: skipped when the placeholder already has a live tracked
/// pane, so repeat calls from `restore_missing_child_agent_panes_for_parent`
/// — including while the initial async hydration is still in flight —
/// don't create a duplicate hidden pane and orphan the first one.
pub(super) fn hydrate_task_backed_hidden_child_pane(
&mut self,
child_conversation: AIConversation,
parent_pane_id: PaneId,
task_id: AmbientAgentTaskId,
ctx: &mut ViewContext<Self>,
) {
let child_id = child_conversation.id();
// Idempotency guard — see fn doc.
if let Some(existing_pane_id) = self.child_agent_panes.get(&child_id).copied() {
if self.has_pane_id(existing_pane_id) {
return;
}
}
let new_pane_id =
self.insert_ambient_agent_pane_hidden_for_child_agent(parent_pane_id, ctx);
let Some(new_terminal_view) = self.terminal_view_from_pane_id(new_pane_id, ctx) else {
log::error!("Failed to get terminal view for remote child agent pane {child_id:?}");
self.discard_pane(new_pane_id.into(), ctx);
return;
};
// Restore the placeholder so the pane has parent linkage + agent
// name before task-backed hydration runs.
let mut restored = false;
new_terminal_view.update(ctx, |terminal_view, ctx| {
terminal_view.restore_conversation_after_view_creation(
RestoredAIConversation::new(child_conversation),
true,
RestoreConversationEntryBehavior::PreserveAgentViewState,
ctx,
);
terminal_view.enter_agent_view(
None,
Some(child_id),
AgentViewEntryOrigin::CloudAgent,
ctx,
);
restored = terminal_view
.ambient_agent_view_model()
.into_optional_handle()
.is_some();
});
if !restored {
log::error!(
"Failed to restore remote child agent pane {child_id:?}: missing ambient agent view model"
);
self.discard_pane(new_pane_id.into(), ctx);
return;
}
// Placeholder's local id stays the canonical `child_agent_panes`
// key across live-attach and transcript hydration.
self.child_agent_panes.insert(child_id, new_pane_id.into());
let task_now = AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| {
model.get_or_async_fetch_task_data(&task_id, ctx)
});
if task_now.is_none() {
// Task data not yet cached: queue a pending hydration and
// attempt a live-attach in the meantime so streaming runs are
// not stalled while waiting on the fetch.
self.pending_remote_child_hydrations
.insert(task_id, child_id);
self.ensure_pending_ambient_restoration_subscription(ctx);
self.apply_existing_ambient_task_to_pane(new_pane_id.into(), child_id, task_id, ctx);
return;
}
self.attempt_remote_child_hydration(child_id, task_id, ctx);
}
/// Dispatches the hydration action chosen by
/// [`decide_remote_child_hydration_action`]. Inspects the
/// [`AmbientAgentTask`] directly because `resolve_open_action` collapses
/// the navigate-to-local and hydrate-cloud-transcript intents into one
/// variant once `conversations_by_id` carries the placeholder.
fn attempt_remote_child_hydration(
&mut self,
child_id: AIConversationId,
task_id: AmbientAgentTaskId,
ctx: &mut ViewContext<Self>,
) {
let Some(pane_id) = self
.child_agent_panes
.get(&child_id)
.copied()
.filter(|pane_id| self.has_pane_id(*pane_id))
else {
return;
};
let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else {
// Defensive: callers only reach here after `get_task_data`
// returned `Some`. If it's gone now, leave the pending entry
// alone so the next `TasksUpdated` can re-drive.
return;
};
match decide_remote_child_hydration_action(&task) {
RemoteChildHydrationAction::LiveAttach => {
self.apply_existing_ambient_task_to_pane(pane_id, child_id, task_id, ctx);
}
RemoteChildHydrationAction::LoadTranscript {
server_token,
task_is_terminal,
} => {
self.hydrate_remote_child_transcript_in_place(
pane_id,
child_id,
task_id,
server_token,
task_is_terminal,
ctx,
);
}
RemoteChildHydrationAction::Fallback { task_is_terminal } => {
// No live session, no server token: attach to the
// (possibly empty) ambient session, then insert the
// conversation-ended tombstone iff the run is terminal so
// an `ActiveUnattachable` child isn't visually ended.
self.attach_ambient_session_and_maybe_tombstone(
pane_id,
child_id,
task_id,
task_is_terminal,
ctx,
);
}
}
}
/// Attaches the hidden child pane's ambient agent view model to the
/// live ambient session for `task_id`. Wrapper around
/// `AmbientAgentViewModel::enter_viewing_existing_session` that also
/// sets the active conversation id.
fn apply_existing_ambient_task_to_pane(
&mut self,
pane_id: PaneId,
child_id: AIConversationId,
task_id: AmbientAgentTaskId,
ctx: &mut ViewContext<Self>,
) {
let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) else {
return;
};
terminal_view.update(ctx, |terminal_view, ctx| {
let Some(ambient_agent_view_model) = terminal_view
.ambient_agent_view_model()
.into_optional_handle()
.cloned()
else {
return;
};
ambient_agent_view_model.update(ctx, |model, ctx| {
model.set_conversation_id(Some(child_id));
model.enter_viewing_existing_session(task_id, ctx);
});
});
}
/// Fetches the cloud transcript identified by `server_token`, hydrates
/// the placeholder via
/// `hydrate_remote_child_placeholder_with_cloud_transcript`, and
/// re-restores the merged conversation into the pane.
/// `task_is_terminal` gates the conversation-ended tombstone in
/// `attach_ambient_session_and_maybe_tombstone` so an
/// `ActiveUnattachable` run isn't visually marked as ended.
fn hydrate_remote_child_transcript_in_place(
&mut self,
pane_id: PaneId,
child_id: AIConversationId,
task_id: AmbientAgentTaskId,
server_token: ServerConversationToken,
task_is_terminal: bool,
ctx: &mut ViewContext<Self>,
) {
let history_handle = BlocklistAIHistoryModel::handle(ctx);
let future = history_handle.update(ctx, |history_model, ctx| {
history_model.load_conversation_by_server_token(&server_token, ctx)
});
ctx.spawn(future, move |group, conversation, ctx| {
// Guard against a stale target while the fetch was in flight:
// the pane id must still be the canonical one for `child_id`
// AND the pane's terminal view must still be displaying it.
let still_canonical = group
.child_agent_panes
.get(&child_id)
.copied()
.is_some_and(|p| p == pane_id && group.has_pane_id(p));
if !still_canonical {
return;
}
let terminal_view_active_conversation = group
.terminal_view_from_pane_id(pane_id, ctx)
.and_then(|tv| tv.as_ref(ctx).active_conversation_id(ctx));
if terminal_view_active_conversation != Some(child_id) {
return;
}
match conversation {
Some(CloudConversationData::Oz(cloud)) => {
let tasks: Vec<warp_multi_agent_api::Task> = cloud
.all_tasks()
.filter_map(|task| task.source().cloned())
.collect();
let cloud_conversation = *cloud;
let merge_result =
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
history.hydrate_remote_child_placeholder_with_cloud_transcript(
child_id,
tasks,
cloud_conversation,
)
});
match merge_result {
Ok(merged) => {
if let Some(terminal_view) =
group.terminal_view_from_pane_id(pane_id, ctx)
{
terminal_view.update(ctx, |view, ctx| {
view.restore_conversation_after_view_creation(
RestoredAIConversation::new(merged),
true,
RestoreConversationEntryBehavior::PreserveAgentViewState,
ctx,
);
});
}
}
Err(err) => {
log::warn!(
"hydrate_remote_child_placeholder_with_cloud_transcript failed for {child_id:?}: {err:#}"
);
}
}
}
Some(CloudConversationData::CLIAgent(_)) | None => {
// Non-Oz transcript or fetch failure — the post-match
// call handles attach + conditional tombstone.
}
}
// Uniform post-match step so the `task_is_terminal` gate
// applies to all three branches above.
group.attach_ambient_session_and_maybe_tombstone(
pane_id,
child_id,
task_id,
task_is_terminal,
ctx,
);
});
}
/// Post-match step for `hydrate_remote_child_transcript_in_place`:
/// attach the live ambient session and insert the conversation-ended
/// tombstone iff `task_is_terminal`. Centralised so the gate stays
/// consistent across the Ok-merge / Err-merge / non-Oz fallback arms.
fn attach_ambient_session_and_maybe_tombstone(
&mut self,
pane_id: PaneId,
child_id: AIConversationId,
task_id: AmbientAgentTaskId,
task_is_terminal: bool,
ctx: &mut ViewContext<Self>,
) {
self.apply_existing_ambient_task_to_pane(pane_id, child_id, task_id, ctx);
if !task_is_terminal {
return;
}
if let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) {
terminal_view.update(ctx, |view, ctx| {
view.insert_conversation_ended_tombstone_with_resolved_cta(ctx);
});
}
}
/// Drains entries from `pending_remote_child_hydrations` for which task
/// data is now available, hydrating each hidden child pane in place.
pub(in crate::pane_group) fn process_pending_remote_child_hydrations(
&mut self,
ctx: &mut ViewContext<Self>,
) {
if self.pending_remote_child_hydrations.is_empty() {
return;
}
let ready_tasks: Vec<_> = self
.pending_remote_child_hydrations
.keys()
.filter(|task_id| {
AgentConversationsModel::as_ref(ctx)
.get_task_data(task_id)
.is_some()
})
.copied()
.collect();
for task_id in ready_tasks {
let Some(placeholder_conversation_id) =
self.pending_remote_child_hydrations.remove(&task_id)
else {
continue;
};
self.attempt_remote_child_hydration(placeholder_conversation_id, task_id, ctx);
}
}
}
@@ -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);
}
}
}
+2 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -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>>,
+4 -6
View File
@@ -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>>,
+7 -9
View File
@@ -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 {}
}
+25 -29
View File
@@ -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>>,
+20 -32
View File
@@ -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 })
}
+4 -8
View File
@@ -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>>,
+23 -29
View File
@@ -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::*;
+166 -35
View File
@@ -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),
}
}
+64 -54
View File
@@ -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>,
},
}
+5 -5
View File
@@ -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>,
+15 -19
View File
@@ -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(),
})
}
+5 -8
View File
@@ -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.;
+109 -71
View File
@@ -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;
@@ -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 {
+36 -20
View File
@@ -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.
+44 -26
View File
@@ -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> {
+15 -15
View File
@@ -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
View File
@@ -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();
+83
View File
@@ -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
+514 -38
View File
@@ -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)),
);
});
});
}