Significant progress. Performing cleanup now
This commit is contained in:
@@ -131,6 +131,13 @@ pub struct RequestParams {
|
||||
pub parent_agent_id: Option<String>,
|
||||
/// The display name for this agent (e.g. "Agent 1"), assigned by the orchestrator.
|
||||
pub agent_name: Option<String>,
|
||||
/// Full Bedrock conversation history for direct Bedrock calls.
|
||||
/// When present, the Bedrock path uses this instead of extracting from task_context.
|
||||
pub bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
|
||||
/// Populated by the Bedrock path after building the message list.
|
||||
/// Contains the full messages sent (old history + new input) so the controller
|
||||
/// can store them back into the conversation for the next request cycle.
|
||||
pub bedrock_messages_sent: std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>,
|
||||
}
|
||||
|
||||
pub type Event = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
|
||||
@@ -317,6 +324,8 @@ impl RequestParams {
|
||||
.map(|id| id.to_string()),
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
bedrock_message_history: Vec::new(),
|
||||
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ pub async fn generate_multi_agent_output(
|
||||
api_keys.allow_use_of_warp_credits = params.allow_use_of_warp_credits_with_byok;
|
||||
}
|
||||
|
||||
let request = api::Request {
|
||||
let mut request = api::Request {
|
||||
task_context: Some(api::request::TaskContext {
|
||||
tasks: params.tasks,
|
||||
}),
|
||||
@@ -183,8 +183,22 @@ pub async fn generate_multi_agent_output(
|
||||
logger.log_protobuf_input(&request);
|
||||
}
|
||||
|
||||
let messages =
|
||||
crate::ai::bedrock::convert_request::extract_messages_from_request(&request);
|
||||
// Build message list from bedrock_message_history + new input messages.
|
||||
// The history contains all prior messages. We extract only NEW messages
|
||||
// from the current request input and append them.
|
||||
let new_input_messages =
|
||||
crate::ai::bedrock::convert_request::extract_new_input_messages(&request);
|
||||
|
||||
let mut messages = params.bedrock_message_history.clone();
|
||||
if !new_input_messages.is_empty() {
|
||||
log::info!(
|
||||
"[bedrock] Appending {} new input messages to history of {}",
|
||||
new_input_messages.len(),
|
||||
messages.len()
|
||||
);
|
||||
messages.extend(new_input_messages);
|
||||
}
|
||||
|
||||
let system_prompt =
|
||||
crate::ai::bedrock::convert_request::extract_system_prompt(&request);
|
||||
let tools = crate::ai::bedrock::convert_request::extract_tools(&request);
|
||||
@@ -249,17 +263,23 @@ pub async fn generate_multi_agent_output(
|
||||
&model_id,
|
||||
&task_id,
|
||||
needs_create_task,
|
||||
messages,
|
||||
messages.clone(),
|
||||
system_prompt,
|
||||
tools,
|
||||
64000,
|
||||
None,
|
||||
true,
|
||||
diagnostic_logger,
|
||||
params.bedrock_messages_sent.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => {
|
||||
// Store the input messages we sent so the controller can
|
||||
// persist them. The stream will append the assistant response.
|
||||
if let Ok(mut sent) = params.bedrock_messages_sent.lock() {
|
||||
*sent = messages;
|
||||
}
|
||||
let output_stream = stream.take_until(cancellation_rx);
|
||||
return Ok(Box::pin(output_stream));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
|
||||
root_task_id: None,
|
||||
parent_agent_id: None,
|
||||
agent_name: None,
|
||||
bedrock_message_history: Vec::new(),
|
||||
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -228,6 +228,12 @@ pub struct AIConversation {
|
||||
/// event log. Used on restore to resume event delivery without
|
||||
/// re-delivering already-processed events.
|
||||
last_event_sequence: Option<i64>,
|
||||
|
||||
/// Accumulated message history for direct Bedrock conversations.
|
||||
/// Contains the full ordered sequence of user messages, assistant responses,
|
||||
/// tool calls, and tool results sent to/received from Bedrock across all
|
||||
/// request cycles. This is the source of truth for what Bedrock sees.
|
||||
bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
|
||||
}
|
||||
|
||||
pub(crate) fn artifact_from_fork_proto(
|
||||
@@ -278,6 +284,7 @@ impl AIConversation {
|
||||
parent_conversation_id: None,
|
||||
is_remote_child: false,
|
||||
last_event_sequence: None,
|
||||
bedrock_message_history: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +466,7 @@ impl AIConversation {
|
||||
parent_conversation_id,
|
||||
is_remote_child: false,
|
||||
last_event_sequence,
|
||||
bedrock_message_history: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -466,6 +474,18 @@ impl AIConversation {
|
||||
self.id
|
||||
}
|
||||
|
||||
pub fn bedrock_message_history(&self) -> &[crate::ai::bedrock::convert::ConversationMessage] {
|
||||
&self.bedrock_message_history
|
||||
}
|
||||
|
||||
pub fn bedrock_message_history_mut(&mut self) -> &mut Vec<crate::ai::bedrock::convert::ConversationMessage> {
|
||||
&mut self.bedrock_message_history
|
||||
}
|
||||
|
||||
pub fn append_to_bedrock_history(&mut self, messages: Vec<crate::ai::bedrock::convert::ConversationMessage>) {
|
||||
self.bedrock_message_history.extend(messages);
|
||||
}
|
||||
|
||||
/// Assigns fresh exchange IDs to all exchanges in this conversation.
|
||||
/// Used when forking conversations to avoid ID collisions with persisted blocks.
|
||||
pub fn reassign_exchange_ids(&mut self) {
|
||||
|
||||
@@ -290,7 +290,7 @@ static DEFAULT_TIPS: LazyLock<Vec<AgentTip>> = LazyLock::new(|| {
|
||||
kind: AgentTipKind::Context,
|
||||
},
|
||||
AgentTip {
|
||||
description: "Warpify a remote SSH session to enable Oz inside that environment.".to_string(),
|
||||
description: "Galaxify a remote SSH session to enable the agent inside that environment.".to_string(),
|
||||
link: Some("https://docs.warp.dev/terminal/warpify".to_string()),
|
||||
binding_name: None,
|
||||
action: None,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
use aws_config::BehaviorVersion;
|
||||
@@ -113,6 +113,7 @@ impl BedrockClient {
|
||||
temperature: Option<f32>,
|
||||
cross_region_inference: bool,
|
||||
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
|
||||
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
||||
) -> Result<ResponseStream, BedrockError> {
|
||||
let effective_model_id = if cross_region_inference {
|
||||
apply_cross_region_prefix(model_id, &self.region)
|
||||
@@ -192,6 +193,7 @@ impl BedrockClient {
|
||||
task_id.to_string(),
|
||||
needs_create_task,
|
||||
diagnostic_logger,
|
||||
messages_sent,
|
||||
)))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use aws_sdk_bedrockruntime::types::{
|
||||
ContentBlock, ConversationRole, InferenceConfiguration, Message as BedrockMessage,
|
||||
SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema, ToolResultBlock,
|
||||
ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock,
|
||||
CachePointBlock, CachePointType, ContentBlock, ConversationRole, InferenceConfiguration,
|
||||
Message as BedrockMessage, SystemContentBlock, Tool, ToolConfiguration, ToolInputSchema,
|
||||
ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification, ToolUseBlock,
|
||||
};
|
||||
use aws_smithy_types::Document;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -15,7 +15,7 @@ pub struct ConvertedRequest {
|
||||
pub tool_config: Option<ToolConfiguration>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConversationMessage {
|
||||
pub role: MessageRole,
|
||||
pub content: MessageContent,
|
||||
@@ -208,7 +208,30 @@ fn convert_messages(messages: Vec<ConversationMessage>) -> Vec<BedrockMessage> {
|
||||
result.push(message);
|
||||
}
|
||||
|
||||
coalesce_consecutive_roles(result)
|
||||
let mut messages = coalesce_consecutive_roles(result);
|
||||
|
||||
// Add a cache point to the second-to-last message (the conversation prefix
|
||||
// that is stable between requests). This allows Bedrock to cache all prior
|
||||
// context and only process the latest message as new input tokens.
|
||||
if messages.len() >= 2 {
|
||||
let cache_idx = messages.len() - 2;
|
||||
let msg = messages.remove(cache_idx);
|
||||
let mut content = msg.content().to_vec();
|
||||
content.push(ContentBlock::CachePoint(
|
||||
CachePointBlock::builder()
|
||||
.r#type(CachePointType::Default)
|
||||
.build()
|
||||
.expect("valid cache point"),
|
||||
));
|
||||
let cached_msg = BedrockMessage::builder()
|
||||
.role(msg.role().clone())
|
||||
.set_content(Some(content))
|
||||
.build()
|
||||
.expect("valid message with cache point");
|
||||
messages.insert(cache_idx, cached_msg);
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
fn coalesce_consecutive_roles(messages: Vec<BedrockMessage>) -> Vec<BedrockMessage> {
|
||||
@@ -245,7 +268,15 @@ fn coalesce_consecutive_roles(messages: Vec<BedrockMessage>) -> Vec<BedrockMessa
|
||||
fn convert_system_prompt(system_prompt: Option<String>) -> Vec<SystemContentBlock> {
|
||||
match system_prompt {
|
||||
Some(prompt) if !prompt.is_empty() => {
|
||||
vec![SystemContentBlock::Text(prompt)]
|
||||
vec![
|
||||
SystemContentBlock::Text(prompt),
|
||||
SystemContentBlock::CachePoint(
|
||||
CachePointBlock::builder()
|
||||
.r#type(CachePointType::Default)
|
||||
.build()
|
||||
.expect("valid cache point"),
|
||||
),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
@@ -277,7 +308,7 @@ fn build_tool_config(tools: Vec<ToolDefinition>) -> Option<ToolConfiguration> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tool_specs: Vec<Tool> = tools
|
||||
let mut tool_specs: Vec<Tool> = tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
let input_schema_doc = json_to_document(tool.input_schema);
|
||||
@@ -292,6 +323,13 @@ fn build_tool_config(tools: Vec<ToolDefinition>) -> Option<ToolConfiguration> {
|
||||
})
|
||||
.collect();
|
||||
|
||||
tool_specs.push(Tool::CachePoint(
|
||||
CachePointBlock::builder()
|
||||
.r#type(CachePointType::Default)
|
||||
.build()
|
||||
.expect("valid cache point"),
|
||||
));
|
||||
|
||||
Some(
|
||||
ToolConfiguration::builder()
|
||||
.set_tools(Some(tool_specs))
|
||||
|
||||
@@ -1,79 +1,366 @@
|
||||
use warp_multi_agent_api as api;
|
||||
|
||||
use super::convert::{ConversationMessage, MessageContent, MessageRole, ToolDefinition};
|
||||
use super::convert::{ConversationMessage, ContentPart, MessageContent, MessageRole, ToolDefinition};
|
||||
|
||||
/// Extract new input messages from the current request and convert them directly
|
||||
/// to ConversationMessage format for the Bedrock message history.
|
||||
/// This extracts UserQuery and ToolCallResult from request.input only.
|
||||
pub fn extract_new_input_messages(request: &api::Request) -> Vec<ConversationMessage> {
|
||||
let mut results = Vec::new();
|
||||
let Some(input) = &request.input else {
|
||||
return results;
|
||||
};
|
||||
let Some(input_type) = &input.r#type else {
|
||||
return results;
|
||||
};
|
||||
|
||||
match input_type {
|
||||
api::request::input::Type::UserInputs(user_inputs) => {
|
||||
let mut tool_results: Vec<ConversationMessage> = Vec::new();
|
||||
for user_input in &user_inputs.inputs {
|
||||
match &user_input.input {
|
||||
Some(api::request::input::user_inputs::user_input::Input::ToolCallResult(
|
||||
result,
|
||||
)) => {
|
||||
if !result.tool_call_id.is_empty() {
|
||||
let content = extract_tool_result_content(result);
|
||||
tool_results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: result.tool_call_id.clone(),
|
||||
content,
|
||||
is_error: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(api::request::input::user_inputs::user_input::Input::UserQuery(
|
||||
query,
|
||||
)) => {
|
||||
if !query.query.is_empty() {
|
||||
results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(query.query.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !tool_results.is_empty() {
|
||||
if tool_results.len() == 1 {
|
||||
results.extend(tool_results);
|
||||
} else {
|
||||
let parts: Vec<ContentPart> = tool_results
|
||||
.into_iter()
|
||||
.map(|tr| match tr.content {
|
||||
MessageContent::ToolResult { tool_use_id, content, is_error } => {
|
||||
ContentPart::ToolResult { tool_use_id, content, is_error }
|
||||
}
|
||||
_ => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::MultiPart(parts),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
api::request::input::Type::UserQuery(query) => {
|
||||
if !query.query.is_empty() {
|
||||
results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(query.query.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
api::request::input::Type::InitProjectRules(_) => {
|
||||
results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(
|
||||
"Initialize this project. Analyze the codebase structure and files, \
|
||||
generate an AGENTS.md file documenting project conventions and setup \
|
||||
instructions, and offer to create a development environment configuration. \
|
||||
Use the available tools to inspect the project before responding."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
api::request::input::Type::CreateEnvironment(env) => {
|
||||
let repo_info = if env.repo_paths.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" Repositories: {}", env.repo_paths.join(", "))
|
||||
};
|
||||
results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(format!(
|
||||
"Create a development environment for this project. \
|
||||
Set up necessary dependencies, configuration files, and tooling.{repo_info}"
|
||||
)),
|
||||
});
|
||||
}
|
||||
api::request::input::Type::CreateNewProject(project) => {
|
||||
results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(format!(
|
||||
"Create a new project: {}",
|
||||
project.query,
|
||||
)),
|
||||
});
|
||||
}
|
||||
api::request::input::Type::CloneRepository(repo) => {
|
||||
results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(format!(
|
||||
"Clone the repository at {} and set it up for development.",
|
||||
repo.url,
|
||||
)),
|
||||
});
|
||||
}
|
||||
api::request::input::Type::ResumeConversation(_) => {
|
||||
results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(
|
||||
"Continue where we left off. Review the conversation history and proceed with the next steps."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
api::request::input::Type::CodeReview(_) => {
|
||||
results.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(
|
||||
"Review the following code changes and provide detailed feedback on correctness, style, and potential issues."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
for msg in &results {
|
||||
let desc = match &msg.content {
|
||||
MessageContent::Text(t) => format!("Text({}chars)", t.len()),
|
||||
MessageContent::ToolResult { tool_use_id, .. } => format!("ToolResult({})", tool_use_id),
|
||||
MessageContent::MultiPart(parts) => format!("MultiPart({} parts)", parts.len()),
|
||||
_ => "Other".to_string(),
|
||||
};
|
||||
log::info!("[bedrock] New input message: role={:?}, content={}", msg.role, desc);
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
/// For the Bedrock direct path: inject all input messages (user queries and tool call
|
||||
/// results) into the task's messages so they persist in conversation history for future
|
||||
/// requests. Without this, inputs are lost after the current request cycle because they
|
||||
/// only exist in `request.input` and are never stored in `task_context.tasks[].messages`.
|
||||
pub fn inject_input_messages_into_task(request: &mut api::Request) {
|
||||
let input_messages: Vec<api::Message> = extract_input_messages(request);
|
||||
if input_messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[bedrock] Injecting {} input messages into task history",
|
||||
input_messages.len()
|
||||
);
|
||||
|
||||
if let Some(task_context) = &mut request.task_context {
|
||||
if let Some(task) = task_context.tasks.first_mut() {
|
||||
task.messages.extend(input_messages);
|
||||
} else {
|
||||
// No task exists yet — create one to hold the messages
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
task_context.tasks.push(api::Task {
|
||||
id: task_id,
|
||||
messages: input_messages,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let task_id = uuid::Uuid::new_v4().to_string();
|
||||
request.task_context = Some(api::request::TaskContext {
|
||||
tasks: vec![api::Task {
|
||||
id: task_id,
|
||||
messages: input_messages,
|
||||
..Default::default()
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
|
||||
let mut results = Vec::new();
|
||||
let Some(input) = &request.input else {
|
||||
return results;
|
||||
};
|
||||
let Some(input_type) = &input.r#type else {
|
||||
return results;
|
||||
};
|
||||
|
||||
let task_id = request
|
||||
.task_context
|
||||
.as_ref()
|
||||
.and_then(|tc| tc.tasks.first())
|
||||
.map(|t| t.id.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
match input_type {
|
||||
api::request::input::Type::UserInputs(user_inputs) => {
|
||||
for user_input in &user_inputs.inputs {
|
||||
match &user_input.input {
|
||||
Some(api::request::input::user_inputs::user_input::Input::ToolCallResult(
|
||||
result,
|
||||
)) => {
|
||||
if !result.tool_call_id.is_empty() {
|
||||
let content = extract_tool_result_content(result);
|
||||
results.push(api::Message {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
task_id: task_id.clone(),
|
||||
request_id: String::new(),
|
||||
timestamp: None,
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(api::message::Message::ToolCallResult(
|
||||
api::message::ToolCallResult {
|
||||
tool_call_id: result.tool_call_id.clone(),
|
||||
context: None,
|
||||
result: Some(
|
||||
api::message::tool_call_result::Result::Server(
|
||||
api::message::tool_call_result::ServerResult {
|
||||
serialized_result: content,
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
)),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(api::request::input::user_inputs::user_input::Input::UserQuery(
|
||||
query,
|
||||
)) => {
|
||||
if !query.query.is_empty() {
|
||||
results.push(api::Message {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
task_id: task_id.clone(),
|
||||
request_id: String::new(),
|
||||
timestamp: None,
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(api::message::Message::UserQuery(
|
||||
api::message::UserQuery {
|
||||
query: query.query.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
api::request::input::Type::UserQuery(query) => {
|
||||
if !query.query.is_empty() {
|
||||
results.push(api::Message {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
task_id: task_id.clone(),
|
||||
request_id: String::new(),
|
||||
timestamp: None,
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(api::message::Message::UserQuery(
|
||||
api::message::UserQuery {
|
||||
query: query.query.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
});
|
||||
}
|
||||
}
|
||||
api::request::input::Type::ToolCallResult(result) => {
|
||||
if !result.tool_call_id.is_empty() {
|
||||
let content = extract_tool_result_content(result);
|
||||
results.push(api::Message {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
task_id: task_id.clone(),
|
||||
request_id: String::new(),
|
||||
timestamp: None,
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(api::message::Message::ToolCallResult(
|
||||
api::message::ToolCallResult {
|
||||
tool_call_id: result.tool_call_id.clone(),
|
||||
context: None,
|
||||
result: Some(
|
||||
api::message::tool_call_result::Result::Server(
|
||||
api::message::tool_call_result::ServerResult {
|
||||
serialized_result: content,
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
)),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
pub fn extract_messages_from_request(request: &api::Request) -> Vec<ConversationMessage> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
if let Some(task_context) = &request.task_context {
|
||||
log::info!(
|
||||
"[bedrock-debug] extract_messages: {} tasks in task_context",
|
||||
task_context.tasks.len()
|
||||
);
|
||||
for task in &task_context.tasks {
|
||||
log::info!(
|
||||
"[bedrock-debug] extract_messages: task '{}' has {} messages",
|
||||
task.id,
|
||||
task.messages.len()
|
||||
);
|
||||
for msg in &task.messages {
|
||||
let msg_type = msg.message.as_ref().map(|m| match m {
|
||||
api::message::Message::UserQuery(_) => "UserQuery",
|
||||
api::message::Message::AgentOutput(_) => "AgentOutput",
|
||||
api::message::Message::ToolCall(_) => "ToolCall",
|
||||
api::message::Message::ToolCallResult(_) => "ToolCallResult",
|
||||
api::message::Message::AgentReasoning(_) => "AgentReasoning",
|
||||
_ => "Other",
|
||||
}).unwrap_or("None");
|
||||
log::info!(
|
||||
"[bedrock-debug] extract_messages: msg id='{}' type={}",
|
||||
msg.id,
|
||||
msg_type
|
||||
);
|
||||
if let Some(converted) = convert_proto_message(msg) {
|
||||
messages.push(converted);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("[bedrock-debug] extract_messages: NO task_context in request!");
|
||||
}
|
||||
|
||||
if let Some(input) = &request.input {
|
||||
if let Some(input_type) = &input.r#type {
|
||||
#[allow(deprecated)]
|
||||
match input_type {
|
||||
api::request::input::Type::UserInputs(user_inputs) => {
|
||||
for user_input in &user_inputs.inputs {
|
||||
if let Some(input_variant) = &user_input.input {
|
||||
match input_variant {
|
||||
api::request::input::user_inputs::user_input::Input::UserQuery(
|
||||
query,
|
||||
) => {
|
||||
if !query.query.is_empty() {
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(query.query.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
api::request::input::user_inputs::user_input::Input::ToolCallResult(
|
||||
result,
|
||||
) => {
|
||||
let content = extract_tool_result_content(result);
|
||||
if !result.tool_call_id.is_empty() {
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: result.tool_call_id.clone(),
|
||||
content,
|
||||
is_error: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
api::request::input::Type::UserQuery(query) => {
|
||||
if !query.query.is_empty() {
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(query.query.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
api::request::input::Type::ToolCallResult(result) => {
|
||||
let content = extract_tool_result_content(result);
|
||||
if !result.tool_call_id.is_empty() {
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: result.tool_call_id.clone(),
|
||||
content,
|
||||
is_error: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// UserInputs (UserQuery + ToolCallResult) are already injected
|
||||
// into task messages by inject_input_messages_into_task().
|
||||
api::request::input::Type::UserInputs(_) => {}
|
||||
api::request::input::Type::UserQuery(_) => {}
|
||||
api::request::input::Type::ToolCallResult(_) => {}
|
||||
api::request::input::Type::InitProjectRules(_) => {
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
@@ -159,6 +446,14 @@ pub fn extract_messages_from_request(request: &api::Request) -> Vec<Conversation
|
||||
|
||||
ensure_starts_with_user_message(&mut messages);
|
||||
ensure_tool_results_paired(&mut messages);
|
||||
log::info!(
|
||||
"[bedrock-debug] extract_messages: TOTAL {} messages to send to Bedrock (User={}, Assistant={}, ToolResult={}, ToolUse={})",
|
||||
messages.len(),
|
||||
messages.iter().filter(|m| m.role == MessageRole::User && matches!(&m.content, MessageContent::Text(_))).count(),
|
||||
messages.iter().filter(|m| m.role == MessageRole::Assistant && matches!(&m.content, MessageContent::Text(_))).count(),
|
||||
messages.iter().filter(|m| matches!(&m.content, MessageContent::ToolResult { .. })).count(),
|
||||
messages.iter().filter(|m| matches!(&m.content, MessageContent::ToolUse { .. })).count(),
|
||||
);
|
||||
messages
|
||||
}
|
||||
|
||||
@@ -331,11 +626,11 @@ fn tool_definition_for_name(name: &str) -> ToolDefinition {
|
||||
},
|
||||
"read_files" => ToolDefinition {
|
||||
name: "read_files".to_string(),
|
||||
description: "Read the contents of one or more files.".to_string(),
|
||||
description: "Read the contents of one or more files. ALWAYS pass all files you need in a single call rather than making multiple separate calls.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"files": { "type": "array", "items": { "type": "string" }, "description": "File paths to read" }
|
||||
"files": { "type": "array", "items": { "type": "string" }, "description": "File paths to read. Include ALL files you need in one call for efficiency." }
|
||||
},
|
||||
"required": ["files"]
|
||||
}),
|
||||
@@ -353,11 +648,11 @@ fn tool_definition_for_name(name: &str) -> ToolDefinition {
|
||||
},
|
||||
"grep" => ToolDefinition {
|
||||
name: "grep".to_string(),
|
||||
description: "Search for patterns in files using grep.".to_string(),
|
||||
description: "Search for patterns in files. Pass all search patterns in one call.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": { "type": "array", "items": { "type": "string" }, "description": "Search patterns" },
|
||||
"queries": { "type": "array", "items": { "type": "string" }, "description": "Search patterns. Include ALL patterns you need in one call." },
|
||||
"path": { "type": "string", "description": "Directory to search in" }
|
||||
},
|
||||
"required": ["queries"]
|
||||
@@ -365,7 +660,7 @@ fn tool_definition_for_name(name: &str) -> ToolDefinition {
|
||||
},
|
||||
"file_glob" => ToolDefinition {
|
||||
name: "file_glob".to_string(),
|
||||
description: "Find files matching glob patterns.".to_string(),
|
||||
description: "Find files matching glob patterns. Pass all patterns in one call.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -544,6 +839,9 @@ fn format_tool_call_result(result: &api::message::ToolCallResult) -> String {
|
||||
_ => "Read files completed.".to_string(),
|
||||
}
|
||||
}
|
||||
api::message::tool_call_result::Result::Server(server_result) => {
|
||||
server_result.serialized_result.clone()
|
||||
}
|
||||
_ => "Tool completed successfully.".to_string(),
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use futures::StreamExt;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
@@ -553,6 +555,7 @@ impl AgentSimulation {
|
||||
8192,
|
||||
None,
|
||||
false,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
)
|
||||
.await
|
||||
.expect("converse_stream should succeed");
|
||||
@@ -1137,6 +1140,7 @@ async fn test_reasoning_model_produces_substantial_output() {
|
||||
8192,
|
||||
None,
|
||||
false,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
)
|
||||
.await
|
||||
.expect("converse_stream should succeed");
|
||||
@@ -1257,6 +1261,7 @@ async fn test_event_sequence_matches_controller_expectations() {
|
||||
100,
|
||||
None,
|
||||
false,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
)
|
||||
.await
|
||||
.expect("should connect");
|
||||
@@ -1369,6 +1374,7 @@ async fn test_followup_turn_does_not_send_create_task() {
|
||||
100,
|
||||
None,
|
||||
false,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
)
|
||||
.await
|
||||
.expect("should connect");
|
||||
@@ -1446,6 +1452,7 @@ async fn run_slash_command_test(
|
||||
4096,
|
||||
None,
|
||||
true,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
)
|
||||
.await
|
||||
.expect("stream should connect");
|
||||
@@ -1671,6 +1678,7 @@ async fn test_slash_resume_conversation() {
|
||||
256,
|
||||
None,
|
||||
true,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
)
|
||||
.await
|
||||
.expect("stream should connect");
|
||||
@@ -1780,6 +1788,7 @@ async fn test_empty_messages_safety_check() {
|
||||
100,
|
||||
None,
|
||||
true,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
)
|
||||
.await
|
||||
.expect("safety fallback message should work");
|
||||
@@ -1929,6 +1938,7 @@ async fn test_full_proto_round_trip_with_tool_history() {
|
||||
1024,
|
||||
None,
|
||||
true,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use futures::StreamExt;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -62,6 +64,7 @@ async fn collect_stream_output(
|
||||
8192,
|
||||
None,
|
||||
false,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
)
|
||||
.await
|
||||
.expect("converse_stream should succeed");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamOutput;
|
||||
use aws_sdk_bedrockruntime::types::{
|
||||
@@ -13,6 +13,7 @@ use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
|
||||
use crate::ai::agent::api::Event;
|
||||
use crate::server::server_api::AIApiError;
|
||||
|
||||
use super::convert::{ContentPart, MessageContent, MessageRole, ConversationMessage};
|
||||
use super::diagnostic::BedrockDiagnosticLogger;
|
||||
|
||||
pub fn bedrock_stream_to_response_events(
|
||||
@@ -20,6 +21,7 @@ pub fn bedrock_stream_to_response_events(
|
||||
task_id: String,
|
||||
needs_create_task: bool,
|
||||
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
|
||||
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
||||
) -> BoxStream<'static, Event> {
|
||||
let request_id = Uuid::new_v4().to_string();
|
||||
let conversation_id = Uuid::new_v4().to_string();
|
||||
@@ -55,6 +57,10 @@ pub fn bedrock_stream_to_response_events(
|
||||
let mut output_tokens: i32 = 0;
|
||||
let mut stop_reason = stream_finished::Reason::Done(stream_finished::Done {});
|
||||
|
||||
// Track full assistant text and tool calls for bedrock_message_history
|
||||
let mut history_text = String::new();
|
||||
let mut history_tool_calls: Vec<ContentPart> = Vec::new();
|
||||
|
||||
let mut event_count: u32 = 0;
|
||||
loop {
|
||||
match output.stream.recv().await {
|
||||
@@ -109,6 +115,7 @@ pub fn bedrock_stream_to_response_events(
|
||||
match d {
|
||||
ContentBlockDelta::Text(text) => {
|
||||
log::info!("[bedrock-debug] Event #{event_count}: TextDelta ({} chars): {:?}", text.len(), &text[..text.len().min(80)]);
|
||||
history_text.push_str(text);
|
||||
if text_flushed {
|
||||
let msg_id = current_text_message_id.as_ref().unwrap();
|
||||
let append = build_append_text(
|
||||
@@ -149,6 +156,14 @@ pub fn bedrock_stream_to_response_events(
|
||||
log::info!("[bedrock-debug] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { ¤t_tool_use_id });
|
||||
if !current_tool_use_id.is_empty() {
|
||||
log::debug!("[bedrock] Tool call complete: {} ({})", current_tool_name, current_tool_use_id);
|
||||
// Track for bedrock_message_history
|
||||
let input_json: serde_json::Value = serde_json::from_str(¤t_tool_input_json)
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
history_tool_calls.push(ContentPart::ToolUse {
|
||||
tool_use_id: current_tool_use_id.clone(),
|
||||
name: current_tool_name.clone(),
|
||||
input: input_json,
|
||||
});
|
||||
if let Some(ref logger) = diagnostic_logger {
|
||||
logger.log_stream_event(&format!(
|
||||
"ToolCall: name={}, id={}, input={}",
|
||||
@@ -242,6 +257,49 @@ pub fn bedrock_stream_to_response_events(
|
||||
}
|
||||
|
||||
log::info!("[bedrock] Stream finished: input_tokens={input_tokens}, output_tokens={output_tokens}");
|
||||
|
||||
// Build and store the assistant message into bedrock_messages_sent
|
||||
// so the controller can persist it as part of conversation history.
|
||||
{
|
||||
let mut parts: Vec<ContentPart> = Vec::new();
|
||||
if !history_text.is_empty() {
|
||||
parts.push(ContentPart::Text(history_text));
|
||||
}
|
||||
parts.extend(history_tool_calls);
|
||||
|
||||
if !parts.is_empty() {
|
||||
let assistant_msg = if parts.len() == 1 {
|
||||
match parts.remove(0) {
|
||||
ContentPart::Text(t) => ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Text(t),
|
||||
},
|
||||
ContentPart::ToolUse { tool_use_id, name, input } => ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::ToolUse { tool_use_id, name, input },
|
||||
},
|
||||
other => ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::MultiPart(vec![other]),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::MultiPart(parts),
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(mut sent) = messages_sent.lock() {
|
||||
sent.push(assistant_msg);
|
||||
log::info!(
|
||||
"[bedrock] Stored assistant message in history. Total messages: {}",
|
||||
sent.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref logger) = diagnostic_logger {
|
||||
let stop_reason_str = match &stop_reason {
|
||||
stream_finished::Reason::Done(_) => "EndTurn",
|
||||
|
||||
@@ -829,6 +829,9 @@ impl BlocklistAIActionExecutor {
|
||||
}
|
||||
|
||||
fn should_autoexecute(&self, input: ExecuteActionInput, ctx: &mut ModelContext<Self>) -> bool {
|
||||
if cfg!(feature = "bedrock_smoke_test") {
|
||||
return true;
|
||||
}
|
||||
match input.action.action {
|
||||
AIAgentActionType::RequestCommandOutput { .. }
|
||||
| AIAgentActionType::WriteToLongRunningShellCommand { .. }
|
||||
|
||||
@@ -48,27 +48,6 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pub fn agent_view_bg_fill(app: &AppContext) -> Fill {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
appearance.theme().surface_overlay_1()
|
||||
|
||||
@@ -13,7 +13,6 @@ use galaxyui::{
|
||||
AppContext, Element, SingletonEntity,
|
||||
};
|
||||
|
||||
use crate::ai::blocklist::agent_view::ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE;
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
cmd_or_ctrl_shift,
|
||||
@@ -193,16 +192,9 @@ pub fn render_agent_shortcuts_view(
|
||||
app,
|
||||
));
|
||||
|
||||
// Use cloud keystroke (cmd+opt+enter) for cloud mode, regular keystroke (cmd+enter) otherwise.
|
||||
let new_conversation_keystroke = if context.is_cloud_agent {
|
||||
ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE.clone()
|
||||
} else {
|
||||
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE.clone()
|
||||
};
|
||||
|
||||
shortcuts.push(render_shortcut(
|
||||
ShortcutProps {
|
||||
keystroke: new_conversation_keystroke.clone(),
|
||||
keystroke: ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE.clone(),
|
||||
text: "start a new conversation".into(),
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
@@ -25,7 +25,6 @@ use crate::{
|
||||
agent_view::{
|
||||
agent_view_bg_color, AgentViewController, AgentViewEntryOrigin,
|
||||
ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
},
|
||||
history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel},
|
||||
},
|
||||
@@ -723,21 +722,6 @@ fn render_body(props: ZeroStateBodyProps<'_>, app: &AppContext) -> Vec<Box<dyn E
|
||||
)]),
|
||||
app,
|
||||
),
|
||||
render_standard_message(
|
||||
Message::new(vec![MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(
|
||||
ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE.clone(),
|
||||
),
|
||||
MessageItem::text("start a new cloud agent conversation"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(TerminalAction::EnterCloudAgentView);
|
||||
},
|
||||
state_handles.start_cloud_conversation.clone(),
|
||||
)]),
|
||||
app,
|
||||
),
|
||||
render_standard_message(
|
||||
Message::new(vec![MessageItem::clickable(
|
||||
vec![
|
||||
|
||||
@@ -1904,6 +1904,7 @@ impl BlocklistAIController {
|
||||
active_tasks,
|
||||
parent_agent_id,
|
||||
agent_name,
|
||||
bedrock_history,
|
||||
) = {
|
||||
let Some(conversation) = history_model
|
||||
.as_ref(ctx)
|
||||
@@ -1926,6 +1927,7 @@ impl BlocklistAIController {
|
||||
active_tasks,
|
||||
conversation.parent_agent_id().map(str::to_string),
|
||||
conversation.agent_name().map(str::to_string),
|
||||
conversation.bedrock_message_history().to_vec(),
|
||||
)
|
||||
};
|
||||
|
||||
@@ -1992,6 +1994,7 @@ impl BlocklistAIController {
|
||||
);
|
||||
request_params.parent_agent_id = parent_agent_id;
|
||||
request_params.agent_name = agent_name;
|
||||
request_params.bedrock_message_history = bedrock_history;
|
||||
|
||||
let server_conversation_token_for_identifiers =
|
||||
conversation_data.server_conversation_token.clone();
|
||||
@@ -2299,6 +2302,26 @@ impl BlocklistAIController {
|
||||
did_input_contain_user_query,
|
||||
ctx,
|
||||
);
|
||||
|
||||
// After the stream finishes, persist the full message
|
||||
// history (input + assistant response) from the Arc back
|
||||
// into the conversation for the next request cycle.
|
||||
let messages_sent_arc = response_stream.as_ref(ctx).bedrock_messages_sent().clone();
|
||||
let new_history = messages_sent_arc.lock().ok().and_then(|sent| {
|
||||
if sent.is_empty() { None } else { Some(sent.clone()) }
|
||||
});
|
||||
if let Some(new_history) = new_history {
|
||||
let history_model = BlocklistAIHistoryModel::handle(ctx);
|
||||
history_model.update(ctx, |history_model, _| {
|
||||
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
|
||||
*conversation.bedrock_message_history_mut() = new_history;
|
||||
log::info!(
|
||||
"[bedrock] Updated conversation bedrock history: {} messages",
|
||||
conversation.bedrock_message_history().len()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
warp_multi_agent_api::response_event::Type::ClientActions(actions) => {
|
||||
let client_actions = actions.actions;
|
||||
|
||||
@@ -155,6 +155,10 @@ impl ResponseStream {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn bedrock_messages_sent(&self) -> &std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>> {
|
||||
&self.params.bedrock_messages_sent
|
||||
}
|
||||
|
||||
/// Returns true if we should attempt to resume the conversation after the stream finishes.
|
||||
pub fn should_resume_conversation_after_stream_finished(&self) -> bool {
|
||||
self.should_resume_conversation_after_stream_finished
|
||||
|
||||
@@ -126,26 +126,7 @@ impl AutoupdateState {
|
||||
}
|
||||
|
||||
pub fn register(ctx: &mut AppContext, server_api: Arc<ServerApi>) {
|
||||
ctx.add_singleton_model(move |ctx| {
|
||||
let state_handle = WindowManager::handle(ctx);
|
||||
let mut me = Self::new(server_api);
|
||||
if FeatureFlag::Autoupdate.is_enabled()
|
||||
&& AppExecutionMode::as_ref(ctx).can_autoupdate()
|
||||
{
|
||||
// Initiate the polling loop
|
||||
me.poll_for_update(ctx);
|
||||
// Queue a possible update check when the app gets activated, i.e. focused.
|
||||
ctx.subscribe_to_model(&state_handle, |me, event, ctx| {
|
||||
let windowing::StateEvent::ValueChanged { current, previous } = event;
|
||||
if previous.stage == ApplicationStage::Inactive
|
||||
&& current.stage == ApplicationStage::Active
|
||||
{
|
||||
me.enqueue_request(RequestType::DailyCheck, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
me
|
||||
});
|
||||
ctx.add_singleton_model(move |_ctx| Self::new(server_api));
|
||||
}
|
||||
|
||||
/// Check if any requests are pending. If there are and we're ready to submit a new request,
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use galaxyui::r#async::Timer;
|
||||
use galaxyui::ViewHandle;
|
||||
use galaxyui::WindowId;
|
||||
|
||||
use crate::ai::agent::{AIAgentOutputStatus, AIAgentTextSection, FinishedAIAgentOutput};
|
||||
use crate::pane_group::PaneGroup;
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::workspace::Workspace;
|
||||
use crate::BlocklistAIHistoryModel;
|
||||
|
||||
const TARGET_DIR: &str = "~/GIT/stitcher/stitcher";
|
||||
const AGENT_QUERY: &str = r#"/agent Analyze this project and respond with EXACTLY this structured format at the end of your response:
|
||||
|
||||
Answer: <a 2-3 sentence summary of how this project handles video uploads and media conversions>
|
||||
ProjectDescription: <a 1 sentence description of what this project is>
|
||||
|
||||
You MUST include both "Answer:" and "ProjectDescription:" fields in your final response. Use tools to explore the codebase first, then provide your structured answer."#;
|
||||
|
||||
const INITIAL_DELAY: Duration = Duration::from_secs(5);
|
||||
const CD_SETTLE_DELAY: Duration = Duration::from_secs(3);
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(2000);
|
||||
const MAX_WAIT: Duration = Duration::from_secs(300);
|
||||
|
||||
pub fn schedule(ctx: &mut galaxyui::ViewContext<Workspace>) {
|
||||
log::info!("[smoke-test] Bedrock smoke test scheduled — starting in {:?}", INITIAL_DELAY);
|
||||
|
||||
ctx.spawn(
|
||||
async move { Timer::after(INITIAL_DELAY).await },
|
||||
|_ws: &mut Workspace, _, ctx| {
|
||||
let Some(window_id) = ctx.window_ids().next() else {
|
||||
log::error!("[smoke-test] No window, aborting");
|
||||
std::process::exit(1);
|
||||
};
|
||||
run_cd(ctx, window_id);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn run_cd(ctx: &mut galaxyui::ViewContext<Workspace>, window_id: WindowId) {
|
||||
log::info!("[smoke-test] cd {TARGET_DIR}");
|
||||
|
||||
let terminal_view = get_terminal_view(ctx, window_id);
|
||||
terminal_view.update(ctx, |view, ctx| {
|
||||
view.write_to_pty(format!("cd {TARGET_DIR}\n").into_bytes(), ctx);
|
||||
});
|
||||
|
||||
ctx.spawn(
|
||||
async move { Timer::after(CD_SETTLE_DELAY).await },
|
||||
move |_ws: &mut Workspace, _, ctx| {
|
||||
submit_query(ctx, window_id);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_query(ctx: &mut galaxyui::ViewContext<Workspace>, window_id: WindowId) {
|
||||
log::info!("[smoke-test] Submitting query...");
|
||||
|
||||
let terminal_view = get_terminal_view(ctx, window_id);
|
||||
terminal_view.update(ctx, |view, ctx| {
|
||||
let input = view.input().clone();
|
||||
input.update(ctx, |input, ctx| {
|
||||
input.submit_queued_prompt(AGENT_QUERY.to_string(), ctx);
|
||||
});
|
||||
});
|
||||
|
||||
log::info!("[smoke-test] Polling for completion (max {:?})", MAX_WAIT);
|
||||
poll(ctx, window_id, std::time::Instant::now());
|
||||
}
|
||||
|
||||
fn poll(
|
||||
ctx: &mut galaxyui::ViewContext<Workspace>,
|
||||
window_id: WindowId,
|
||||
start: std::time::Instant,
|
||||
) {
|
||||
ctx.spawn(
|
||||
async move { Timer::after(POLL_INTERVAL).await },
|
||||
move |_ws: &mut Workspace, _, ctx| {
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed > MAX_WAIT {
|
||||
log::error!("[smoke-test] TIMEOUT after {:?}", elapsed);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let terminal_view = get_terminal_view(ctx, window_id);
|
||||
if let Some(full_text) = get_finished_text(ctx, &terminal_view) {
|
||||
log::info!("[smoke-test] === CONVERSATION COMPLETE ({:.1}s) ===", elapsed.as_secs_f64());
|
||||
log::info!("[smoke-test] Full response length: {} chars", full_text.len());
|
||||
|
||||
let answer = extract_field(&full_text, "Answer:");
|
||||
let project_desc = extract_field(&full_text, "ProjectDescription:");
|
||||
|
||||
match (&answer, &project_desc) {
|
||||
(Some(a), Some(p)) => {
|
||||
log::info!("[smoke-test] ========================================");
|
||||
log::info!("[smoke-test] Answer: {}", a);
|
||||
log::info!("[smoke-test] ProjectDescription: {}", p);
|
||||
log::info!("[smoke-test] ========================================");
|
||||
log::info!("[smoke-test] === TEST PASSED ===");
|
||||
std::process::exit(0);
|
||||
}
|
||||
_ => {
|
||||
log::warn!("[smoke-test] ========================================");
|
||||
if let Some(a) = &answer {
|
||||
log::info!("[smoke-test] Answer: {}", a);
|
||||
} else {
|
||||
log::warn!("[smoke-test] MISSING: Answer field not found in response");
|
||||
}
|
||||
if let Some(p) = &project_desc {
|
||||
log::info!("[smoke-test] ProjectDescription: {}", p);
|
||||
} else {
|
||||
log::warn!("[smoke-test] MISSING: ProjectDescription field not found in response");
|
||||
}
|
||||
log::warn!("[smoke-test] ========================================");
|
||||
log::warn!("[smoke-test] Full text dump:");
|
||||
for line in full_text.lines() {
|
||||
log::warn!("[smoke-test] {}", line);
|
||||
}
|
||||
log::warn!("[smoke-test] === TEST FAILED (missing structured fields) ===");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
poll(ctx, window_id, start);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn extract_field<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with(prefix) {
|
||||
let value = trimmed[prefix.len()..].trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_finished_text(
|
||||
ctx: &galaxyui::ViewContext<Workspace>,
|
||||
terminal_view: &ViewHandle<TerminalView>,
|
||||
) -> Option<String> {
|
||||
use galaxyui::SingletonEntity;
|
||||
|
||||
let view_id = terminal_view.id();
|
||||
BlocklistAIHistoryModel::handle(ctx).read(ctx, |history, _| {
|
||||
let conv = history.active_conversation(view_id)?;
|
||||
|
||||
if conv.status().is_in_progress() {
|
||||
log::info!("[smoke-test] Still running...");
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut all_text = String::new();
|
||||
for exchange in conv.exchanges_reversed() {
|
||||
match &exchange.output_status {
|
||||
AIAgentOutputStatus::Finished {
|
||||
finished_output: FinishedAIAgentOutput::Success { output },
|
||||
} => {
|
||||
let output = output.get();
|
||||
for text_section in output.text_from_agent_output() {
|
||||
for section in &text_section.sections {
|
||||
match section {
|
||||
AIAgentTextSection::PlainText { text } => {
|
||||
all_text.push_str(text.text());
|
||||
all_text.push('\n');
|
||||
}
|
||||
AIAgentTextSection::Code { code, .. } => {
|
||||
all_text.push_str(code);
|
||||
all_text.push('\n');
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AIAgentOutputStatus::Finished {
|
||||
finished_output: FinishedAIAgentOutput::Error { error, .. },
|
||||
} => {
|
||||
log::error!("[smoke-test] Conversation finished with error: {error}");
|
||||
return None;
|
||||
}
|
||||
AIAgentOutputStatus::Finished {
|
||||
finished_output: FinishedAIAgentOutput::Cancelled { .. },
|
||||
} => {
|
||||
log::error!("[smoke-test] Conversation was cancelled");
|
||||
return None;
|
||||
}
|
||||
_ => {
|
||||
log::info!("[smoke-test] Exchange still in progress...");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if all_text.is_empty() {
|
||||
log::warn!("[smoke-test] Conversation finished but no text output found");
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(all_text)
|
||||
})
|
||||
}
|
||||
|
||||
fn get_terminal_view(
|
||||
ctx: &galaxyui::ViewContext<Workspace>,
|
||||
window_id: WindowId,
|
||||
) -> ViewHandle<TerminalView> {
|
||||
let pane_group: ViewHandle<PaneGroup> = ctx
|
||||
.views_of_type(window_id)
|
||||
.expect("[smoke-test] views for window")
|
||||
.first()
|
||||
.expect("[smoke-test] pane group")
|
||||
.clone();
|
||||
|
||||
pane_group.read(ctx, |pg, ctx| {
|
||||
pg.terminal_views(ctx)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("[smoke-test] should have at least one terminal view")
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
mod ai;
|
||||
mod alloc;
|
||||
mod antivirus;
|
||||
#[cfg(feature = "bedrock_smoke_test")]
|
||||
mod bedrock_smoke_test;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod app_menus;
|
||||
mod app_services;
|
||||
|
||||
@@ -592,10 +592,6 @@ fn all_commands() -> Vec<StaticCommand> {
|
||||
commands.push(PR_COMMENTS);
|
||||
}
|
||||
|
||||
if FeatureFlag::CloudMode.is_enabled() && FeatureFlag::CloudModeFromLocalSession.is_enabled() {
|
||||
commands.push(CLOUD_AGENT.clone());
|
||||
}
|
||||
|
||||
if FeatureFlag::InlineProfileSelector.is_enabled() {
|
||||
commands.push(PROFILE.clone());
|
||||
}
|
||||
|
||||
@@ -72,7 +72,8 @@ impl SettingsWidget for AboutPageWidget {
|
||||
"bundled/svg/bedrock.svg"
|
||||
};
|
||||
|
||||
let version = ChannelState::app_version().unwrap_or("v#.##.###");
|
||||
let version = ChannelState::app_version()
|
||||
.unwrap_or(concat!("v", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
let version_text = ui_builder
|
||||
.span(version.to_string())
|
||||
|
||||
@@ -28,9 +28,9 @@ use crate::settings::{
|
||||
CanUseWarpCreditsWithByok, CodeSettings, CodebaseContextEnabled, FileBasedMcpEnabled,
|
||||
GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory, IntelligentAutosuggestionsEnabled,
|
||||
MemoryEnabled, NLDInTerminalEnabled, NaturalLanguageAutosuggestionsEnabled,
|
||||
OrchestrationEnabled, RuleSuggestionsEnabled, SharedBlockTitleGenerationEnabled,
|
||||
RuleSuggestionsEnabled, SharedBlockTitleGenerationEnabled,
|
||||
ShouldRenderCLIAgentToolbar, ShouldRenderUseAgentToolbarForUserCommands,
|
||||
ShouldShowOzUpdatesInZeroState, ShowAgentTips, ShowConversationHistory, ShowHintText,
|
||||
ShowAgentTips, ShowConversationHistory, ShowHintText,
|
||||
ThinkingDisplayMode, VoiceInputEnabled, WarpDriveContextEnabled,
|
||||
};
|
||||
use crate::terminal::session_settings::{SessionSettings, SessionSettingsChangedEvent};
|
||||
@@ -269,29 +269,6 @@ pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
.with_enabled(|| FeatureFlag::AgentTips.is_enabled())],
|
||||
app,
|
||||
);
|
||||
ToggleSettingActionPair::add_toggle_setting_action_pairs_as_bindings(
|
||||
vec![ToggleSettingActionPair::custom(
|
||||
SettingActionPairDescriptions::new(
|
||||
"Show Oz changelog in new agent conversation view",
|
||||
"Hide Oz changelog in new agent conversation view",
|
||||
),
|
||||
builder(SettingsAction::AI(
|
||||
AISettingsPageAction::ToggleShowOzUpdatesInZeroState,
|
||||
)),
|
||||
SettingActionPairContexts::new(
|
||||
context.clone()
|
||||
& id!(flags::IS_ANY_AI_ENABLED)
|
||||
& !id!(flags::SHOW_OZ_UPDATES_IN_ZERO_STATE_FLAG),
|
||||
context.clone()
|
||||
& id!(flags::IS_ANY_AI_ENABLED)
|
||||
& id!(flags::SHOW_OZ_UPDATES_IN_ZERO_STATE_FLAG),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.with_group(bindings::BindingGroup::WarpAi)
|
||||
.with_enabled(|| FeatureFlag::AgentView.is_enabled())],
|
||||
app,
|
||||
);
|
||||
{
|
||||
use crate::settings::ThinkingDisplayMode;
|
||||
use galaxyui::keymap::FixedBinding;
|
||||
@@ -1468,12 +1445,8 @@ impl AISettingsPageView {
|
||||
widgets.push(Box::new(VoiceWidget::default()));
|
||||
}
|
||||
widgets.push(Box::new(CLIAgentWidget::default()));
|
||||
widgets.push(Box::new(ApiKeysWidget::new(ctx)));
|
||||
widgets.push(Box::new(AgentAttributionWidget::default()));
|
||||
widgets.push(Box::new(OtherAIWidget::default()));
|
||||
if FeatureFlag::AgentModeComputerUse.is_enabled() {
|
||||
widgets.push(Box::new(CloudAgentComputerUseWidget::default()));
|
||||
}
|
||||
}
|
||||
Some(AISubpage::WarpAgent) => {
|
||||
// Oz page: global toggle + Active AI + Input + Other
|
||||
@@ -1507,12 +1480,8 @@ impl AISettingsPageView {
|
||||
if voice_supported {
|
||||
widgets.push(Box::new(VoiceWidget::default()));
|
||||
}
|
||||
widgets.push(Box::new(ApiKeysWidget::new(ctx)));
|
||||
widgets.push(Box::new(AgentAttributionWidget::default()));
|
||||
widgets.push(Box::new(OtherAIWidget::default()));
|
||||
if FeatureFlag::AgentModeComputerUse.is_enabled() {
|
||||
widgets.push(Box::new(CloudAgentComputerUseWidget::default()));
|
||||
}
|
||||
}
|
||||
Some(AISubpage::Profiles) => {
|
||||
if !FeatureFlag::UsageBasedPricing.is_enabled() {
|
||||
@@ -2076,7 +2045,6 @@ pub enum AISettingsPageAction {
|
||||
ToggleCodebaseContext,
|
||||
ToggleShowInputHintText,
|
||||
ToggleShowAgentTips,
|
||||
ToggleShowOzUpdatesInZeroState,
|
||||
SetThinkingDisplayMode(ThinkingDisplayMode),
|
||||
AttemptLoginGatedUpgrade,
|
||||
RemoveCLIAgentToolbarEnabledCommand(String),
|
||||
@@ -2116,13 +2084,11 @@ pub enum AISettingsPageAction {
|
||||
SetBedrockProfile(String),
|
||||
ToggleBedrockCrossRegionInference,
|
||||
ToggleBedrockFallbackToWarp,
|
||||
ToggleCloudAgentComputerUse,
|
||||
ToggleFileBasedMcp,
|
||||
ToggleIncludeAgentCommandsInHistory,
|
||||
ToggleAgentAttribution,
|
||||
#[cfg(feature = "local_fs")]
|
||||
SetConversationLayout(crate::util::file::external_editor::settings::OpenConversationPreference),
|
||||
ToggleOrchestration,
|
||||
ToggleShowConversationHistory,
|
||||
ToggleAutoToggleRichInput,
|
||||
ToggleAutoOpenRichInputOnCLIAgentStart,
|
||||
@@ -2504,14 +2470,6 @@ impl TypedActionView for AISettingsPageView {
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::ToggleShowOzUpdatesInZeroState => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.should_show_oz_updates_in_zero_state
|
||||
.toggle_and_save_value(ctx));
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::SetThinkingDisplayMode(mode) => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.thinking_display_mode.set_value(*mode, ctx));
|
||||
@@ -2831,14 +2789,6 @@ impl TypedActionView for AISettingsPageView {
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::ToggleCloudAgentComputerUse => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
.cloud_agent_computer_use_enabled
|
||||
.toggle_and_save_value(ctx));
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::ToggleFileBasedMcp => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.file_based_mcp_enabled.toggle_and_save_value(ctx));
|
||||
@@ -2872,12 +2822,6 @@ impl TypedActionView for AISettingsPageView {
|
||||
);
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::ToggleOrchestration => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings.orchestration_enabled.toggle_and_save_value(ctx));
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
AISettingsPageAction::ToggleShowConversationHistory => {
|
||||
AISettings::handle(ctx).update(ctx, |settings, ctx| {
|
||||
report_if_error!(settings
|
||||
@@ -5345,7 +5289,6 @@ impl SettingsWidget for VoiceWidget {
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct OtherAIWidget {
|
||||
show_oz_updates_in_zero_state_toggle: SwitchStateHandle,
|
||||
use_agent_footer_toggle: SwitchStateHandle,
|
||||
show_conversation_history_toggle: SwitchStateHandle,
|
||||
}
|
||||
@@ -5405,15 +5348,6 @@ impl SettingsWidget for OtherAIWidget {
|
||||
|
||||
if FeatureFlag::AgentView.is_enabled() {
|
||||
let mut agent_view_column = Flex::column()
|
||||
.with_child(render_ai_setting_toggle::<ShouldShowOzUpdatesInZeroState>(
|
||||
"Show Oz changelog in new conversation view",
|
||||
AISettingsPageAction::ToggleShowOzUpdatesInZeroState,
|
||||
*ai_settings.should_show_oz_updates_in_zero_state,
|
||||
is_toggleable,
|
||||
self.show_oz_updates_in_zero_state_toggle.clone(),
|
||||
&view.local_only_icon_tooltip_states,
|
||||
app,
|
||||
))
|
||||
.with_child(render_ai_setting_toggle::<ShouldRenderUseAgentToolbarForUserCommands>(
|
||||
"Show \"Use Agent\" footer",
|
||||
AISettingsPageAction::ToggleUseAgentToolbar,
|
||||
@@ -5902,462 +5836,6 @@ impl SettingsWidget for AgentAttributionWidget {
|
||||
#[path = "ai_page_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Default)]
|
||||
struct CloudAgentComputerUseWidget {
|
||||
toggle: SwitchStateHandle,
|
||||
orchestration_toggle: SwitchStateHandle,
|
||||
}
|
||||
|
||||
impl SettingsWidget for CloudAgentComputerUseWidget {
|
||||
type View = AISettingsPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"oz cloud agent computer use orchestration multi-agent"
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
use crate::ai::execution_profiles::{CloudAgentComputerUseState, ComputerUsePermission};
|
||||
|
||||
let is_any_ai_enabled = AISettings::as_ref(app).is_any_ai_enabled(app);
|
||||
|
||||
// Determine toggle state based on workspace autonomy setting and user preference
|
||||
let CloudAgentComputerUseState {
|
||||
enabled: is_checked,
|
||||
is_forced_by_org,
|
||||
} = ComputerUsePermission::resolve_cloud_agent_state(app);
|
||||
|
||||
// Toggle is disabled if forced by org settings OR if AI is globally disabled
|
||||
let is_disabled = is_forced_by_org || !is_any_ai_enabled;
|
||||
|
||||
let ui_builder = appearance.ui_builder();
|
||||
let toggle = if is_forced_by_org {
|
||||
// Disabled by organization setting - show tooltip on hover
|
||||
ui_builder
|
||||
.switch(self.toggle.clone())
|
||||
.check(is_checked)
|
||||
.with_tooltip(TooltipConfig {
|
||||
text: "This option is enforced by your organization's settings and cannot be customized.".to_string(),
|
||||
styles: ui_builder.default_tool_tip_styles(),
|
||||
})
|
||||
.disable()
|
||||
.build()
|
||||
.finish()
|
||||
} else if !is_any_ai_enabled {
|
||||
// Disabled because AI is off globally - no tooltip needed
|
||||
ui_builder
|
||||
.switch(self.toggle.clone())
|
||||
.check(is_checked)
|
||||
.with_disabled(true)
|
||||
.build()
|
||||
.finish()
|
||||
} else {
|
||||
// Enabled - allow toggling
|
||||
ui_builder
|
||||
.switch(self.toggle.clone())
|
||||
.check(is_checked)
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(AISettingsPageAction::ToggleCloudAgentComputerUse);
|
||||
})
|
||||
.finish()
|
||||
};
|
||||
|
||||
let toggle_row = build_toggle_element(
|
||||
render_body_item_label::<AISettingsPageAction>(
|
||||
"Computer use in Cloud Agents".to_string(),
|
||||
Some(styles::header_font_color(!is_disabled, app)),
|
||||
None,
|
||||
LocalOnlyIconState::Hidden,
|
||||
ToggleState::Enabled,
|
||||
appearance,
|
||||
),
|
||||
toggle,
|
||||
appearance,
|
||||
None,
|
||||
);
|
||||
|
||||
let mut column = Flex::column()
|
||||
.with_child(render_separator(appearance))
|
||||
.with_child(
|
||||
build_sub_header(
|
||||
appearance,
|
||||
"Experimental",
|
||||
Some(styles::header_font_color(is_any_ai_enabled, app)),
|
||||
)
|
||||
.with_padding_bottom(HEADER_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(toggle_row)
|
||||
.with_child(render_ai_setting_description(
|
||||
"Enable computer use in cloud agent conversations started from the Warp app.",
|
||||
!is_disabled,
|
||||
app,
|
||||
));
|
||||
|
||||
if FeatureFlag::Orchestration.is_enabled() {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
column.add_child(render_ai_setting_toggle::<OrchestrationEnabled>(
|
||||
"Orchestration",
|
||||
AISettingsPageAction::ToggleOrchestration,
|
||||
*ai_settings.orchestration_enabled,
|
||||
is_any_ai_enabled,
|
||||
self.orchestration_toggle.clone(),
|
||||
&view.local_only_icon_tooltip_states,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_ai_setting_description(
|
||||
"Enable multi-agent orchestration, allowing the agent to spawn and coordinate parallel sub-agents.",
|
||||
is_any_ai_enabled,
|
||||
app,
|
||||
));
|
||||
}
|
||||
|
||||
column.finish()
|
||||
}
|
||||
}
|
||||
|
||||
struct ApiKeysWidget {
|
||||
openai_api_key_editor: ViewHandle<EditorView>,
|
||||
anthropic_api_key_editor: ViewHandle<EditorView>,
|
||||
google_api_key_editor: ViewHandle<EditorView>,
|
||||
|
||||
can_use_warp_credits_with_byok: SwitchStateHandle,
|
||||
upgrade_highlight_index: HighlightedHyperlink,
|
||||
}
|
||||
|
||||
impl ApiKeysWidget {
|
||||
fn new(ctx: &mut ViewContext<<Self as SettingsWidget>::View>) -> Self {
|
||||
let ai_settings = AISettings::as_ref(ctx);
|
||||
let workspace_handle = UserWorkspaces::handle(ctx);
|
||||
let is_any_ai_enabled = ai_settings.is_any_ai_enabled(ctx);
|
||||
let is_byo_enabled = workspace_handle.as_ref(ctx).is_byo_api_key_enabled();
|
||||
|
||||
let ApiKeys {
|
||||
openai: openai_key,
|
||||
anthropic: anthropic_key,
|
||||
google: google_key,
|
||||
..
|
||||
} = ApiKeyManager::as_ref(ctx).keys().clone();
|
||||
|
||||
// A helper macro to create and configure an API key editor. This avoids a lot
|
||||
// of code duplication and ensures consistency between the editors.
|
||||
macro_rules! create_api_key_editor {
|
||||
($editor:ident, $key:ident, $set_func:ident, $placeholder:literal) => {
|
||||
let $editor = ctx.add_typed_action_view(move |ctx| {
|
||||
let appearance = Appearance::handle(ctx).as_ref(ctx);
|
||||
let options = SingleLineEditorOptions {
|
||||
is_password: true,
|
||||
text: TextOptions {
|
||||
font_size_override: Some(appearance.ui_font_size()),
|
||||
font_family_override: Some(appearance.monospace_font_family()),
|
||||
text_colors_override: Some(TextColors {
|
||||
default_color: appearance.theme().active_ui_text_color(),
|
||||
disabled_color: appearance.theme().disabled_ui_text_color(),
|
||||
hint_color: appearance.theme().disabled_ui_text_color(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let mut editor = EditorView::single_line(options, ctx);
|
||||
editor.set_placeholder_text($placeholder, ctx);
|
||||
if let Some(key) = &$key {
|
||||
editor.set_buffer_text(key, ctx);
|
||||
}
|
||||
editor
|
||||
});
|
||||
AISettingsPageView::update_editor_interaction_state(
|
||||
$editor.clone(),
|
||||
is_any_ai_enabled && is_byo_enabled,
|
||||
ctx,
|
||||
);
|
||||
ctx.subscribe_to_view(&$editor, |_, $editor, event, ctx| {
|
||||
if matches!(event, EditorEvent::Blurred | EditorEvent::Enter) {
|
||||
let buffer_text = $editor.as_ref(ctx).buffer_text(ctx);
|
||||
let key = buffer_text.is_empty().not().then_some(buffer_text);
|
||||
ApiKeyManager::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.$set_func(key, ctx);
|
||||
});
|
||||
}
|
||||
});
|
||||
let editor_clone = $editor.clone();
|
||||
ctx.subscribe_to_model(&workspace_handle, move |_, workspace, event, ctx| {
|
||||
if let UserWorkspacesEvent::TeamsChanged = event {
|
||||
let is_any_ai_enabled =
|
||||
AISettings::handle(ctx).as_ref(ctx).is_any_ai_enabled(ctx);
|
||||
let is_byo_enabled = workspace.as_ref(ctx).is_byo_api_key_enabled();
|
||||
let is_enabled = is_any_ai_enabled && is_byo_enabled;
|
||||
let has_key = !editor_clone.as_ref(ctx).is_empty(ctx);
|
||||
|
||||
// If BYO is disabled, clear the API key from the editor and storage
|
||||
if !is_byo_enabled && has_key {
|
||||
editor_clone.update(ctx, |editor, ctx| {
|
||||
editor.set_buffer_text("", ctx);
|
||||
});
|
||||
ApiKeyManager::handle(ctx).update(ctx, |model, ctx| {
|
||||
model.$set_func(None, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
AISettingsPageView::update_editor_interaction_state(
|
||||
editor_clone.clone(),
|
||||
is_enabled,
|
||||
ctx,
|
||||
);
|
||||
ctx.notify();
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
create_api_key_editor!(openai_api_key_editor, openai_key, set_openai_key, "sk-...");
|
||||
create_api_key_editor!(
|
||||
anthropic_api_key_editor,
|
||||
anthropic_key,
|
||||
set_anthropic_key,
|
||||
"sk-ant-..."
|
||||
);
|
||||
create_api_key_editor!(
|
||||
google_api_key_editor,
|
||||
google_key,
|
||||
set_google_key,
|
||||
"AIzaSy..."
|
||||
);
|
||||
|
||||
Self {
|
||||
openai_api_key_editor,
|
||||
anthropic_api_key_editor,
|
||||
google_api_key_editor,
|
||||
|
||||
can_use_warp_credits_with_byok: Default::default(),
|
||||
upgrade_highlight_index: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_api_keys_section(
|
||||
&self,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
is_byo_enabled: bool,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
let is_any_ai_enabled = ai_settings.is_any_ai_enabled(app);
|
||||
let is_enabled = is_any_ai_enabled && is_byo_enabled;
|
||||
|
||||
let mut column = Flex::column()
|
||||
.with_spacing(16.)
|
||||
.with_child(
|
||||
Container::new(
|
||||
render_ai_setting_description(
|
||||
"Use your own API keys from model providers for the Warp Agent to use. API keys are stored locally and never synced to the cloud. Using auto models or models from providers you have not provided API keys for will consume Warp credits.",
|
||||
is_enabled,
|
||||
app,
|
||||
))
|
||||
// Remove the bottom margin of the description so that it doesn't
|
||||
// create extra space between the description and the API key inputs.
|
||||
.with_margin_bottom(-styles::DESCRIPTION_MARGIN_BOTTOM).finish()
|
||||
);
|
||||
|
||||
/// Helper function to render the UI for an API key input field.
|
||||
fn render_api_key_input(
|
||||
appearance: &Appearance,
|
||||
label: &'static str,
|
||||
editor: ViewHandle<EditorView>,
|
||||
is_enabled: bool,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let padding = Some(Coords {
|
||||
top: 10.,
|
||||
bottom: 10.,
|
||||
left: 16.,
|
||||
right: 16.,
|
||||
});
|
||||
let editor_style = UiComponentStyles {
|
||||
padding,
|
||||
background: Some(appearance.theme().surface_2().into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let label = Text::new_inline(label, appearance.ui_font_family(), CONTENT_FONT_SIZE)
|
||||
.with_color(styles::header_font_color(is_enabled, app).into())
|
||||
.finish();
|
||||
|
||||
let input = appearance
|
||||
.ui_builder()
|
||||
.text_input(editor)
|
||||
.with_style(editor_style)
|
||||
.build()
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_spacing(8.)
|
||||
.with_child(label)
|
||||
.with_child(input)
|
||||
.finish()
|
||||
}
|
||||
|
||||
column.add_child(render_api_key_input(
|
||||
appearance,
|
||||
"OpenAI API Key",
|
||||
self.openai_api_key_editor.clone(),
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_api_key_input(
|
||||
appearance,
|
||||
"Anthropic API Key",
|
||||
self.anthropic_api_key_editor.clone(),
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
column.add_child(render_api_key_input(
|
||||
appearance,
|
||||
"Google API Key",
|
||||
self.google_api_key_editor.clone(),
|
||||
is_enabled,
|
||||
app,
|
||||
));
|
||||
|
||||
// Show upgrade CTA if BYOK is not enabled
|
||||
if !is_byo_enabled {
|
||||
let auth_state = AuthStateProvider::as_ref(app).get();
|
||||
let upgrade_text_fragments = if let Some(team) =
|
||||
UserWorkspaces::as_ref(app).current_team()
|
||||
{
|
||||
// Enterprise teams don't have a self-serve upgrade path; route them
|
||||
// to sales to enable BYOK on their existing plan.
|
||||
if team.billing_metadata.customer_type == CustomerType::Enterprise {
|
||||
vec![
|
||||
FormattedTextFragment::hyperlink("Contact sales", "mailto:sales@warp.dev"),
|
||||
FormattedTextFragment::plain_text(
|
||||
" to enable bringing your own API keys on your Enterprise plan.",
|
||||
),
|
||||
]
|
||||
} else {
|
||||
let current_user_email = auth_state.user_email().unwrap_or_default();
|
||||
let has_admin_permissions = team.has_admin_permissions(¤t_user_email);
|
||||
let upgrade_url = UserWorkspaces::upgrade_link_for_team(team.uid);
|
||||
if has_admin_permissions {
|
||||
vec![
|
||||
FormattedTextFragment::hyperlink(
|
||||
"Upgrade to the Build plan",
|
||||
upgrade_url,
|
||||
),
|
||||
FormattedTextFragment::plain_text(" to use your own API keys."),
|
||||
]
|
||||
} else {
|
||||
vec![FormattedTextFragment::plain_text(
|
||||
"Ask your team's admin to upgrade to the Build plan to use your own API keys.",
|
||||
)]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let user_id = auth_state.user_id().unwrap_or_default();
|
||||
let upgrade_url = UserWorkspaces::upgrade_link(user_id);
|
||||
vec![
|
||||
FormattedTextFragment::hyperlink("Upgrade to the Build plan", upgrade_url),
|
||||
FormattedTextFragment::plain_text(" to use your own API keys."),
|
||||
]
|
||||
};
|
||||
|
||||
let upgrade_text_element = FormattedTextElement::new(
|
||||
FormattedText::new([FormattedTextLine::Line(upgrade_text_fragments)]),
|
||||
appearance.ui_font_size(),
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_family(),
|
||||
blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()),
|
||||
self.upgrade_highlight_index.clone(),
|
||||
)
|
||||
.with_hyperlink_font_color(appearance.theme().accent().into_solid())
|
||||
.register_default_click_handlers(|url, ctx, _| {
|
||||
ctx.dispatch_typed_action(AISettingsPageAction::HyperlinkClick(url));
|
||||
});
|
||||
|
||||
column.add_child(Container::new(upgrade_text_element.finish()).finish());
|
||||
}
|
||||
|
||||
column.finish()
|
||||
}
|
||||
|
||||
fn render_can_use_warp_credits_with_byok_toggle(
|
||||
&self,
|
||||
view: &AISettingsPageView,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
|
||||
let toggle = render_ai_setting_toggle::<CanUseWarpCreditsWithByok>(
|
||||
"Warp credit fallback",
|
||||
AISettingsPageAction::ToggleCanUseWarpCreditsWithByok,
|
||||
*ai_settings.can_use_warp_credits_with_byok,
|
||||
ai_settings.is_any_ai_enabled(app),
|
||||
self.can_use_warp_credits_with_byok.clone(),
|
||||
&view.local_only_icon_tooltip_states,
|
||||
app,
|
||||
);
|
||||
|
||||
let description = render_ai_setting_description(
|
||||
"When enabled, agent requests may be routed to one of Warp's provided models in the event of an error. Warp will prioritize using your API keys over your Warp credits.",
|
||||
ai_settings.is_any_ai_enabled(app),
|
||||
app,
|
||||
);
|
||||
|
||||
Flex::column()
|
||||
.with_child(toggle)
|
||||
.with_child(description)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsWidget for ApiKeysWidget {
|
||||
type View = AISettingsPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"api keys bring your own byo openai anthropic google claude gemini gpt"
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ai_settings = AISettings::as_ref(app);
|
||||
let is_any_ai_enabled = ai_settings.is_any_ai_enabled(app);
|
||||
let is_byo_enabled = UserWorkspaces::as_ref(app).is_byo_api_key_enabled();
|
||||
|
||||
let mut column = Flex::column()
|
||||
.with_child(render_separator(appearance))
|
||||
.with_child(
|
||||
build_sub_header(
|
||||
appearance,
|
||||
"API Keys",
|
||||
Some(styles::header_font_color(is_any_ai_enabled, app)),
|
||||
)
|
||||
.with_padding_bottom(HEADER_PADDING)
|
||||
.finish(),
|
||||
)
|
||||
.with_child(self.render_api_keys_section(appearance, app, is_byo_enabled));
|
||||
|
||||
if is_byo_enabled {
|
||||
column.add_child(
|
||||
Container::new(self.render_can_use_warp_credits_with_byok_toggle(view, app))
|
||||
.with_margin_top(16.)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
Container::new(column.finish())
|
||||
.with_margin_bottom(HEADER_PADDING)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
struct BedrockSettingsWidget {
|
||||
enabled_toggle: SwitchStateHandle,
|
||||
cross_region_toggle: SwitchStateHandle,
|
||||
|
||||
@@ -30,7 +30,6 @@ use appearance_page::{AppearancePageAction, AppearanceSettingsPageView};
|
||||
use billing_and_usage_page::{BillingAndUsagePageEvent, BillingAndUsagePageView};
|
||||
use code_page::CodeSubpage;
|
||||
use code_page::{CodeSettingsPageAction, CodeSettingsPageEvent};
|
||||
use environments_page::EnvironmentsPageView;
|
||||
use features_page::{FeaturesPageView, FeaturesSettingsPageEvent};
|
||||
use itertools::Itertools as _;
|
||||
use keybindings::KeybindingsView;
|
||||
@@ -39,17 +38,14 @@ use mcp_servers_page::MCPServersSettingsPageView;
|
||||
use nav::{SettingsNavItem, SettingsUmbrella};
|
||||
use pathfinder_geometry::vector::Vector2F;
|
||||
use privacy_page::{PrivacyPageView, PrivacyPageViewEvent};
|
||||
use referrals_page::{ReferralsPageEvent, ReferralsPageView};
|
||||
use settings_file_footer::{render_footer, SettingsFooterKind, SettingsFooterMouseStates};
|
||||
use settings_page::{
|
||||
MatchData, SettingsPage, SettingsPageEvent, SettingsPageMeta, SettingsPageViewHandle,
|
||||
HEADER_PADDING,
|
||||
};
|
||||
use show_blocks_view::{ShowBlocksEvent, ShowBlocksView};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use teams_page::{TeamsPageView, TeamsPageViewEvent};
|
||||
use galaxy_core::send_telemetry_from_ctx;
|
||||
use galaxy_core::{
|
||||
channel::ChannelState, context_flag::ContextFlag, features::FeatureFlag,
|
||||
@@ -97,10 +93,8 @@ mod platform;
|
||||
mod platform_page;
|
||||
mod privacy;
|
||||
mod privacy_page;
|
||||
mod referrals_page;
|
||||
mod settings_file_footer;
|
||||
pub(crate) mod settings_page;
|
||||
mod show_blocks_view;
|
||||
mod tab_menu;
|
||||
mod teams_page;
|
||||
mod telemetry;
|
||||
@@ -195,9 +189,6 @@ pub enum SettingsSection {
|
||||
Features,
|
||||
Keybindings,
|
||||
Privacy,
|
||||
Referrals,
|
||||
SharedBlocks,
|
||||
Teams,
|
||||
WarpDrive,
|
||||
Warpify,
|
||||
/// Internal backing-page identifier for AISettingsPageView. Multiple subpages
|
||||
@@ -220,9 +211,6 @@ pub enum SettingsSection {
|
||||
// ── Code umbrella subpages ──
|
||||
CodeIndexing,
|
||||
EditorAndCodeReview,
|
||||
// ── Cloud platform umbrella subpages ──
|
||||
CloudEnvironments,
|
||||
OzCloudAPIKeys,
|
||||
}
|
||||
|
||||
use crate::util::bindings::custom_tag_to_keystroke;
|
||||
@@ -233,7 +221,6 @@ impl Display for SettingsSection {
|
||||
match self {
|
||||
SettingsSection::BillingAndUsage => write!(f, "Billing and usage"),
|
||||
SettingsSection::Keybindings => write!(f, "Keyboard shortcuts"),
|
||||
SettingsSection::SharedBlocks => write!(f, "Shared blocks"),
|
||||
SettingsSection::MCPServers => write!(f, "MCP Servers"),
|
||||
SettingsSection::WarpDrive => write!(f, "Galaxy Drive"),
|
||||
SettingsSection::WarpAgent => write!(f, "Galaxy Agent"),
|
||||
@@ -244,8 +231,6 @@ impl Display for SettingsSection {
|
||||
SettingsSection::Bedrock => write!(f, "AWS Bedrock"),
|
||||
SettingsSection::CodeIndexing => write!(f, "Indexing and projects"),
|
||||
SettingsSection::EditorAndCodeReview => write!(f, "Editor and Code Review"),
|
||||
SettingsSection::CloudEnvironments => write!(f, "Environments"),
|
||||
SettingsSection::OzCloudAPIKeys => write!(f, "Oz Cloud API Keys"),
|
||||
_ => write!(f, "{self:?}"),
|
||||
}
|
||||
}
|
||||
@@ -254,7 +239,7 @@ impl Display for SettingsSection {
|
||||
impl SettingsSection {
|
||||
/// Returns true if this section is a subpage under any umbrella.
|
||||
pub fn is_subpage(&self) -> bool {
|
||||
self.is_ai_subpage() || self.is_code_subpage() || self.is_cloud_platform_subpage()
|
||||
self.is_ai_subpage() || self.is_code_subpage()
|
||||
}
|
||||
|
||||
/// Returns true if this section is a subpage under the "Agents" umbrella.
|
||||
@@ -275,11 +260,6 @@ impl SettingsSection {
|
||||
matches!(self, Self::CodeIndexing | Self::EditorAndCodeReview)
|
||||
}
|
||||
|
||||
/// Returns true if this section is a subpage under the "Cloud platform" umbrella.
|
||||
pub fn is_cloud_platform_subpage(&self) -> bool {
|
||||
matches!(self, Self::CloudEnvironments | Self::OzCloudAPIKeys)
|
||||
}
|
||||
|
||||
/// Maps subpage sections back to their parent page section for page lookup.
|
||||
/// Non-subpage sections return themselves.
|
||||
pub fn parent_page_section(&self) -> Self {
|
||||
@@ -290,8 +270,6 @@ impl SettingsSection {
|
||||
s if s.is_ai_subpage() => Self::AI,
|
||||
// Code subpages render within the Code page.
|
||||
s if s.is_code_subpage() => Self::Code,
|
||||
// CloudEnvironments and OzCloudAPIKeys ARE their own backing pages
|
||||
// (1:1 mapping), so they return themselves.
|
||||
other => *other,
|
||||
}
|
||||
}
|
||||
@@ -312,11 +290,6 @@ impl SettingsSection {
|
||||
pub fn code_subpages() -> &'static [Self] {
|
||||
&[Self::CodeIndexing, Self::EditorAndCodeReview]
|
||||
}
|
||||
|
||||
/// The ordered list of Cloud platform subpage sections.
|
||||
pub fn cloud_platform_subpages() -> &'static [Self] {
|
||||
&[Self::CloudEnvironments, Self::OzCloudAPIKeys]
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SettingsSection {
|
||||
@@ -334,9 +307,6 @@ impl FromStr for SettingsSection {
|
||||
"Features" => Ok(Self::Features),
|
||||
"Keyboard shortcuts" => Ok(Self::Keybindings),
|
||||
"Privacy" => Ok(Self::Privacy),
|
||||
"Referrals" => Ok(Self::Referrals),
|
||||
"Shared blocks" => Ok(Self::SharedBlocks),
|
||||
"Teams" => Ok(Self::Teams),
|
||||
"Warpify" => Ok(Self::Warpify),
|
||||
"WarpDrive" | "Warp Drive" | "Galaxy Drive" => Ok(Self::WarpDrive),
|
||||
// This page was called "Oz" at one point, keep for backward compatibility.
|
||||
@@ -348,8 +318,6 @@ impl FromStr for SettingsSection {
|
||||
"AWS Bedrock" | "Bedrock" => Ok(Self::Bedrock),
|
||||
"Indexing and projects" | "CodeIndexing" => Ok(Self::CodeIndexing),
|
||||
"Editor and Code Review" | "EditorAndCodeReview" => Ok(Self::EditorAndCodeReview),
|
||||
"CloudEnvironments" => Ok(Self::CloudEnvironments),
|
||||
"Oz Cloud API Keys" | "OzCloudAPIKeys" => Ok(Self::OzCloudAPIKeys),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
@@ -961,15 +929,10 @@ macro_rules! update_page {
|
||||
SettingsPageViewHandle::Main(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Appearance(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Features(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::SharedBlocks(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Keybindings(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Teams(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Warpify(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::OzCloudAPIKeys(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Privacy(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Referrals(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::AI(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::CloudEnvironments(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::About(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::Code(handle) => $ctx.update_view(handle, $update),
|
||||
SettingsPageViewHandle::BillingAndUsage(handle) => $ctx.update_view(handle, $update),
|
||||
@@ -989,7 +952,6 @@ pub struct SettingsView {
|
||||
clipped_scroll_state: ClippedScrollStateHandle,
|
||||
context_menu: ViewHandle<Menu<SettingsAction>>,
|
||||
context_menu_state: Option<Vector2F>,
|
||||
environments_page_handle: ViewHandle<EnvironmentsPageView>,
|
||||
/// Sidebar navigation items (pages + umbrellas).
|
||||
nav_items: Vec<SettingsNavItem>,
|
||||
/// Handle to the AI settings page, used to switch subpage modes.
|
||||
@@ -1038,20 +1000,6 @@ impl SettingsView {
|
||||
me.handle_features_page_event(event, ctx);
|
||||
});
|
||||
|
||||
// Shared blocks page
|
||||
let block_client = ServerApiProvider::as_ref(ctx).get_block_client();
|
||||
let show_blocks_view_handle =
|
||||
ctx.add_typed_action_view(|ctx| ShowBlocksView::new(block_client, ctx));
|
||||
|
||||
ctx.subscribe_to_view(&show_blocks_view_handle, |_, _, event, ctx| match event {
|
||||
ShowBlocksEvent::ShowToast { message, flavor } => {
|
||||
ctx.emit(SettingsViewEvent::ShowToast {
|
||||
message: message.clone(),
|
||||
flavor: *flavor,
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// About page
|
||||
let about_page_handle = ctx.add_view(AboutPageView::new);
|
||||
|
||||
@@ -1062,12 +1010,6 @@ impl SettingsView {
|
||||
me.handle_ai_page_event(event, ctx);
|
||||
});
|
||||
|
||||
// Environments page
|
||||
let environments_page_handle = ctx.add_typed_action_view(EnvironmentsPageView::new);
|
||||
ctx.subscribe_to_view(&environments_page_handle, |me, _, event, ctx| {
|
||||
me.handle_environments_page_event(event, ctx);
|
||||
});
|
||||
|
||||
// Billing and usage page
|
||||
let billing_and_usage_page_handle = ctx.add_typed_action_view(BillingAndUsagePageView::new);
|
||||
ctx.subscribe_to_view(&billing_and_usage_page_handle, |me, _, event, ctx| {
|
||||
@@ -1084,20 +1026,6 @@ impl SettingsView {
|
||||
me.handle_code_page_event(event, ctx);
|
||||
});
|
||||
|
||||
// Teams page, adding unconditionally, as `should_render` later on decides whether it
|
||||
// should be shown to the user or not
|
||||
let teams_page_handle = ctx.add_typed_action_view(TeamsPageView::new);
|
||||
ctx.subscribe_to_view(&teams_page_handle, |_, _, event, ctx| match event {
|
||||
TeamsPageViewEvent::TeamsChanged => ctx.notify(),
|
||||
TeamsPageViewEvent::OpenWarpDrive => ctx.emit(SettingsViewEvent::OpenWarpDrive),
|
||||
TeamsPageViewEvent::ShowToast { message, flavor } => {
|
||||
ctx.emit(SettingsViewEvent::ShowToast {
|
||||
message: message.clone(),
|
||||
flavor: *flavor,
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
let warpify_page_handle = ctx.add_typed_action_view(WarpifyPageView::new);
|
||||
ctx.subscribe_to_view(&warpify_page_handle, |me, _, event, ctx| {
|
||||
me.handle_warpify_page_event(event, ctx);
|
||||
@@ -1109,13 +1037,6 @@ impl SettingsView {
|
||||
me.handle_privacy_page_event(event, ctx);
|
||||
});
|
||||
|
||||
let referrals_client = ServerApiProvider::as_ref(ctx).get_referrals_client();
|
||||
let referrals_page_handle =
|
||||
ctx.add_typed_action_view(|ctx| ReferralsPageView::new(referrals_client, ctx));
|
||||
ctx.subscribe_to_view(&referrals_page_handle, |me, _, event, ctx| {
|
||||
me.handle_referrals_page_event(event, ctx);
|
||||
});
|
||||
|
||||
// Warp Drive page
|
||||
let warp_drive_page_handle =
|
||||
ctx.add_typed_action_view(warp_drive_page::WarpDriveSettingsPageView::new);
|
||||
@@ -1167,20 +1088,16 @@ impl SettingsView {
|
||||
SettingsPage::new(ai_page_handle),
|
||||
SettingsPage::new(billing_and_usage_page_handle),
|
||||
SettingsPage::new(code_page_handle),
|
||||
SettingsPage::new(teams_page_handle),
|
||||
SettingsPage::new(appearance_page_handle),
|
||||
SettingsPage::new(features_page_handle),
|
||||
SettingsPage::new(keybindings_handle),
|
||||
SettingsPage::new(platform_page_handle),
|
||||
SettingsPage::new(warpify_page_handle),
|
||||
SettingsPage::new(referrals_page_handle),
|
||||
SettingsPage::new(show_blocks_view_handle),
|
||||
SettingsPage::new(warp_drive_page_handle),
|
||||
];
|
||||
|
||||
settings_pages.extend(vec![
|
||||
SettingsPage::new(mcp_servers_page_handle),
|
||||
SettingsPage::new(environments_page_handle.clone()),
|
||||
SettingsPage::new(privacy_page_handle),
|
||||
SettingsPage::new(about_page_handle),
|
||||
]);
|
||||
@@ -1199,20 +1116,10 @@ impl SettingsView {
|
||||
SettingsSection::EditorAndCodeReview,
|
||||
],
|
||||
)),
|
||||
SettingsNavItem::Umbrella(SettingsUmbrella::new(
|
||||
"Cloud platform",
|
||||
vec![
|
||||
SettingsSection::CloudEnvironments,
|
||||
SettingsSection::OzCloudAPIKeys,
|
||||
],
|
||||
)),
|
||||
SettingsNavItem::Page(SettingsSection::Teams),
|
||||
SettingsNavItem::Page(SettingsSection::Appearance),
|
||||
SettingsNavItem::Page(SettingsSection::Features),
|
||||
SettingsNavItem::Page(SettingsSection::Keybindings),
|
||||
SettingsNavItem::Page(SettingsSection::Warpify),
|
||||
SettingsNavItem::Page(SettingsSection::Referrals),
|
||||
SettingsNavItem::Page(SettingsSection::SharedBlocks),
|
||||
SettingsNavItem::Page(SettingsSection::WarpDrive),
|
||||
SettingsNavItem::Page(SettingsSection::Privacy),
|
||||
SettingsNavItem::Page(SettingsSection::About),
|
||||
@@ -1250,7 +1157,6 @@ impl SettingsView {
|
||||
clipped_scroll_state: Default::default(),
|
||||
context_menu,
|
||||
context_menu_state: Default::default(),
|
||||
environments_page_handle,
|
||||
nav_items,
|
||||
ai_page_handle: ai_page_handle_for_nav,
|
||||
code_page_handle: code_page_handle_for_nav,
|
||||
@@ -1635,24 +1541,6 @@ impl SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_environments_page_event(
|
||||
&mut self,
|
||||
event: &SettingsPageEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
SettingsPageEvent::FocusModal => ctx.focus(&self.search_editor),
|
||||
SettingsPageEvent::EnvironmentSetupModeSelectorToggled { .. }
|
||||
| SettingsPageEvent::AgentAssistedEnvironmentModalToggled { .. } => {
|
||||
// Re-render so the modal overlay is shown/hidden.
|
||||
ctx.notify();
|
||||
}
|
||||
SettingsPageEvent::Pane(_) => {
|
||||
// Not applicable in standalone settings view.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_features_page_event(
|
||||
&mut self,
|
||||
event: &FeaturesSettingsPageEvent,
|
||||
@@ -1748,25 +1636,6 @@ impl SettingsView {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_referrals_page_event(
|
||||
&mut self,
|
||||
event: &ReferralsPageEvent,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
match event {
|
||||
ReferralsPageEvent::SignupAnonymousUser => {
|
||||
ctx.emit(SettingsViewEvent::SignupAnonymousUser)
|
||||
}
|
||||
ReferralsPageEvent::FocusModal => ctx.focus(&self.search_editor),
|
||||
ReferralsPageEvent::ShowToast { message, flavor } => {
|
||||
ctx.emit(SettingsViewEvent::ShowToast {
|
||||
message: message.clone(),
|
||||
flavor: *flavor,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_warp_drive_page_event(
|
||||
&mut self,
|
||||
event: &warp_drive_page::WarpDriveSettingsPageEvent,
|
||||
@@ -1881,9 +1750,6 @@ impl SettingsView {
|
||||
self.clear_search_query(ctx);
|
||||
}
|
||||
self.current_settings_page = section;
|
||||
if previous_section != section && section == SettingsSection::CloudEnvironments {
|
||||
send_telemetry_from_ctx!(SettingsTelemetryEvent::EnvironmentsPageOpened, ctx);
|
||||
}
|
||||
|
||||
// When navigating to a subpage, update the backing page's active subpage mode
|
||||
// and auto-expand the umbrella containing it.
|
||||
@@ -1949,40 +1815,20 @@ impl SettingsView {
|
||||
fn should_render_page(&self, settings_page: &SettingsPage, app: &AppContext) -> bool {
|
||||
match &settings_page.view_handle {
|
||||
SettingsPageViewHandle::Main(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Teams(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::SharedBlocks(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Keybindings(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Features(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Appearance(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::BillingAndUsage(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::About(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::OzCloudAPIKeys(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Privacy(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Warpify(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Referrals(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::AI(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::CloudEnvironments(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::MCPServers(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::Code(v) => v.as_ref(app).should_render(app),
|
||||
SettingsPageViewHandle::WarpDrive(v) => v.as_ref(app).should_render(app),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the invite section of the teams page, optionally with an email to invite.
|
||||
pub fn open_teams_page_email_invite(
|
||||
&mut self,
|
||||
email: Option<&String>,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
if let Some(team_page) = self.settings_page(SettingsSection::Teams) {
|
||||
if let SettingsPageViewHandle::Teams(view) = &team_page.view_handle {
|
||||
view.update(ctx, |view, ctx| {
|
||||
view.open_team_members(email, ctx);
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the MCP servers page, optionally to list page or edit page.
|
||||
/// If `autoinstall_gallery_title` is provided, triggers auto-install of the specified gallery MCP.
|
||||
pub fn open_mcp_servers_page(
|
||||
@@ -2115,9 +1961,6 @@ impl SettingsView {
|
||||
SettingsPageViewHandle::Keybindings(view_handle) => {
|
||||
view_handle.update(ctx, |view, ctx| view.on_tab_pressed(ctx));
|
||||
}
|
||||
SettingsPageViewHandle::Teams(view_handle) => {
|
||||
view_handle.update(ctx, |view, ctx| view.on_tab_pressed(ctx));
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
@@ -2178,9 +2021,6 @@ impl SettingsView {
|
||||
SettingsPageViewHandle::Privacy(view) => {
|
||||
view.read(app, |view, _| view.get_modal_content())
|
||||
}
|
||||
SettingsPageViewHandle::OzCloudAPIKeys(view) => {
|
||||
view.read(app, |view, _| view.get_modal_content())
|
||||
}
|
||||
SettingsPageViewHandle::MCPServers(view) => {
|
||||
view.read(app, |view, _| view.get_modal_content(app))
|
||||
}
|
||||
@@ -2481,24 +2321,6 @@ impl View for SettingsView {
|
||||
);
|
||||
}
|
||||
|
||||
// Render environment setup mode selector overlay when open.
|
||||
if let Some(selector_handle) = self
|
||||
.environments_page_handle
|
||||
.as_ref(app)
|
||||
.environment_setup_mode_selector_handle()
|
||||
{
|
||||
stack.add_child(ChildView::new(selector_handle).finish());
|
||||
}
|
||||
|
||||
// Render agent-assisted environment modal overlay when open.
|
||||
if let Some(modal_handle) = self
|
||||
.environments_page_handle
|
||||
.as_ref(app)
|
||||
.agent_assisted_environment_modal_handle(app)
|
||||
{
|
||||
stack.add_child(ChildView::new(modal_handle).finish());
|
||||
}
|
||||
|
||||
SavePosition::new(stack.finish(), POSITION_ID).finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,17 +43,15 @@ use crate::ui_components::buttons::icon_button;
|
||||
use crate::view_components::{Dropdown, DropdownItem};
|
||||
use crate::{
|
||||
appearance::Appearance,
|
||||
auth::auth_manager::AuthManager,
|
||||
channel::ChannelState,
|
||||
report_if_error, send_telemetry_from_ctx,
|
||||
server::telemetry::TelemetryEvent,
|
||||
settings::{AISettings, PrivacySettings},
|
||||
terminal::safe_mode_settings::{SafeModeEnabled, SafeModeSettings},
|
||||
ui_components::icons::Icon,
|
||||
util::links::PRIVACY_POLICY_URL,
|
||||
workspaces::{
|
||||
user_workspaces::UserWorkspaces,
|
||||
workspace::{AdminEnablementSetting, CustomerType, UgcCollectionEnablementSetting},
|
||||
workspace::{CustomerType, UgcCollectionEnablementSetting},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,25 +95,7 @@ const TELEMETRY_FREE_TIER_NOTE: &str =
|
||||
const TELEMETRY_DOCS_URL: &str =
|
||||
"https://docs.warp.dev/support-and-community/privacy-and-security/privacy#what-telemetry-data-does-warp-collect-and-why";
|
||||
|
||||
const DATA_MANAGEMENT_TITLE: &str = "Manage your data";
|
||||
const DATA_MANAGEMENT_DESCRIPTION: &str =
|
||||
"At any time, you may choose to delete your Warp account permanently. \
|
||||
You will no longer be able to use Warp.";
|
||||
const DATA_MANAGEMENT_LINK_TEXT: &str = "Visit the data management page";
|
||||
|
||||
const PRIVACY_POLICY_TITLE: &str = "Privacy policy";
|
||||
const PRIVACY_POLICY_LINK_TEXT: &str = "Read Warp's privacy policy";
|
||||
|
||||
pub fn data_management_url(custom_token: Option<&str>) -> String {
|
||||
match custom_token {
|
||||
Some(token) => format!(
|
||||
"{}/data_management?customToken={}",
|
||||
ChannelState::server_root_url(),
|
||||
token
|
||||
),
|
||||
None => format!("{}/data_management", ChannelState::server_root_url(),),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrivacyPageView {
|
||||
page: PageType<Self>,
|
||||
@@ -241,13 +221,10 @@ impl PrivacyPageView {
|
||||
Box::new(SecretRedactionWidget::default()),
|
||||
Box::new(AppAnalyticsWidget::default()),
|
||||
Box::new(CrashReportsWidget::default()),
|
||||
Box::new(CloudConversationStorageWidget::default()),
|
||||
];
|
||||
if ContextFlag::NetworkLogConsole.is_enabled() {
|
||||
widgets.push(Box::new(NetworkLogWidget::default()));
|
||||
}
|
||||
widgets.push(Box::new(DataManagementWidget::default()));
|
||||
widgets.push(Box::new(PrivacyPolicyWidget::default()));
|
||||
PageType::new_uncategorized(widgets, Some("Privacy"))
|
||||
}
|
||||
|
||||
@@ -327,17 +304,6 @@ impl PrivacyPageView {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn toggle_cloud_conversation_storage(&mut self, ctx: &mut ViewContext<Self>) {
|
||||
let privacy_settings_handle = PrivacySettings::handle(ctx);
|
||||
let old_value = privacy_settings_handle
|
||||
.as_ref(ctx)
|
||||
.is_cloud_conversation_storage_enabled;
|
||||
ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| {
|
||||
privacy_settings.set_is_cloud_conversation_storage_enabled(!old_value, ctx);
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn queue_regex_removal(&mut self, idx: usize, ctx: &mut ViewContext<Self>) {
|
||||
// Check if this removal is already pending
|
||||
if self.pending_regex_removals.contains(&idx) {
|
||||
@@ -503,10 +469,8 @@ pub enum PrivacyPageAction {
|
||||
SetSecretDisplayMode(SecretDisplayMode),
|
||||
ToggleTelemetry,
|
||||
ToggleCrashReporting,
|
||||
ToggleCloudConversationStorage,
|
||||
LaunchNetworkLogging,
|
||||
RemoveCustomRegex(usize),
|
||||
OpenDataManagementWebpage,
|
||||
AddAllRecommendedRegexes,
|
||||
ShowAddRegexModal,
|
||||
AddRecommendedRegex(usize),
|
||||
@@ -584,19 +548,10 @@ impl TypedActionView for PrivacyPageView {
|
||||
}
|
||||
PrivacyPageAction::ToggleTelemetry => self.toggle_telemetry(ctx),
|
||||
PrivacyPageAction::ToggleCrashReporting => self.toggle_crash_reporting(ctx),
|
||||
PrivacyPageAction::ToggleCloudConversationStorage => {
|
||||
self.toggle_cloud_conversation_storage(ctx)
|
||||
}
|
||||
PrivacyPageAction::LaunchNetworkLogging => self.launch_network_logging(ctx),
|
||||
PrivacyPageAction::RemoveCustomRegex(idx) => {
|
||||
self.queue_regex_removal(*idx, ctx);
|
||||
}
|
||||
PrivacyPageAction::OpenDataManagementWebpage => {
|
||||
AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| {
|
||||
auth_manager
|
||||
.open_url_maybe_with_anonymous_token(ctx, Box::new(data_management_url));
|
||||
});
|
||||
}
|
||||
PrivacyPageAction::AddAllRecommendedRegexes => {
|
||||
// First process any pending removals
|
||||
if !self.pending_regex_removals.is_empty() {
|
||||
@@ -1673,120 +1628,6 @@ impl SettingsWidget for CrashReportsWidget {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CloudConversationStorageWidget {
|
||||
switch_state: SwitchStateHandle,
|
||||
}
|
||||
|
||||
impl SettingsWidget for CloudConversationStorageWidget {
|
||||
type View = PrivacyPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"sync cloud conversation store storage ai agent"
|
||||
}
|
||||
|
||||
fn should_render(&self, app: &AppContext) -> bool {
|
||||
if !FeatureFlag::CloudConversations.is_enabled() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hide the toggle entirely when AI is disabled: the setting has no
|
||||
// effect without AI (no agent conversations are produced), so showing
|
||||
// it is confusing.
|
||||
if !AISettings::as_ref(app).is_any_ai_enabled(app) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let privacy_settings = PrivacySettings::as_ref(app);
|
||||
!privacy_settings.is_telemetry_force_enabled()
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
_view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ui_builder = appearance.ui_builder();
|
||||
let privacy_settings = PrivacySettings::as_ref(app);
|
||||
let org_setting =
|
||||
UserWorkspaces::as_ref(app).get_cloud_conversation_storage_enablement_setting();
|
||||
|
||||
let (toggle_state, is_checked) = match org_setting {
|
||||
AdminEnablementSetting::Enable => (ToggleState::Disabled, true),
|
||||
AdminEnablementSetting::Disable => (ToggleState::Disabled, false),
|
||||
AdminEnablementSetting::RespectUserSetting => (
|
||||
ToggleState::Enabled,
|
||||
privacy_settings.is_cloud_conversation_storage_enabled,
|
||||
),
|
||||
};
|
||||
|
||||
let switch = ui_builder
|
||||
.switch(self.switch_state.clone())
|
||||
.check(is_checked);
|
||||
let switch = if matches!(toggle_state, ToggleState::Enabled) {
|
||||
switch
|
||||
.build()
|
||||
.on_click(move |ctx, _, _| {
|
||||
ctx.dispatch_typed_action(PrivacyPageAction::ToggleCloudConversationStorage)
|
||||
})
|
||||
.finish()
|
||||
} else {
|
||||
switch
|
||||
.with_tooltip(TooltipConfig {
|
||||
text: "This setting is managed by your organization.".to_string(),
|
||||
styles: ui_builder.default_tool_tip_styles(),
|
||||
})
|
||||
.disable()
|
||||
.build()
|
||||
.finish()
|
||||
};
|
||||
|
||||
Flex::column()
|
||||
.with_child(render_body_item::<PrivacyPageAction>(
|
||||
"Store AI conversations in the cloud".into(),
|
||||
None,
|
||||
LocalOnlyIconState::Hidden,
|
||||
toggle_state,
|
||||
appearance,
|
||||
switch,
|
||||
None,
|
||||
))
|
||||
.with_child(
|
||||
ui_builder
|
||||
.paragraph(
|
||||
if is_checked {
|
||||
"Agent conversations can be shared with others and are retained \
|
||||
when you log in on different devices. This data is only stored \
|
||||
for product functionality, and Warp will not use it for analytics."
|
||||
} else {
|
||||
"Agent conversations are only stored locally on your machine, are \
|
||||
lost upon logout, and cannot be shared. Note: conversation data \
|
||||
for ambient agents are still stored in the cloud."
|
||||
}
|
||||
.to_owned(),
|
||||
)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
),
|
||||
margin: Some(
|
||||
Coords::default()
|
||||
.top(styles::DESCRIPTION_NEGATIVE_MARGIN_OFFSET)
|
||||
.bottom(styles::DESCRIPTION_MARGIN_BOTTOM),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct NetworkLogWidget {
|
||||
link_mouse_state: MouseStateHandle,
|
||||
@@ -1865,133 +1706,6 @@ impl SettingsWidget for NetworkLogWidget {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct DataManagementWidget {
|
||||
link_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl SettingsWidget for DataManagementWidget {
|
||||
type View = PrivacyPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"data management delete account"
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
_view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
_app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let ui_builder = appearance.ui_builder();
|
||||
Flex::column()
|
||||
.with_child(render_body_item::<PrivacyPageAction>(
|
||||
DATA_MANAGEMENT_TITLE.into(),
|
||||
None,
|
||||
// Not rendering a setting, so no need to show local only icon state.
|
||||
LocalOnlyIconState::Hidden,
|
||||
ToggleState::Enabled,
|
||||
appearance,
|
||||
Empty::new().finish(),
|
||||
None,
|
||||
))
|
||||
.with_child(
|
||||
ui_builder
|
||||
.paragraph(DATA_MANAGEMENT_DESCRIPTION)
|
||||
.with_style(UiComponentStyles {
|
||||
font_color: Some(
|
||||
appearance
|
||||
.theme()
|
||||
.sub_text_color(appearance.theme().surface_2())
|
||||
.into_solid(),
|
||||
),
|
||||
margin: Some(
|
||||
Coords::default()
|
||||
.top(styles::DESCRIPTION_NEGATIVE_MARGIN_OFFSET)
|
||||
.bottom(styles::DESCRIPTION_LINE_MARGIN_BOTTOM),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.finish(),
|
||||
)
|
||||
.with_child(
|
||||
Align::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.link(
|
||||
DATA_MANAGEMENT_LINK_TEXT.into(),
|
||||
None,
|
||||
Some(Box::new(|ctx| {
|
||||
ctx.dispatch_typed_action(
|
||||
PrivacyPageAction::OpenDataManagementWebpage,
|
||||
);
|
||||
})),
|
||||
self.link_mouse_state.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.build()
|
||||
.with_margin_bottom(styles::DESCRIPTION_MARGIN_BOTTOM)
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PrivacyPolicyWidget {
|
||||
link_mouse_state: MouseStateHandle,
|
||||
}
|
||||
|
||||
impl SettingsWidget for PrivacyPolicyWidget {
|
||||
type View = PrivacyPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"privacy policy terms"
|
||||
}
|
||||
|
||||
fn render(
|
||||
&self,
|
||||
_view: &Self::View,
|
||||
appearance: &Appearance,
|
||||
_app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
Flex::column()
|
||||
.with_child(render_body_item::<PrivacyPageAction>(
|
||||
PRIVACY_POLICY_TITLE.into(),
|
||||
None,
|
||||
// Not rendering a setting, so no need to show local only icon state.
|
||||
LocalOnlyIconState::Hidden,
|
||||
ToggleState::Enabled,
|
||||
appearance,
|
||||
Empty::new().finish(),
|
||||
None,
|
||||
))
|
||||
.with_child(
|
||||
Align::new(
|
||||
appearance
|
||||
.ui_builder()
|
||||
.link(
|
||||
PRIVACY_POLICY_LINK_TEXT.into(),
|
||||
Some(PRIVACY_POLICY_URL.into()),
|
||||
None,
|
||||
self.link_mouse_state.clone(),
|
||||
)
|
||||
.soft_wrap(false)
|
||||
.build()
|
||||
.with_margin_bottom(styles::DESCRIPTION_MARGIN_BOTTOM)
|
||||
.finish(),
|
||||
)
|
||||
.left()
|
||||
.finish(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_actions_from_parent_view<T: Action + Clone>(
|
||||
app: &mut AppContext,
|
||||
context: &ContextPredicate,
|
||||
|
||||
@@ -11,15 +11,11 @@ use super::{
|
||||
appearance_page::AppearanceSettingsPageView,
|
||||
billing_and_usage_page::BillingAndUsagePageView,
|
||||
code_page::CodeSettingsPageView,
|
||||
environments_page::EnvironmentsPageView,
|
||||
features_page::FeaturesPageView,
|
||||
keybindings::KeybindingsView,
|
||||
main_page::MainSettingsPageView,
|
||||
mcp_servers_page::MCPServersSettingsPageView,
|
||||
privacy_page::PrivacyPageView,
|
||||
referrals_page::ReferralsPageView,
|
||||
show_blocks_view::ShowBlocksView,
|
||||
teams_page::TeamsPageView,
|
||||
warp_drive_page::WarpDriveSettingsPageView,
|
||||
warpify_page::WarpifyPageView,
|
||||
SettingsSection,
|
||||
@@ -106,17 +102,12 @@ pub enum SettingsPageViewHandle {
|
||||
Main(ViewHandle<MainSettingsPageView>),
|
||||
Appearance(ViewHandle<AppearanceSettingsPageView>),
|
||||
Features(ViewHandle<FeaturesPageView>),
|
||||
SharedBlocks(ViewHandle<ShowBlocksView>),
|
||||
Keybindings(ViewHandle<KeybindingsView>),
|
||||
About(ViewHandle<AboutPageView>),
|
||||
Code(ViewHandle<CodeSettingsPageView>),
|
||||
Teams(ViewHandle<TeamsPageView>),
|
||||
OzCloudAPIKeys(ViewHandle<super::platform_page::PlatformPageView>),
|
||||
Privacy(ViewHandle<PrivacyPageView>),
|
||||
Warpify(ViewHandle<WarpifyPageView>),
|
||||
Referrals(ViewHandle<ReferralsPageView>),
|
||||
AI(ViewHandle<AISettingsPageView>),
|
||||
CloudEnvironments(ViewHandle<EnvironmentsPageView>),
|
||||
BillingAndUsage(ViewHandle<BillingAndUsagePageView>),
|
||||
MCPServers(ViewHandle<MCPServersSettingsPageView>),
|
||||
WarpDrive(ViewHandle<WarpDriveSettingsPageView>),
|
||||
@@ -129,17 +120,12 @@ impl SettingsPageViewHandle {
|
||||
Main(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Appearance(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Features(view_handle) => ChildView::new(view_handle).finish(),
|
||||
SharedBlocks(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Keybindings(view_handle) => ChildView::new(view_handle).finish(),
|
||||
About(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Code(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Teams(view_handle) => ChildView::new(view_handle).finish(),
|
||||
OzCloudAPIKeys(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Privacy(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Warpify(view_handle) => ChildView::new(view_handle).finish(),
|
||||
Referrals(view_handle) => ChildView::new(view_handle).finish(),
|
||||
AI(view_handle) => ChildView::new(view_handle).finish(),
|
||||
CloudEnvironments(view_handle) => ChildView::new(view_handle).finish(),
|
||||
BillingAndUsage(view_handle) => ChildView::new(view_handle).finish(),
|
||||
MCPServers(view_handle) => ChildView::new(view_handle).finish(),
|
||||
WarpDrive(view_handle) => ChildView::new(view_handle).finish(),
|
||||
|
||||
@@ -75,10 +75,10 @@ const ITEM_VERTICAL_SPACING: f32 = 24.;
|
||||
const BUILT_IN_TEXT_INPUT_MARGIN: f32 = 10.;
|
||||
const SPACE_AFTER_TEXT_INPUT: f32 = ITEM_VERTICAL_SPACING - BUILT_IN_TEXT_INPUT_MARGIN;
|
||||
|
||||
const SSH_TMUX_WARPIFICATION_DESCRIPTION: &str = "The tmux ssh wrapper works in many situations where the default one does not, but may require you to hit a button to warpify. Takes effect in new tabs.";
|
||||
const SSH_TMUX_WARPIFICATION_DESCRIPTION: &str = "The tmux ssh wrapper works in many situations where the default one does not, but may require you to hit a button to galaxify. Takes effect in new tabs.";
|
||||
|
||||
const SSH_EXTENSION_INSTALL_MODE_DESCRIPTION: &str =
|
||||
"Controls the installation behavior for Warp's SSH extension when a remote host doesn't have it installed.";
|
||||
"Controls the installation behavior for Galaxy's SSH extension when a remote host doesn't have it installed.";
|
||||
|
||||
/// This page lets users configure when they get asked to warpify a session. Some shell commands
|
||||
/// are recognized by default. Users can add new shell commands, or prevent the default ones from
|
||||
@@ -185,7 +185,7 @@ impl WarpifyPageView {
|
||||
{
|
||||
categories.push(
|
||||
Category::new("SSH", vec![Box::new(SSHWidget::default())])
|
||||
.with_subtitle("Warpify your interactive SSH sessions."),
|
||||
.with_subtitle("Galaxify your interactive SSH sessions."),
|
||||
);
|
||||
}
|
||||
PageType::new_categorized(categories, None)
|
||||
@@ -532,7 +532,7 @@ impl TitleWidget {
|
||||
fn render_top_of_page(&self, appearance: &Appearance, _app: &AppContext) -> Box<dyn Element> {
|
||||
let warpify_description = vec![
|
||||
FormattedTextFragment::plain_text(
|
||||
"Configure whether Warp attempts to “Warpify” (add support for blocks, \
|
||||
"Configure whether Galaxy attempts to \u{201c}Galaxify\u{201d} (add support for blocks, \
|
||||
input modes, etc) certain shells. ",
|
||||
),
|
||||
FormattedTextFragment::hyperlink(
|
||||
@@ -556,7 +556,7 @@ impl TitleWidget {
|
||||
.finish();
|
||||
|
||||
Flex::column()
|
||||
.with_child(render_page_title("Warpify", HEADER_FONT_SIZE, appearance))
|
||||
.with_child(render_page_title("Galaxify", HEADER_FONT_SIZE, appearance))
|
||||
.with_child(warpify_description)
|
||||
.finish()
|
||||
}
|
||||
@@ -566,7 +566,7 @@ impl SettingsWidget for TitleWidget {
|
||||
type View = WarpifyPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"ssh subshell warpify session"
|
||||
"ssh subshell galaxify session"
|
||||
}
|
||||
|
||||
fn render(
|
||||
@@ -628,7 +628,7 @@ impl SettingsWidget for SubshellsWidget {
|
||||
type View = WarpifyPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"warpify subshell"
|
||||
"galaxify subshell"
|
||||
}
|
||||
|
||||
fn render(
|
||||
@@ -655,7 +655,7 @@ impl SettingsWidget for SSHWidget {
|
||||
type View = WarpifyPageView;
|
||||
|
||||
fn search_terms(&self) -> &str {
|
||||
"warpify ssh"
|
||||
"galaxify ssh"
|
||||
}
|
||||
|
||||
fn render(
|
||||
@@ -682,7 +682,7 @@ impl SettingsWidget for SSHWidget {
|
||||
&WarpifySettings::as_ref(app).enable_ssh_warpification,
|
||||
move || {
|
||||
render_body_item::<WarpifyPageAction>(
|
||||
"Warpify SSH Sessions".into(),
|
||||
"Galaxify SSH Sessions".into(),
|
||||
None,
|
||||
LocalOnlyIconState::for_setting(
|
||||
EnableSshWarpification::storage_key(),
|
||||
|
||||
@@ -35,7 +35,7 @@ const UNSUPPORTED_TMUX_VERSION_ERROR: &str =
|
||||
"The tmux version available on the remote machine is below 3.0. Please install tmux 3.0 or greater using a different method and try again.";
|
||||
const TMUX_FAILED_ERROR: &str =
|
||||
"tmux failed to execute on the remote machine. Please re-install tmux and try again.";
|
||||
const WARPIFY_TIMEOUT_ERROR: &str = "Warpifying the session hit a timeout.";
|
||||
const WARPIFY_TIMEOUT_ERROR: &str = "Galaxifying the session hit a timeout.";
|
||||
const UNSUPPORTED_SHELL_ERROR: &str =
|
||||
"Unsupported shell. Please set bash, zsh, or fish as your default shell and try again.";
|
||||
const TMUX_INSTALL_FAILED_ERROR: &str =
|
||||
@@ -258,7 +258,7 @@ impl View for SshErrorBlock {
|
||||
ButtonVariant::Accent,
|
||||
self.warpify_without_tmux_button_mouse_state.clone(),
|
||||
)
|
||||
.with_centered_text_label("Warpify without TMUX".into())
|
||||
.with_centered_text_label("Galaxify without TMUX".into())
|
||||
.with_style(UiComponentStyles {
|
||||
font_size: Some(appearance.monospace_font_size()),
|
||||
..Default::default()
|
||||
|
||||
@@ -67,7 +67,7 @@ impl Entity for SshWarpifyBlock {
|
||||
impl SshWarpifyBlock {
|
||||
fn render_title_ui(&self, theme: &WarpTheme, appearance: &Appearance) -> Box<dyn Element> {
|
||||
let icon = Icon::new(UiIcon::Warp.into(), theme.active_ui_detail());
|
||||
warpify::render::header_row("Warpifying SSH Session...", icon, theme, appearance)
|
||||
warpify::render::header_row("Galaxifying SSH Session...", icon, theme, appearance)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +97,8 @@ impl WarpifyBannerState {
|
||||
|
||||
pub fn title(&self) -> &str {
|
||||
match &self.mode {
|
||||
WarpificationMode::Ssh { .. } => "Warpify SSH session",
|
||||
WarpificationMode::Subshell { .. } => "Warpify subshell",
|
||||
WarpificationMode::Ssh { .. } => "Galaxify SSH session",
|
||||
WarpificationMode::Subshell { .. } => "Galaxify subshell",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ pub fn init(app: &mut AppContext) {
|
||||
),
|
||||
EditableBinding::new(
|
||||
"terminal:warpify_subshell",
|
||||
"Warpify subshell",
|
||||
"Galaxify subshell",
|
||||
TerminalAction::TriggerSubshellBootstrap,
|
||||
)
|
||||
.with_key_binding("ctrl-i")
|
||||
@@ -350,7 +350,7 @@ pub fn init(app: &mut AppContext) {
|
||||
),
|
||||
EditableBinding::new(
|
||||
"terminal:warpify_ssh_session",
|
||||
"Warpify ssh session",
|
||||
"Galaxify ssh session",
|
||||
TerminalAction::WarpifySSHSession,
|
||||
)
|
||||
.with_key_binding("ctrl-i")
|
||||
@@ -1073,35 +1073,7 @@ pub fn init(app: &mut AppContext) {
|
||||
.with_enabled(|| FeatureFlag::Projects.is_enabled())
|
||||
.with_context_predicate(id!("Workspace") & id!(flags::IS_ANY_AI_ENABLED))]);
|
||||
|
||||
// Register bindings for starting a new cloud agent conversation.
|
||||
{
|
||||
app.register_fixed_bindings([FixedBinding::new_per_platform(
|
||||
PerPlatformKeystroke {
|
||||
mac: "cmd-alt-enter",
|
||||
linux_and_windows: "ctrl-alt-enter",
|
||||
},
|
||||
TerminalAction::EnterCloudAgentView,
|
||||
id!("Terminal") & id!(flags::IS_ANY_AI_ENABLED),
|
||||
)
|
||||
.with_enabled(|| {
|
||||
FeatureFlag::AgentView.is_enabled()
|
||||
&& FeatureFlag::CloudMode.is_enabled()
|
||||
&& FeatureFlag::CloudModeFromLocalSession.is_enabled()
|
||||
})
|
||||
.with_group(bindings::BindingGroup::WarpAi.as_str())]);
|
||||
if cfg!(target_os = "macos") {
|
||||
// On MacOS, if the user has the 'Option as meta' setting enabled, the cmd-alt-enter
|
||||
// binding above will not match.
|
||||
//
|
||||
// TODO(zachbai): Consider if, for the purposes of fixed bindings, alt/meta should work
|
||||
// fungibly regardless of underlying setting.
|
||||
app.register_fixed_bindings([FixedBinding::new(
|
||||
"cmd-meta-enter",
|
||||
TerminalAction::EnterCloudAgentView,
|
||||
id!("Terminal") & id!(flags::IS_ANY_AI_ENABLED),
|
||||
)]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Registers bindings related to input modes.
|
||||
|
||||
@@ -33,10 +33,10 @@ impl WarpifyFooterView {
|
||||
let button_size = ButtonSize::XSmall;
|
||||
|
||||
let warpify_button = ctx.add_typed_action_view(|_ctx| {
|
||||
ActionButton::new("Warpify subshell", AgentFooterButtonTheme::new(None))
|
||||
ActionButton::new("Galaxify subshell", AgentFooterButtonTheme::new(None))
|
||||
.with_icon(Icon::Warp)
|
||||
.with_size(button_size)
|
||||
.with_tooltip("Enable Warp shell integration in this session")
|
||||
.with_tooltip("Enable Galaxy shell integration in this session")
|
||||
.with_tooltip_alignment(TooltipAlignment::Left)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(WarpifyFooterViewAction::Warpify);
|
||||
@@ -48,7 +48,7 @@ impl WarpifyFooterView {
|
||||
.with_icon(Icon::Oz)
|
||||
.with_keybinding(KeystrokeSource::Fixed(USE_AGENT_KEYSTROKE.clone()), ctx)
|
||||
.with_size(button_size)
|
||||
.with_tooltip("Ask the Warp agent to assist")
|
||||
.with_tooltip("Ask the Galaxy agent to assist")
|
||||
.with_tooltip_alignment(TooltipAlignment::Left)
|
||||
.on_click(|ctx| {
|
||||
ctx.dispatch_typed_action(WarpifyFooterViewAction::UseAgent);
|
||||
@@ -76,9 +76,9 @@ impl WarpifyFooterView {
|
||||
pub fn set_mode(&mut self, mode: WarpificationMode, ctx: &mut ViewContext<Self>) {
|
||||
let (label, binding_name) = match mode {
|
||||
WarpificationMode::Ssh { .. } => {
|
||||
("Warpify SSH session", "terminal:warpify_ssh_session")
|
||||
("Galaxify SSH session", "terminal:warpify_ssh_session")
|
||||
}
|
||||
WarpificationMode::Subshell { .. } => ("Warpify subshell", "terminal:warpify_subshell"),
|
||||
WarpificationMode::Subshell { .. } => ("Galaxify subshell", "terminal:warpify_subshell"),
|
||||
};
|
||||
self.warpify_button.update(ctx, |button, ctx| {
|
||||
button.set_label(label, ctx);
|
||||
|
||||
@@ -19,7 +19,6 @@ use galaxyui::{
|
||||
use crate::{
|
||||
ai::blocklist::agent_view::{
|
||||
AgentViewController, AgentViewControllerEvent, ENTER_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE,
|
||||
},
|
||||
appearance::Appearance,
|
||||
settings::{AISettings, AISettingsChangedEvent, InputModeSettings},
|
||||
@@ -199,21 +198,6 @@ impl View for TerminalViewZeroStateBlock {
|
||||
)]),
|
||||
app,
|
||||
),
|
||||
render_standard_message(
|
||||
Message::new(vec![MessageItem::clickable(
|
||||
vec![
|
||||
MessageItem::keystroke(
|
||||
ENTER_CLOUD_AGENT_VIEW_NEW_CONVERSATION_KEYSTROKE.clone(),
|
||||
),
|
||||
MessageItem::text("start a new cloud agent conversation"),
|
||||
],
|
||||
|ctx| {
|
||||
ctx.dispatch_typed_action(TerminalAction::EnterCloudAgentView);
|
||||
},
|
||||
self.state_handles.start_cloud_conversation.clone(),
|
||||
)]),
|
||||
app,
|
||||
),
|
||||
render_standard_message(
|
||||
Message::new(vec![MessageItem::clickable(
|
||||
vec![
|
||||
|
||||
@@ -477,7 +477,7 @@ define_settings_group!(TabSettings, settings: [
|
||||
},
|
||||
use_vertical_tabs: UseVerticalTabs {
|
||||
type: bool,
|
||||
default: false,
|
||||
default: true,
|
||||
supported_platforms: SupportedPlatforms::ALL,
|
||||
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
|
||||
private: false,
|
||||
|
||||
@@ -3174,6 +3174,11 @@ impl Workspace {
|
||||
registry.register(window_id, weak_handle);
|
||||
});
|
||||
|
||||
#[cfg(feature = "bedrock_smoke_test")]
|
||||
{
|
||||
crate::bedrock_smoke_test::schedule(ctx);
|
||||
}
|
||||
|
||||
ws
|
||||
}
|
||||
|
||||
@@ -17340,8 +17345,17 @@ impl Workspace {
|
||||
}
|
||||
|
||||
if FeatureFlag::AvatarInTabBar.is_enabled() {
|
||||
let resource_center_closed = !self.current_workspace_state.is_resource_center_open;
|
||||
if resource_center_closed && ContextFlag::WarpEssentials.is_enabled() {
|
||||
target.add_child(
|
||||
Container::new(self.render_resource_center_button(appearance, ctx))
|
||||
.with_margin_left(TAB_BAR_PADDING_LEFT)
|
||||
.finish(),
|
||||
);
|
||||
}
|
||||
|
||||
target.add_child(
|
||||
Container::new(self.render_avatar_button(appearance, ctx))
|
||||
Container::new(self.render_settings_button(appearance))
|
||||
.with_margin_left(TAB_BAR_PADDING_LEFT)
|
||||
.finish(),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user