diff --git a/app/src/ai/agent_sdk/environment.rs b/app/src/ai/agent_sdk/environment.rs index 425cb999f89..20b1ffafa82 100644 --- a/app/src/ai/agent_sdk/environment.rs +++ b/app/src/ai/agent_sdk/environment.rs @@ -301,9 +301,12 @@ impl EnvironmentCommandRunner { println!("Description: {desc}"); } match &env.base_image { - BaseImage::DockerImage(img) => { + Some(BaseImage::DockerImage(img)) => { println!("Docker image: {img}"); } + None => { + println!("Docker image: None"); + } } if env.github_repos.is_empty() { println!("Repositories: None"); @@ -952,7 +955,7 @@ impl EnvironmentCommandRunner { } if let Some(new_docker_image) = docker_image { - updated_env.base_image = BaseImage::DockerImage(new_docker_image); + updated_env.base_image = Some(BaseImage::DockerImage(new_docker_image)); } for repo in add_repos { @@ -1121,7 +1124,8 @@ struct EnvironmentInfo { name: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, - base_image: BaseImage, + #[serde(skip_serializing_if = "Option::is_none")] + base_image: Option, github_repos: Vec, setup_commands: Vec, creator_email: String, @@ -1156,12 +1160,17 @@ impl TableFormat for EnvironmentInfo { .join(", "); let setup_commands_display = self.setup_commands.join("\n"); let description_display = self.description.as_deref().unwrap_or(""); + let base_image_display = self + .base_image + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "None".to_string()); vec![ Cell::new(&self.id), Cell::new(&self.name), Cell::new(description_display), - Cell::new(self.base_image.to_string()), + Cell::new(base_image_display), Cell::new(github_repos_display), Cell::new(setup_commands_display), Cell::new(&self.creator_email), diff --git a/app/src/ai/conversation_details_panel.rs b/app/src/ai/conversation_details_panel.rs index 51ba6c9776f..c47efdb3b8d 100644 --- a/app/src/ai/conversation_details_panel.rs +++ b/app/src/ai/conversation_details_panel.rs @@ -1635,7 +1635,7 @@ impl ConversationDetailsPanel { app: &AppContext, ) -> Box { let environment_name = &env_model.name; - let docker_image = env_model.base_image.to_string(); + let docker_image = env_model.base_image.as_ref().map(ToString::to_string); let theme = appearance.theme(); let ui_font_size = appearance.ui_font_size(); @@ -1702,16 +1702,18 @@ impl ConversationDetailsPanel { .finish(), ); - section.add_child( - Container::new(render_copyable_field( - "Image", - &docker_image, - CopyButtonKind::DockerImage, - ConversationDetailsPanelAction::CopyDockerImage, - )) - .with_margin_bottom(LABEL_VALUE_GAP) - .finish(), - ); + if let Some(docker_image) = &docker_image { + section.add_child( + Container::new(render_copyable_field( + "Image", + docker_image, + CopyButtonKind::DockerImage, + ConversationDetailsPanelAction::CopyDockerImage, + )) + .with_margin_bottom(LABEL_VALUE_GAP) + .finish(), + ); + } Container::new(section.finish()) .with_margin_bottom(FIELD_SPACING) @@ -2263,10 +2265,12 @@ impl TypedActionView for ConversationDetailsPanel { if let Ok(server_id) = ServerId::try_from(env_id.as_str()) { let sync_id = SyncId::ServerId(server_id); if let Some(env) = CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx) { - let docker_image = env.model().string_model.base_image.to_string(); - ctx.clipboard() - .write(ClipboardContent::plain_text(docker_image)); - self.record_copy(CopyButtonKind::DockerImage, ctx); + if let Some(base_image) = &env.model().string_model.base_image { + let docker_image = base_image.to_string(); + ctx.clipboard() + .write(ClipboardContent::plain_text(docker_image)); + self.record_copy(CopyButtonKind::DockerImage, ctx); + } } } } diff --git a/app/src/context_chips/display_menu.rs b/app/src/context_chips/display_menu.rs index 41513559d52..15fde7bced9 100644 --- a/app/src/context_chips/display_menu.rs +++ b/app/src/context_chips/display_menu.rs @@ -161,7 +161,7 @@ struct FilteredMenuItem { struct EnvironmentSidecarData { name: String, id: String, - image: String, + image: Option, repos_text: String, } @@ -664,7 +664,12 @@ impl DisplayChipMenu { Some(EnvironmentSidecarData { name: env.model().string_model.display_name(), id: env.id.to_string(), - image: env.model().string_model.base_image.to_string(), + image: env + .model() + .string_model + .base_image + .as_ref() + .map(ToString::to_string), repos_text, }) } @@ -824,9 +829,9 @@ impl DisplayChipMenu { ) }; - let image_value = { - let docker_image = data.image.clone(); - render_copyable_text_field( + let image_value = match data.image.clone() { + None => value_text("(none)".to_string()), + Some(docker_image) => render_copyable_text_field( CopyableTextFieldConfig::new(docker_image.clone()) .with_font_size(value_font_size) .with_text_color(main_text_color) @@ -843,7 +848,7 @@ impl DisplayChipMenu { }); }, app, - ) + ), }; let row = |row_icon: Icon, label: &str, value: Box, is_last: bool| { diff --git a/app/src/settings_view/environments_page.rs b/app/src/settings_view/environments_page.rs index 84265303660..19de94fd81e 100644 --- a/app/src/settings_view/environments_page.rs +++ b/app/src/settings_view/environments_page.rs @@ -119,7 +119,7 @@ struct EnvironmentDisplayData { id: SyncId, name: String, description: Option, - docker_image: String, + docker_image: Option, github_repos: Vec<(String, String)>, // (owner, repo) setup_commands: Vec, last_edited_ts: Option, @@ -144,7 +144,7 @@ impl EnvironmentDisplayData { id: env.id, name: model.name.clone(), description: model.description.clone(), - docker_image: model.base_image.to_string(), + docker_image: model.base_image.as_ref().map(ToString::to_string), github_repos: model .github_repos .iter() @@ -168,7 +168,7 @@ impl EnvironmentDisplayData { self.id.to_string(), self.name.clone(), self.description.clone().unwrap_or_default(), - self.docker_image.clone(), + self.docker_image.clone().unwrap_or_default(), ]; if haystacks @@ -270,7 +270,11 @@ impl EnvironmentsPageView { name: model.name.clone(), description: model.description.clone().unwrap_or_default(), selected_repos: model.github_repos.clone(), - docker_image: model.base_image.to_string(), + docker_image: model + .base_image + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), setup_commands: model.setup_commands.clone(), } }); @@ -1811,7 +1815,10 @@ impl EnvironmentsPageWidget { } } - let mut details_parts = vec![format!("Image: {}", env_docker_image)]; + let mut details_parts = Vec::new(); + if let Some(image) = &env_docker_image { + details_parts.push(format!("Image: {image}")); + } if !env_github_repos.is_empty() { let repos_text = env_github_repos diff --git a/app/src/settings_view/environments_page_tests.rs b/app/src/settings_view/environments_page_tests.rs index c54e0e44167..5f19701c3bf 100644 --- a/app/src/settings_view/environments_page_tests.rs +++ b/app/src/settings_view/environments_page_tests.rs @@ -55,7 +55,7 @@ fn make_test_environment_with_timestamps( id: SyncId::ClientId(ClientId::new()), name: name.to_string(), description: None, - docker_image: docker_image.to_string(), + docker_image: Some(docker_image.to_string()), github_repos, setup_commands, last_edited_ts, diff --git a/crates/cloud_object_models/src/cloud_environment.rs b/crates/cloud_object_models/src/cloud_environment.rs index 0a23d4f6c13..63bb8ecb58b 100644 --- a/crates/cloud_object_models/src/cloud_environment.rs +++ b/crates/cloud_object_models/src/cloud_environment.rs @@ -181,9 +181,14 @@ pub struct AmbientAgentEnvironment { /// `github_repos`. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_repos: Option>, - /// Base image specification + /// Base image specification. + /// + /// `None` means the environment does not pin a base image (the key is + /// absent from the stored JSON); the server picks a default in that case. + /// Serialization emits no fields for `None`, preserving the existing wire + /// format for environments that do pin an image. #[serde(flatten)] - pub base_image: BaseImage, + pub base_image: Option, /// List of setup commands to run after cloning #[serde(default)] pub setup_commands: Vec, @@ -212,7 +217,7 @@ impl AmbientAgentEnvironment { code_forge: None, github_repos, source_repos: None, - base_image: BaseImage::DockerImage(docker_image), + base_image: Some(BaseImage::DockerImage(docker_image)), setup_commands, providers: ProvidersConfig::default(), secrets: None, diff --git a/crates/cloud_object_models/src/cloud_environment_tests.rs b/crates/cloud_object_models/src/cloud_environment_tests.rs index 74352cdc331..1cd7f5e3735 100644 --- a/crates/cloud_object_models/src/cloud_environment_tests.rs +++ b/crates/cloud_object_models/src/cloud_environment_tests.rs @@ -28,11 +28,77 @@ fn deserialize_legacy_environment_without_providers() { ); assert_eq!( env.base_image, - BaseImage::DockerImage("ubuntu:latest".into()) + Some(BaseImage::DockerImage("ubuntu:latest".into())) ); assert_eq!(env.setup_commands, vec!["echo hello"]); } +#[test] +fn deserialize_environment_without_docker_image() { + let json = serde_json::json!({ + "name": "no-image-env", + "github_repos": [{"owner": "warpdotdev", "repo": "warp"}], + "setup_commands": ["echo hello"] + }); + + let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap(); + assert_eq!(env.name, "no-image-env"); + assert_eq!(env.base_image, None); + assert_eq!(env.github_repos.len(), 1); + assert_eq!(env.setup_commands, vec!["echo hello"]); +} + +#[test] +fn serialize_environment_without_docker_image_omits_field() { + let mut env = AmbientAgentEnvironment::new( + "no-image-env".into(), + None, + vec![], + "ubuntu:latest".into(), + vec![], + ); + env.base_image = None; + + let json = serde_json::to_value(&env).unwrap(); + assert!(!json.as_object().unwrap().contains_key("docker_image")); +} + +#[test] +fn serialize_environment_with_docker_image_preserves_wire_format() { + let env = AmbientAgentEnvironment::new( + "image-env".into(), + None, + vec![], + "ubuntu:latest".into(), + vec![], + ); + + let json = serde_json::to_value(&env).unwrap(); + // The base image must serialize as a flat top-level `docker_image` key. + assert_eq!( + json.get("docker_image"), + Some(&serde_json::json!("ubuntu:latest")) + ); + assert!(!json.as_object().unwrap().contains_key("base_image")); +} + +#[test] +fn roundtrip_serde_without_docker_image() { + let mut env = AmbientAgentEnvironment::new( + "no-image-rt".into(), + Some("desc".into()), + vec![GithubRepo::new("owner".into(), "repo".into())], + "unused".into(), + vec!["make build".into()], + ); + env.base_image = None; + + let serialized = serde_json::to_string(&env).unwrap(); + let deserialized: AmbientAgentEnvironment = serde_json::from_str(&serialized).unwrap(); + assert_eq!(env, deserialized); + assert_eq!(deserialized.base_image, None); +} + #[test] fn deserialize_gitlab_environment_uses_authoritative_source_repos() { let json = serde_json::json!({