From 89bd049db6ac51d89901c111defc9555fdd4704c Mon Sep 17 00:00:00 2001 From: Oz Date: Mon, 13 Jul 2026 21:43:24 +0000 Subject: [PATCH] Allow cloud environments without Docker images Co-Authored-By: Oz --- app/src/ai/agent_sdk/environment.rs | 18 +++++--- app/src/ai/conversation_details_panel.rs | 38 +++++++++------- app/src/context_chips/display_menu.rs | 8 +++- app/src/settings_view/environments_page.rs | 44 ++++++++++++------- .../settings_view/environments_page_tests.rs | 2 +- .../src/cloud_environment.rs | 6 +-- .../src/cloud_environment_tests.rs | 43 +++++++++++++++++- 7 files changed, 114 insertions(+), 45 deletions(-) diff --git a/app/src/ai/agent_sdk/environment.rs b/app/src/ai/agent_sdk/environment.rs index 425cb999f89..b4958e6e8a0 100644 --- a/app/src/ai/agent_sdk/environment.rs +++ b/app/src/ai/agent_sdk/environment.rs @@ -300,10 +300,8 @@ impl EnvironmentCommandRunner { if let Some(desc) = &env.description { println!("Description: {desc}"); } - match &env.base_image { - BaseImage::DockerImage(img) => { - println!("Docker image: {img}"); - } + if let Some(BaseImage::DockerImage(img)) = &env.base_image { + println!("Docker image: {img}"); } if env.github_repos.is_empty() { println!("Repositories: None"); @@ -952,7 +950,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 +1119,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, @@ -1161,7 +1160,12 @@ impl TableFormat for EnvironmentInfo { Cell::new(&self.id), Cell::new(&self.name), Cell::new(description_display), - Cell::new(self.base_image.to_string()), + Cell::new( + self.base_image + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(), + ), 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..d1328ae0843 100644 --- a/app/src/ai/conversation_details_panel.rs +++ b/app/src/ai/conversation_details_panel.rs @@ -1635,7 +1635,6 @@ impl ConversationDetailsPanel { app: &AppContext, ) -> Box { let environment_name = &env_model.name; - let docker_image = env_model.base_image.to_string(); let theme = appearance.theme(); let ui_font_size = appearance.ui_font_size(); @@ -1702,16 +1701,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) = env_model.base_image.as_ref().map(ToString::to_string) { + 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 +2264,17 @@ 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(docker_image) = env + .model() + .string_model + .base_image + .as_ref() + .map(ToString::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..d88bf8e61f1 100644 --- a/app/src/context_chips/display_menu.rs +++ b/app/src/context_chips/display_menu.rs @@ -664,7 +664,13 @@ 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) + .unwrap_or_default(), repos_text, }) } diff --git a/app/src/settings_view/environments_page.rs b/app/src/settings_view/environments_page.rs index 84265303660..7f904746db9 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,11 @@ impl EnvironmentsPageWidget { } } - let mut details_parts = vec![format!("Image: {}", env_docker_image)]; + let mut details_parts = Vec::new(); + + if let Some(docker_image) = &env_docker_image { + details_parts.push(format!("Image: {docker_image}")); + } if !env_github_repos.is_empty() { let repos_text = env_github_repos @@ -1836,18 +1844,20 @@ impl EnvironmentsPageWidget { details_section.add_child(env_id_with_copy); // Add other details on a new line - selectable - let details_text = details_parts.join(" · "); - details_section.add_child( - Text::new( - details_text, - appearance.ui_font_family(), - appearance.ui_font_size() * 0.9, - ) - .soft_wrap(true) - .with_color(blended_colors::text_sub(theme, theme.surface_1())) - .with_selectable(true) - .finish(), - ); + if !details_parts.is_empty() { + let details_text = details_parts.join(" · "); + details_section.add_child( + Text::new( + details_text, + appearance.ui_font_family(), + appearance.ui_font_size() * 0.9, + ) + .soft_wrap(true) + .with_color(blended_colors::text_sub(theme, theme.surface_1())) + .with_selectable(true) + .finish(), + ); + } let timestamp_color = blended_colors::text_sub(theme, theme.surface_1()); 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..c51378e6cf2 100644 --- a/crates/cloud_object_models/src/cloud_environment.rs +++ b/crates/cloud_object_models/src/cloud_environment.rs @@ -182,8 +182,8 @@ pub struct AmbientAgentEnvironment { #[serde(default, skip_serializing_if = "Option::is_none")] pub source_repos: Option>, /// Base image specification - #[serde(flatten)] - pub base_image: BaseImage, + #[serde(default, skip_serializing_if = "Option::is_none", flatten)] + pub base_image: Option, /// List of setup commands to run after cloning #[serde(default)] pub setup_commands: Vec, @@ -212,7 +212,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..220dd0f5131 100644 --- a/crates/cloud_object_models/src/cloud_environment_tests.rs +++ b/crates/cloud_object_models/src/cloud_environment_tests.rs @@ -2,6 +2,47 @@ use super::{ AmbientAgentEnvironment, AwsProviderConfig, BaseImage, CodeForge, EnvironmentSecretRef, GcpProviderConfig, GithubRepo, ProvidersConfig, SourceRepo, }; +#[test] +fn deserialize_environment_without_docker_image() { + let json = serde_json::json!({ + "name": "default-image-env", + "github_repos": [], + "setup_commands": [] + }); + let env: AmbientAgentEnvironment = serde_json::from_value(json).unwrap(); + + assert_eq!(env.base_image, None); +} + +#[test] +fn absent_docker_image_roundtrip_omits_json_field() { + let mut env = + AmbientAgentEnvironment::new("no-image-env".into(), None, vec![], String::new(), vec![]); + env.base_image = None; + + let json = serde_json::to_value(&env).unwrap(); + assert!(!json.as_object().unwrap().contains_key("docker_image")); + + let roundtripped: AmbientAgentEnvironment = serde_json::from_value(json).unwrap(); + assert_eq!(roundtripped, env); +} + +#[test] +fn docker_image_roundtrip_preserves_flattened_json_shape() { + let json = serde_json::json!({ + "name": "custom-image-env", + "github_repos": [], + "docker_image": "ubuntu:latest", + "setup_commands": [] + }); + + let env: AmbientAgentEnvironment = serde_json::from_value(json.clone()).unwrap(); + assert_eq!( + env.base_image, + Some(BaseImage::DockerImage("ubuntu:latest".into())) + ); + assert_eq!(serde_json::to_value(env).unwrap(), json); +} #[test] fn deserialize_legacy_environment_without_providers() { @@ -28,7 +69,7 @@ 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"]); }