diff --git a/app/src/server/server_api/factory.rs b/app/src/server/server_api/factory.rs index e6f50e6436a..be8ab067220 100644 --- a/app/src/server/server_api/factory.rs +++ b/app/src/server/server_api/factory.rs @@ -1,8 +1,12 @@ +#[cfg(any(target_family = "wasm", test))] +use anyhow::Context; use anyhow::{Result, anyhow}; use async_trait::async_trait; use cynic::{MutationBuilder, QueryBuilder}; #[cfg(test)] use mockall::automock; +#[cfg(any(target_family = "wasm", test))] +use serde::Deserialize; use warp_graphql::mutations::delete_runner::{ DeleteRunner, DeleteRunnerInput, DeleteRunnerResult, DeleteRunnerVariables, }; @@ -26,11 +30,20 @@ pub struct UpsertedRunner { pub is_update: bool, } -/// Client for the Factory GraphQL surface (runner CRUD). +#[cfg(any(target_family = "wasm", test))] +#[derive(Debug, Deserialize)] +struct FactoryAccessResponse { + allowed: bool, +} + +/// Client for the Factory API surface. #[cfg_attr(test, automock)] #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] pub trait FactoryClient: 'static + Send + Sync { + #[cfg(any(target_family = "wasm", test))] + /// Returns whether the current principal is in the Factory early-access rollout. + async fn has_factory_access(&self) -> Result; /// Fetch all runners visible to the caller, optionally sorted. async fn get_runners(&self, sort_by: Option) -> Result>; @@ -47,6 +60,34 @@ pub trait FactoryClient: 'static + Send + Sync { #[cfg_attr(not(target_family = "wasm"), async_trait)] #[cfg_attr(target_family = "wasm", async_trait(?Send))] impl FactoryClient for ServerApi { + #[cfg(any(target_family = "wasm", test))] + async fn has_factory_access(&self) -> Result { + let auth_token = self + .get_or_refresh_access_token() + .await + .context("Failed to get access token for Factory access request")?; + let url = format!( + "{}/api/v1/factory/access", + crate::ChannelState::server_root_url() + ); + let mut request = self.http_client().get(&url); + if let Some(token) = auth_token.as_bearer_token() { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .with_context(|| format!("Failed to send Factory access request to {url}"))?; + if !response.status().is_success() { + self.observe_iap_challenge(&response); + return Err(Self::error_from_response(response).await); + } + let response: FactoryAccessResponse = response + .json() + .await + .with_context(|| format!("Failed to deserialize Factory access response from {url}"))?; + Ok(response.allowed) + } async fn get_runners(&self, sort_by: Option) -> Result> { let operation = GetRunners::build(GetRunnersVariables { request_context: get_request_context(), @@ -97,3 +138,7 @@ impl FactoryClient for ServerApi { } } } + +#[cfg(test)] +#[path = "factory_tests.rs"] +mod tests; diff --git a/app/src/server/server_api/factory_tests.rs b/app/src/server/server_api/factory_tests.rs new file mode 100644 index 00000000000..d36af9460e0 --- /dev/null +++ b/app/src/server/server_api/factory_tests.rs @@ -0,0 +1,48 @@ +use std::sync::Arc; + +use futures::executor::block_on; + +use super::FactoryClient; +use crate::ChannelState; +use crate::auth::auth_state::AuthState; +use crate::server::server_api::ServerApi; +use crate::server::telemetry::TelemetryApi; + +fn server_api_with_bearer_token() -> ServerApi { + let (event_sender, _) = async_channel::unbounded(); + let auth_state = Arc::new(AuthState::new_logged_out_for_test()); + auth_state.set_remote_server_bearer_token("factory-access-token".to_string()); + ServerApi::new_with_parts( + Arc::new(http_client::Client::new_for_test()), + auth_state, + event_sender, + None, + None, + TelemetryApi::new(), + ) +} + +fn factory_access_response(body: &str) -> anyhow::Result { + let _request = { + let mut server = ChannelState::mock_server(); + server + .mock("GET", "/api/v1/factory/access") + .match_header("authorization", "Bearer factory-access-token") + .with_status(200) + .with_body(body) + .create() + }; + + block_on(server_api_with_bearer_token().has_factory_access()) +} + +#[test] +fn factory_access_request_uses_authenticated_endpoint_and_decodes_rollout_state() { + assert!(factory_access_response(r#"{"allowed":true}"#).unwrap()); + assert!(!factory_access_response(r#"{"allowed":false}"#).unwrap()); +} + +#[test] +fn factory_access_request_rejects_malformed_response() { + assert!(factory_access_response(r#"{"access":true}"#).is_err()); +} diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 2876acf6f1a..7f79b89f280 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -336,6 +336,8 @@ use crate::server::cloud_objects::update_manager::{ use crate::server::ids::{ObjectUid, ServerId, SyncId}; use crate::server::network_log_pane_manager::NetworkLogPaneManager; use crate::server::server_api::ai::AIClient; +#[cfg(any(target_family = "wasm", test))] +use crate::server::server_api::factory::FactoryClient; use crate::server::server_api::{ServerApi, ServerApiProvider, ServerTime}; use crate::server::telemetry::{ AddTabWithShellSource, AnonymousUserSignupEntrypoint, CloseTarget, EnvVarTelemetryMetadata, @@ -473,8 +475,8 @@ use crate::util::openable_file_type::{ }; use crate::util::traffic_lights::{TrafficLightMouseStates, TrafficLightSide, traffic_light_data}; use crate::util::truncation::truncate_from_end; -#[cfg(target_family = "wasm")] -use crate::view_components::action_button::ActionButton; +#[cfg(any(target_family = "wasm", test))] +use crate::view_components::action_button::{ActionButton, SecondaryTheme}; use crate::view_components::callout_bubble::{ CalloutArrowDirection, CalloutArrowPosition, CalloutBubbleConfig, render_callout_bubble, }; @@ -862,6 +864,34 @@ enum SimplifiedWasmTabBarContent { ConversationTranscript { task_id: Option }, } +#[cfg(any(target_family = "wasm", test))] +#[derive(Clone, Debug, PartialEq, Eq)] +struct SimplifiedWasmProductNavigation { + action_label: &'static str, + destination: String, +} + +#[cfg(any(target_family = "wasm", test))] +impl SimplifiedWasmProductNavigation { + fn from_factory_access(allowed: bool) -> Self { + if allowed { + Self { + action_label: "View Factory", + destination: ChannelState::server_root_url().into_owned(), + } + } else { + Self { + action_label: "View in Oz", + destination: format!("{}/runs", ChannelState::oz_root_url()), + } + } + } + + fn open_action(&self) -> WorkspaceAction { + WorkspaceAction::OpenLink(self.destination.clone()) + } +} + type RemoteUploadId = (TerminalPaneId, FileUploadId); type WorkspaceMenuHandles = ( ViewHandle>, @@ -1156,8 +1186,10 @@ pub struct Workspace { wasm_nux_dialog: ViewHandle, #[cfg(target_family = "wasm")] open_in_warp_button: ViewHandle, - #[cfg(target_family = "wasm")] - view_cloud_runs_button: ViewHandle, + #[cfg(any(target_family = "wasm", test))] + view_product_button: ViewHandle, + #[cfg(any(target_family = "wasm", test))] + simplified_wasm_product_navigation: SimplifiedWasmProductNavigation, #[cfg(target_family = "wasm")] transcript_info_button: ViewHandle, #[cfg(target_family = "wasm")] @@ -1220,6 +1252,45 @@ pub struct Workspace { } impl Workspace { + #[cfg(any(target_family = "wasm", test))] + fn build_view_product_button( + navigation: SimplifiedWasmProductNavigation, + ctx: &mut ViewContext, + ) -> ViewHandle { + let label = navigation.action_label; + let action = navigation.open_action(); + ctx.add_typed_action_view(|_| { + ActionButton::new(label, SecondaryTheme).on_click(move |ctx| { + let action = action.clone(); + ctx.dispatch_typed_action(action); + }) + }) + } + + #[cfg(any(target_family = "wasm", test))] + fn fetch_factory_access_with_client( + &mut self, + factory_client: Arc, + ctx: &mut ViewContext, + ) { + ctx.spawn( + async move { factory_client.has_factory_access().await }, + |workspace, result, ctx| match result { + Ok(allowed) => { + let navigation = SimplifiedWasmProductNavigation::from_factory_access(allowed); + workspace.view_product_button = + Self::build_view_product_button(navigation.clone(), ctx); + workspace.simplified_wasm_product_navigation = navigation; + ctx.notify(); + } + Err(error) => { + log::warn!( + "Failed to check Factory access; keeping legacy Oz navigation: {error:#}" + ); + } + }, + ); + } /// Whether this workspace was opened directly against a shared session, cloud /// conversation, or similar deep-linked content. pub(crate) fn opened_from_content_deep_link(&self) -> bool { @@ -3259,8 +3330,13 @@ impl Workspace { #[cfg(target_family = "wasm")] let transcript_info_button = Self::build_transcript_info_button(ctx); - #[cfg(target_family = "wasm")] - let view_cloud_runs_button = Self::build_view_cloud_runs_button(ctx); + #[cfg(any(target_family = "wasm", test))] + let simplified_wasm_product_navigation = + SimplifiedWasmProductNavigation::from_factory_access(false); + + #[cfg(any(target_family = "wasm", test))] + let view_product_button = + Self::build_view_product_button(simplified_wasm_product_navigation.clone(), ctx); #[cfg(target_family = "wasm")] let transcript_details_panel = Self::build_transcript_details_panel(ctx); @@ -3515,8 +3591,10 @@ impl Workspace { open_in_warp_button, #[cfg(target_family = "wasm")] transcript_info_button, - #[cfg(target_family = "wasm")] - view_cloud_runs_button, + #[cfg(any(target_family = "wasm", test))] + view_product_button, + #[cfg(any(target_family = "wasm", test))] + simplified_wasm_product_navigation, #[cfg(target_family = "wasm")] transcript_details_panel, tab_fixed_width: None, @@ -3569,6 +3647,8 @@ impl Workspace { // any) read from `GlobalResourceHandles`. Subsequent updates are // pushed by `subscribe_to_settings_errors` and `dismiss_workspace_banner`. ws.sync_settings_error_state_into_settings_pane(ctx); + #[cfg(target_family = "wasm")] + ws.fetch_factory_access(ctx); let weak_handle = ctx.handle(); WorkspaceRegistry::handle(ctx).update(ctx, |registry, _| { @@ -20865,7 +20945,7 @@ impl Workspace { .with_main_axis_size(MainAxisSize::Max); let bg_color = blended_colors::neutral_1(appearance.theme()); - // Left: Warp logo - clickable to link to warp.dev + let open_product_action = self.simplified_wasm_product_navigation.open_action(); let warp_logo = Hoverable::new(self.mouse_states.warp_logo.clone(), |_state| { ConstrainedBox::new( warp_core::ui::Icon::Warp @@ -20876,25 +20956,18 @@ impl Workspace { .with_width(24.) .finish() }) - .on_click(|ctx, _, _| { - ctx.dispatch_typed_action(WorkspaceAction::OpenLink("https://warp.dev".to_owned())); + .on_click(move |ctx, _, _| { + let open_product_action = open_product_action.clone(); + ctx.dispatch_typed_action(open_product_action); }) .with_cursor(Cursor::PointingHand) .finish(); tab_bar.add_child(warp_logo); - // Right: Info button + "View all cloud runs" button (for ambient agent sessions) + "Open in Warp" button let mut right_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min); - // Extract task_id from conversation transcripts and shared sessions - let task_id = match content_type { - SimplifiedWasmTabBarContent::ConversationTranscript { task_id } - | SimplifiedWasmTabBarContent::SharedSession { task_id } => task_id, - SimplifiedWasmTabBarContent::WarpDriveObject => None, - }; - // Show info button for conversation transcripts and shared sessions (if there's content to display) let should_show_info_button = !matches!(content_type, SimplifiedWasmTabBarContent::WarpDriveObject) @@ -20912,15 +20985,14 @@ impl Workspace { .with_margin_right(8.) .finish(), ); + } - // Add "View all cloud runs" button when task_id exists (with 4px gap) - if task_id.is_some() { - right_row.add_child( - Container::new(ChildView::new(&self.view_cloud_runs_button).finish()) - .with_margin_right(4.) - .finish(), - ); - } + if !matches!(content_type, SimplifiedWasmTabBarContent::WarpDriveObject) { + right_row.add_child( + Container::new(ChildView::new(&self.view_product_button).finish()) + .with_margin_right(4.) + .finish(), + ); } // Hide "Open in Warp" button on mobile devices diff --git a/app/src/workspace/view/wasm_view.rs b/app/src/workspace/view/wasm_view.rs index d4bb881c214..5f6cae526b1 100644 --- a/app/src/workspace/view/wasm_view.rs +++ b/app/src/workspace/view/wasm_view.rs @@ -1,6 +1,5 @@ //! WASM-only view functions for the Workspace. -use warp_core::channel::ChannelState; use warpui::elements::{ChildView, Element}; use warpui::{AppContext, SingletonEntity, ViewContext, ViewHandle}; @@ -10,23 +9,17 @@ use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::conversation_details_panel::{ ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent, }; +use crate::server::server_api::ServerApiProvider; use crate::terminal::TerminalView; use crate::ui_components::icons; use crate::uri::browser_url_handler::parse_current_url; -use crate::view_components::action_button::{ - ActionButton, ButtonSize, NakedTheme, PrimaryTheme, SecondaryTheme, -}; +use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, PrimaryTheme}; use crate::wasm_nux_dialog::{WasmNUXDialog, WasmNUXDialogEvent}; use crate::workspace::action::WorkspaceAction; use crate::workspace::view::{NotebookSource, OpenWarpDriveObjectSettings, Workspace}; const TRANSCRIPT_PANEL_WIDTH: f32 = 280.0; -/// Builds the OZ runs URL for viewing all cloud runs. -fn build_oz_runs_url() -> String { - format!("{}/runs", ChannelState::oz_root_url()) -} - impl Workspace { pub(super) fn build_wasm_nux_dialog(ctx: &mut ViewContext) -> ViewHandle { let wasm_nux_dialog = ctx.add_typed_action_view(|_| WasmNUXDialog::new()); @@ -54,15 +47,9 @@ impl Workspace { }) } - pub(super) fn build_view_cloud_runs_button( - ctx: &mut ViewContext, - ) -> ViewHandle { - let url = build_oz_runs_url(); - ctx.add_typed_action_view(|_ctx| { - ActionButton::new("View all cloud runs", SecondaryTheme).on_click(move |ctx| { - ctx.dispatch_typed_action(WorkspaceAction::OpenLink(url.clone())); - }) - }) + pub(super) fn fetch_factory_access(&mut self, ctx: &mut ViewContext) { + let factory_client = ServerApiProvider::as_ref(ctx).get_factory_client(); + self.fetch_factory_access_with_client(factory_client, ctx); } pub(super) fn build_transcript_info_button( diff --git a/app/src/workspace/view_tests.rs b/app/src/workspace/view_tests.rs index 63570077665..5ef2ef29fdd 100644 --- a/app/src/workspace/view_tests.rs +++ b/app/src/workspace/view_tests.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; use ai::project_context::model::ProjectContextModel; @@ -59,6 +60,7 @@ use crate::server::cloud_objects::listener::Listener; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::experiments::ServerExperiments; use crate::server::server_api::ServerApiProvider; +use crate::server::server_api::factory::MockFactoryClient; use crate::server::sync_queue::SyncQueue; use crate::server::telemetry::context_provider::AppTelemetryContextProvider; use crate::settings::PrivacySettings; @@ -90,6 +92,160 @@ use crate::workspaces::user_workspaces::UserWorkspaces; use crate::{ AgentNotificationsModel, GlobalResourceHandlesProvider, ObjectActions, experiments, workspace, }; + +#[test] +fn simplified_wasm_navigation_uses_factory_destination_for_allowed_users() { + let navigation = SimplifiedWasmProductNavigation::from_factory_access(true); + + assert_eq!(navigation.action_label, "View Factory"); + assert_eq!(navigation.destination, ChannelState::server_root_url()); +} + +#[test] +fn simplified_wasm_navigation_falls_back_to_legacy_oz_destination() { + let navigation = SimplifiedWasmProductNavigation::from_factory_access(false); + + assert_eq!(navigation.action_label, "View in Oz"); + assert_open_link_action( + &navigation, + &format!("{}/runs", ChannelState::oz_root_url()), + ); +} + +fn assert_open_link_action(navigation: &SimplifiedWasmProductNavigation, expected: &str) { + match navigation.open_action() { + WorkspaceAction::OpenLink(destination) => assert_eq!(destination, expected), + _ => panic!("expected OpenLink action"), + } +} + +#[test] +fn factory_access_updates_workspace_navigation_asynchronously() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + let legacy_destination = format!("{}/runs", ChannelState::oz_root_url()); + let initial_button_id = workspace.read(&app, |workspace, _| { + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + assert_open_link_action( + &workspace.simplified_wasm_product_navigation, + &legacy_destination, + ); + workspace.view_product_button.id() + }); + + let mut factory_client = MockFactoryClient::new(); + factory_client + .expect_has_factory_access() + .once() + .return_once(|| Ok(true)); + workspace.update(&mut app, |workspace, ctx| { + workspace.fetch_factory_access_with_client(Arc::new(factory_client), ctx); + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + assert_eq!(workspace.view_product_button.id(), initial_button_id); + }); + + crate::test_util::assert_eventually!( + workspace.read(&app, |workspace, _| { + workspace.simplified_wasm_product_navigation.action_label == "View Factory" + && workspace.view_product_button.id() != initial_button_id + }), + "Factory access did not update the workspace navigation" + ); + workspace.read(&app, |workspace, _| { + assert_open_link_action( + &workspace.simplified_wasm_product_navigation, + ChannelState::server_root_url().as_ref(), + ); + }); + }); +} + +#[test] +fn denied_factory_access_keeps_legacy_destination_and_refreshes_button() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + let initial_button_id = + workspace.read(&app, |workspace, _| workspace.view_product_button.id()); + let mut factory_client = MockFactoryClient::new(); + factory_client + .expect_has_factory_access() + .once() + .return_once(|| Ok(false)); + + workspace.update(&mut app, |workspace, ctx| { + workspace.fetch_factory_access_with_client(Arc::new(factory_client), ctx); + }); + + crate::test_util::assert_eventually!( + workspace.read(&app, |workspace, _| { + workspace.view_product_button.id() != initial_button_id + }), + "Denied Factory access did not complete" + ); + workspace.read(&app, |workspace, _| { + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + assert_open_link_action( + &workspace.simplified_wasm_product_navigation, + &format!("{}/runs", ChannelState::oz_root_url()), + ); + }); + }); +} + +#[test] +fn failed_factory_access_preserves_loading_fallback() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + let initial_button_id = + workspace.read(&app, |workspace, _| workspace.view_product_button.id()); + let probe_completed = Arc::new(AtomicBool::new(false)); + let probe_completed_for_mock = probe_completed.clone(); + let mut factory_client = MockFactoryClient::new(); + factory_client + .expect_has_factory_access() + .once() + .return_once(move || { + probe_completed_for_mock.store(true, Ordering::SeqCst); + Err(anyhow::anyhow!("malformed response")) + }); + + workspace.update(&mut app, |workspace, ctx| { + workspace.fetch_factory_access_with_client(Arc::new(factory_client), ctx); + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + }); + + crate::test_util::assert_eventually!( + probe_completed.load(Ordering::SeqCst), + "Failed Factory access probe did not complete" + ); + workspace.read(&app, |workspace, _| { + assert_eq!(workspace.view_product_button.id(), initial_button_id); + assert_eq!( + workspace.simplified_wasm_product_navigation.action_label, + "View in Oz" + ); + assert_open_link_action( + &workspace.simplified_wasm_product_navigation, + &format!("{}/runs", ChannelState::oz_root_url()), + ); + }); + }); +} pub(crate) fn initialize_app(app: &mut App) { initialize_settings_for_tests(app);