From e5bdd5f6977f4288c3371dc09448b4d78c283398 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:32:13 +0000 Subject: [PATCH 1/5] [multi-team P7a] Scope default host slug and agent attribution to the window's team Migrates two settings getters off the ambient `current_workspace().settings` read onto `TeamScope`: - `agent_attribution_setting_for_scope` replaces `get_agent_attribution_setting`, resolved fresh at render from the settings page's window. - `default_host_slug_for_scope` replaces the windowed uses of `default_host_slug`, resolved from the cloud-mode input's window and re-resolved when that window's team changes. - `any_team_has_default_host_slug` answers the windowless `/host` gate explicitly across every team, since that data source is shared with the TUI. `is_invite_link_enabled` and `is_discoverable` are untouched: they are genuinely workspace-only and have no `TeamSettings` equivalent. --- app/src/ai/orchestration/providers.rs | 8 +- app/src/settings/ai.rs | 4 +- app/src/settings_view/warp_agent_page.rs | 24 +- app/src/terminal/input.rs | 61 ++- .../input/slash_commands/data_source/core.rs | 7 +- app/src/workspaces/user_workspaces.rs | 116 ++++- app/src/workspaces/user_workspaces_tests.rs | 395 +++++++++++++++--- 7 files changed, 520 insertions(+), 95 deletions(-) diff --git a/app/src/ai/orchestration/providers.rs b/app/src/ai/orchestration/providers.rs index 38fe3a932e8..c4386bbe747 100644 --- a/app/src/ai/orchestration/providers.rs +++ b/app/src/ai/orchestration/providers.rs @@ -87,6 +87,12 @@ pub fn first_filtered_model_id(harness_type: &str, ctx: &AppContext) -> Option Option { if let Ok(slug) = std::env::var(DEFAULT_HOST_ENV_VAR) { let trimmed = slug.trim(); @@ -95,7 +101,7 @@ pub fn resolve_default_host_slug(ctx: &AppContext) -> Option { } } UserWorkspaces::as_ref(ctx) - .default_host_slug() + .unscoped_default_host_slug() .map(str::to_string) .filter(|s| !s.trim().is_empty()) } diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index 70b6925e1e3..e89e1ed1697 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -2069,8 +2069,8 @@ define_settings_group!(AISettings, settings: [ // Whether Oz should add attribution (co-author line) to commit messages and PRs. // This is the user-level preference; it may be overridden by the team-level - // `enable_warp_attribution` AdminEnablementSetting (see - // `UserWorkspaces::get_agent_attribution_setting`). + // `enable_warp_attribution` AdminEnablementSetting of the window's team (see + // `UserWorkspaces::agent_attribution_setting_for_scope`). agent_attribution_enabled: AgentAttributionEnabled { type: bool, default: true, diff --git a/app/src/settings_view/warp_agent_page.rs b/app/src/settings_view/warp_agent_page.rs index 4a315432021..ea7aa2f9517 100644 --- a/app/src/settings_view/warp_agent_page.rs +++ b/app/src/settings_view/warp_agent_page.rs @@ -1994,7 +1994,7 @@ impl WarpAgentPageView { categories.push(Category::new( "Agent Attribution", - vec![Box::new(AgentAttributionWidget::default())], + vec![Box::new(AgentAttributionWidget::new(ctx))], )); #[cfg_attr(not(feature = "local_fs"), allow(unused_mut))] @@ -4169,9 +4169,18 @@ pub(crate) fn derive_agent_attribution_toggle_state( } } -#[derive(Default)] struct AgentAttributionWidget { toggle: SwitchStateHandle, + view_handle: WeakViewHandle, +} + +impl AgentAttributionWidget { + fn new(ctx: &ViewContext) -> Self { + Self { + toggle: Default::default(), + view_handle: ctx.handle(), + } + } } impl SettingsWidget for AgentAttributionWidget { @@ -4190,7 +4199,16 @@ impl SettingsWidget for AgentAttributionWidget { let ai_settings = AISettings::as_ref(app); let is_any_ai_enabled = ai_settings.is_any_ai_enabled(app); - let org_setting = UserWorkspaces::as_ref(app).get_agent_attribution_setting(); + // Whether this toggle is locked is live policy, so it is resolved from the window + // being painted rather than captured when the page was built: moving the window to a + // team with a different attribution policy has to change the toggle on the next + // frame. A page rendered outside any window has no team to read, so the user keeps + // control of their own preference. + let workspaces = UserWorkspaces::as_ref(app); + let org_setting = workspaces + .team_context(&self.view_handle, app) + .map(|scope| workspaces.agent_attribution_setting_for_scope(&scope)) + .unwrap_or_default(); let state = derive_agent_attribution_toggle_state( &org_setting, *ai_settings.agent_attribution_enabled, diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 3cd18692ec4..900431f35c9 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -407,6 +407,33 @@ pub fn get_input_box_top_border_width() -> f32 { } } +/// The cloud-mode host `ctx`'s window should default to: the `WARP_CLOUD_MODE_DEFAULT_HOST` +/// developer override when set, otherwise the default host configured for that window's team. +/// +/// Re-run on every team change rather than captured once, so a window that moves to a team +/// with a different self-hosted default picks that up instead of keeping the host it opened +/// with. The scope it mints is consumed here and never stored, so nothing can go stale +/// between the read and its use. +/// +/// Resolves through the `ViewContext`'s window rather than a [`WeakViewHandle`], which is the +/// one shape available at both call sites: the first read happens while `Input` is still being +/// constructed, and a view is not in `view_to_window` until construction finishes, so a handle +/// would resolve no window and silently drop the team's host. Cross-window tab drag still does +/// not re-resolve, because no signal for it exists yet (tracked on REV-2205). +fn effective_default_host(ctx: &ViewContext) -> Option { + if let Some(slug) = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST") + .ok() + .filter(|slug| !slug.is_empty()) + { + return Some(slug); + } + let workspaces = UserWorkspaces::as_ref(ctx); + let scope = workspaces.team_context_for_operation(ctx); + workspaces + .default_host_slug_for_scope(&scope) + .map(String::from) +} + pub const COMPLETIONS_MENU_WIDTH: f32 = 330.; pub const OPEN_COMPLETIONS_KEYBINDING_NAME: &str = "input:open_completion_suggestions"; pub const INPUT_A11Y_LABEL: &str = "Command Input."; @@ -2335,15 +2362,7 @@ impl Input { ) -> ViewHandle { let view = ctx .add_typed_action_view(|ctx| HostSelector::new(menu_positioning_provider.clone(), ctx)); - // Env var takes priority over workspace setting for developer testing. - let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST") - .ok() - .filter(|s| !s.is_empty()) - .or_else(|| { - UserWorkspaces::as_ref(ctx) - .default_host_slug() - .map(String::from) - }); + let effective_host = effective_default_host(ctx); if let Some(slug) = &effective_host { view.update(ctx, |selector, ctx| { selector.set_default_host(slug.clone(), ctx); @@ -2376,22 +2395,24 @@ impl Input { }); } }); - // Keep the host selector and view model in sync when workspace metadata refreshes (e.g. - // admin changes default_host_slug). + // Keep the host selector and view model in sync when the host this window should + // default to changes: because the admin edited the team's `default_host_slug`, or + // because the window moved to a team that configures a different one. let view_for_ws = view.clone(); let vm_for_ws = view_model.clone(); ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), move |_me, _, event, ctx| { - if !matches!(event, UserWorkspacesEvent::TeamsChanged) { + // Windows are independent, so a sibling window switching team must not retarget + // this one. + let affects_this_window = matches!(event, UserWorkspacesEvent::TeamsChanged) + || matches!( + event, + UserWorkspacesEvent::WindowTeamChanged { window_id } + if *window_id == ctx.window_id() + ); + if !affects_this_window { return; } - let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST") - .ok() - .filter(|s| !s.is_empty()) - .or_else(|| { - UserWorkspaces::as_ref(ctx) - .default_host_slug() - .map(String::from) - }); + let effective_host = effective_default_host(ctx); if let Some(slug) = &effective_host { view_for_ws.update(ctx, |selector, ctx| { selector.set_default_host(slug.clone(), ctx); diff --git a/app/src/terminal/input/slash_commands/data_source/core.rs b/app/src/terminal/input/slash_commands/data_source/core.rs index f507ac59b26..f77822f8f9e 100644 --- a/app/src/terminal/input/slash_commands/data_source/core.rs +++ b/app/src/terminal/input/slash_commands/data_source/core.rs @@ -420,12 +420,15 @@ pub trait SlashCommandDataSource { fn common_command_gates(&self, ctx: &AppContext) -> CommonCommandGates { let ai_settings = AISettings::as_ref(ctx); - // Hide /host when no default host is configured (env var or workspace setting). + // Hide /host when no default host is configured (env var or team setting). This data + // source is shared with the TUI and runs on a `ModelContext`, so it has no window to + // scope the read to; it asks the cross-team availability question instead, and the + // command itself resolves the window's own host when it runs. let has_default_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST") .ok() .filter(|s| !s.is_empty()) .is_some() - || UserWorkspaces::as_ref(ctx).default_host_slug().is_some(); + || UserWorkspaces::as_ref(ctx).any_team_has_default_host_slug(); CommonCommandGates { is_orchestration_enabled: ai_settings.is_orchestration_enabled(ctx), is_cloud_handoff_enabled: ai_settings.is_cloud_handoff_enabled(ctx), diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index 97da9f8427d..19692bdeffd 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -39,10 +39,10 @@ use crate::settings::{ AISettings, AISettingsChangedEvent, CodeSettings, CodeSettingsChangedEvent, PrivacySettings, }; #[cfg(test)] -use crate::workspaces::workspace::{AIAutonomyPolicy, WorkspaceMember, WorkspaceSettings}; +use crate::workspaces::workspace::{AIAutonomyPolicy, WorkspaceMember}; use crate::workspaces::workspace::{ AiAutonomySettings, AiOverages, PurchaseAddOnCreditsPolicy, SandboxedAgentSettings, - UsageBasedPricingSettings, + UsageBasedPricingSettings, WorkspaceSettings, }; const STRIPE_SUBSCRIPTION_INTERVAL_PAGE_PREFIX: &str = "/upgrade"; @@ -185,10 +185,6 @@ pub struct CreateTeamResponse { /// [`TeamScope`]'s contract. Code with no window at all (e.g. background GEAP token refresh) /// is not this type's job -- it needs its own accessor that reads across every one of the /// user's teams explicitly, in the shape of `UserWorkspaces::teams_allow_codebase_context`. -// Nothing constructs or consumes one outside this module's own tests yet; remove this -// `#[allow(dead_code)]`, and widen visibility to `pub`, once a Group 1 migration PR has a real -// call site. -#[allow(dead_code)] pub(crate) struct TeamContextForOperation { team_uid: Option, } @@ -208,7 +204,6 @@ pub(crate) struct TeamContextForOperation { /// workspace-level data. Code with no window at all must not construct a scope to route around /// this; it should read across every team explicitly, the way /// `UserWorkspaces::teams_allow_codebase_context` does. -#[allow(dead_code)] pub(crate) trait TeamScope { fn team_uid(&self) -> Option; } @@ -443,8 +438,6 @@ impl UserWorkspaces { /// [`TeamContextForOperation`]. This is the only way application code mints one. Always /// succeeds -- a window with no team selected still yields a scope, just one whose /// `team_uid()` is `None`; see [`TeamScope`]'s contract for what that means to a getter. - // Only tests call this today; remove once a Group 1 migration PR has a real call site. - #[allow(dead_code)] pub(crate) fn team_context_for_operation( &self, ctx: &ViewContext, @@ -464,7 +457,6 @@ impl UserWorkspaces { } /// Resolves `view`'s window team for one read. See [`TeamContext`]. - #[allow(dead_code)] pub(crate) fn team_context<'a, T: Entity>( &'a self, view: &WeakViewHandle, @@ -1974,19 +1966,107 @@ impl UserWorkspaces { } } - pub fn default_host_slug(&self) -> Option<&str> { + /// The current workspace's settings, but only when the user belongs to no team at all. + /// + /// `WorkspaceSettings` is not team-neutral data. Whenever the user has any team, + /// `GetEffectiveWorkspaceSettingsForWorkspace` resolves one arbitrarily-chosen team + /// server-side and falls through to a literal `workspaceTeamIDs[0]`, so reading it as a + /// default hands back some other team's policy. It is trustworthy only for a genuinely + /// teamless user, whose settings the server computes from tier defaults. This applies the + /// same guard as [`Self::teams_allow_codebase_context`], which is the shape scoped getters + /// reuse for their no-team branch. + fn teamless_workspace_settings(&self) -> Option<&WorkspaceSettings> { + let is_on_a_team = self + .workspaces + .iter() + .any(|workspace| !workspace.teams.is_empty()); + if is_on_a_team { + return None; + } self.current_workspace() - .and_then(|workspace| workspace.settings.default_host_slug.as_deref()) + .map(|workspace| &workspace.settings) } - /// Returns the team-level agent attribution setting. + /// The default self-hosted worker host slug configured for `scope`'s team. /// - /// Use this to decide whether the user's attribution toggle should be locked - /// (`Enable`/`Disable`) or editable (`RespectUserSetting`). - pub fn get_agent_attribution_setting(&self) -> AdminEnablementSetting { + /// Returns `None` when that team configures none, and also when the scope has no team + /// while the user is on some other team: another team's host is not a substitute. See + /// [`TeamScope`]. + pub(crate) fn default_host_slug_for_scope(&self, scope: &impl TeamScope) -> Option<&str> { + match scope.team_uid() { + Some(team_uid) => self + .team_from_uid(team_uid) + .and_then(|team| team.settings.default_host_slug.as_deref()), + None => self + .teamless_workspace_settings() + .and_then(|settings| settings.default_host_slug.as_deref()), + } + } + + /// Whether *some* team the user belongs to configures a default self-hosted worker host. + /// + /// This answers only the availability question a windowless surface can honestly ask: the + /// `/host` slash command is worth offering when a default host exists anywhere. It + /// deliberately does not choose *which* slug, because there is no defensible ordering over + /// host slugs the way [`AdminEnablementSetting`] has a most-restrictive direction — with + /// two teams configuring different hosts, any pick is arbitrary. Windowed callers must use + /// [`Self::default_host_slug_for_scope`] instead; picking a slug without a window is a + /// product decision that has not been made. + /// + /// Falls back to workspace settings only when the user is on no team, mirroring + /// [`Self::teams_allow_codebase_context`]'s empty-iterator guard. + pub fn any_team_has_default_host_slug(&self) -> bool { + let mut team_slugs = self + .workspaces + .iter() + .flat_map(|workspace| workspace.teams.iter()) + .map(|team| &team.settings.default_host_slug) + .peekable(); + + if team_slugs.peek().is_none() { + return self + .teamless_workspace_settings() + .is_some_and(|settings| settings.default_host_slug.is_some()); + } + + team_slugs.any(Option::is_some) + } + + /// The default self-hosted worker host slug from the current workspace's settings. + /// + /// **Not a team-neutral read**, despite reading workspace settings: see + /// [`Self::teamless_workspace_settings`] for why. Sole remaining caller is + /// `ai::orchestration::resolve_default_host_slug`, which feeds the plan card, the + /// confirmation card, the TUI orchestration block and the handoff pipeline. That chain + /// still needs both a windowless accessor the TUI can reach and a pinned scope for the + /// handoff's chosen destination, so it moves as one follow-up rather than piecemeal. + /// Do not add callers: windowed code uses [`Self::default_host_slug_for_scope`], and a + /// windowless availability check uses [`Self::any_team_has_default_host_slug`]. + pub fn unscoped_default_host_slug(&self) -> Option<&str> { self.current_workspace() - .map(|workspace| workspace.settings.enable_warp_attribution.clone()) - .unwrap_or_default() + .and_then(|workspace| workspace.settings.default_host_slug.as_deref()) + } + + /// The agent attribution policy for `scope`'s team: `Enable` or `Disable` lock the user's + /// attribution toggle, `RespectUserSetting` leaves it editable. + /// + /// This is live UI state rather than a recorded fact, so resolve it from the rendering + /// window's [`TeamContext`] on each frame; a value captured when a surface opened goes + /// stale the moment that window switches team. + pub(crate) fn agent_attribution_setting_for_scope( + &self, + scope: &impl TeamScope, + ) -> AdminEnablementSetting { + match scope.team_uid() { + Some(team_uid) => self + .team_from_uid(team_uid) + .map(|team| team.settings.enable_warp_attribution.clone()) + .unwrap_or_default(), + None => self + .teamless_workspace_settings() + .map(|settings| settings.enable_warp_attribution.clone()) + .unwrap_or_default(), + } } pub fn teams_allow_codebase_context(&self) -> AdminEnablementSetting { diff --git a/app/src/workspaces/user_workspaces_tests.rs b/app/src/workspaces/user_workspaces_tests.rs index 9319e9bd7a3..40fa8ed8ffd 100644 --- a/app/src/workspaces/user_workspaces_tests.rs +++ b/app/src/workspaces/user_workspaces_tests.rs @@ -1480,20 +1480,44 @@ fn test_joining_team_moves_objects() { }) } +/// Resolves attribution the way the settings widget does: from the window `view` renders in. +fn attribution_setting_for_view( + user_workspaces: &UserWorkspaces, + view: &WeakViewHandle, + ctx: &AppContext, +) -> AdminEnablementSetting { + let scope = user_workspaces + .team_context(view, ctx) + .expect("a registered window should resolve a team context"); + user_workspaces.agent_attribution_setting_for_scope(&scope) +} + +/// Resolves the default host the way the cloud-mode host selector does. +fn default_host_slug_for_view<'a>( + user_workspaces: &'a UserWorkspaces, + view: &WeakViewHandle, + ctx: &AppContext, +) -> Option<&'a str> { + let scope = user_workspaces + .team_context(view, ctx) + .expect("a registered window should resolve a team context"); + user_workspaces.default_host_slug_for_scope(&scope) +} + #[test] fn test_agent_attribution_default_with_no_workspace() { App::test((), |mut app| async move { - initialize_app( - &mut app, - CachedResources { workspaces: vec![] }, - Arc::new(MockTeamClient::new()), - Arc::new(MockWorkspaceClient::new()), - ); + initialize_window_team_test_app(&mut app, vec![]); + + let (window_id, view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_id, None, ctx); + }); + let weak_view = view.downgrade(); app.read(|ctx| { - let setting = UserWorkspaces::as_ref(ctx).get_agent_attribution_setting(); assert_eq!( - setting, + attribution_setting_for_view(UserWorkspaces::as_ref(ctx), &weak_view, ctx), AdminEnablementSetting::RespectUserSetting, "attribution should default to RespectUserSetting when there is no workspace" ); @@ -1501,87 +1525,360 @@ fn test_agent_attribution_default_with_no_workspace() { }) } +/// The toggle is locked per window, so two windows whose teams disagree must each see their +/// own team's policy. #[test] -fn test_agent_attribution_forced_on_by_team() { - let team = team_for_test(); - let mut workspace = workspace_for_test(&team); - workspace.settings.enable_warp_attribution = AdminEnablementSetting::Enable; +fn test_agent_attribution_resolves_each_windows_own_team() { + let (mut team_a, mut team_b) = two_teams(); + team_a.settings.enable_warp_attribution = AdminEnablementSetting::Enable; + team_b.settings.enable_warp_attribution = AdminEnablementSetting::Disable; + let mut workspace = workspace_for_test(&team_a); + workspace.teams.push(team_b.clone()); App::test((), |mut app| async move { - initialize_app( - &mut app, - CachedResources { - workspaces: vec![workspace], - }, - Arc::new(MockTeamClient::new()), - Arc::new(MockWorkspaceClient::new()), - ); + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_a, view_a) = create_test_window(&mut app); + let (window_b, view_b) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_a, team_a.uid, ctx); + user_workspaces.set_team_for_window(window_b, team_b.uid, ctx); + }); + let (weak_a, weak_b) = (view_a.downgrade(), view_b.downgrade()); app.read(|ctx| { - let setting = UserWorkspaces::as_ref(ctx).get_agent_attribution_setting(); + let user_workspaces = UserWorkspaces::as_ref(ctx); assert_eq!( - setting, + attribution_setting_for_view(user_workspaces, &weak_a, ctx), AdminEnablementSetting::Enable, - "attribution should be Enable when forced on by the team" + "the window on team A should see team A's forced-on attribution" + ); + assert_eq!( + attribution_setting_for_view(user_workspaces, &weak_b, ctx), + AdminEnablementSetting::Disable, + "the window on team B should see team B's forced-off attribution" ); }); }) } +/// Attribution is live policy rather than a recorded fact, so a window that moves to another +/// team must report that team's policy on the next read. A surface that cached the value when +/// it opened would keep showing the old team's lock. #[test] -fn test_agent_attribution_forced_off_by_team() { +fn test_agent_attribution_follows_a_window_onto_its_new_team() { + let (mut team_a, mut team_b) = two_teams(); + team_a.settings.enable_warp_attribution = AdminEnablementSetting::Disable; + team_b.settings.enable_warp_attribution = AdminEnablementSetting::Enable; + let mut workspace = workspace_for_test(&team_a); + workspace.teams.push(team_b.clone()); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_id, team_a.uid, ctx); + }); + let weak_view = view.downgrade(); + + app.read(|ctx| { + assert_eq!( + attribution_setting_for_view(UserWorkspaces::as_ref(ctx), &weak_view, ctx), + AdminEnablementSetting::Disable + ); + }); + + // Reconciling away from a team that left the workspace is the only way a window + // changes team today. + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.update_workspaces(vec![workspace_for_test(&team_b)], ctx); + }); + + app.read(|ctx| { + assert_eq!( + attribution_setting_for_view(UserWorkspaces::as_ref(ctx), &weak_view, ctx), + AdminEnablementSetting::Enable, + "the window should report its new team's attribution policy" + ); + }); + }) +} + +/// A window with no team is not on a team, so it must not inherit the policy of some other +/// team the user happens to belong to. +#[test] +fn test_agent_attribution_does_not_substitute_another_team_for_a_teamless_window() { + let mut team = team_for_test(); + team.settings.enable_warp_attribution = AdminEnablementSetting::Enable; + let mut workspace = workspace_for_test(&team); + workspace.settings.enable_warp_attribution = AdminEnablementSetting::Disable; + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_id, None, ctx); + }); + let weak_view = view.downgrade(); + + app.read(|ctx| { + assert_eq!( + attribution_setting_for_view(UserWorkspaces::as_ref(ctx), &weak_view, ctx), + AdminEnablementSetting::RespectUserSetting, + "neither the other team's policy nor the workspace's should stand in for a \ + window that is not on a team" + ); + }); + }) +} + +/// Workspace settings are only trustworthy for a user with no teams at all; that user still +/// has to get the policy their server-side tier defaults produced. +#[test] +fn test_agent_attribution_reads_workspace_settings_for_a_teamless_user() { let team = team_for_test(); let mut workspace = workspace_for_test(&team); + workspace.teams.clear(); workspace.settings.enable_warp_attribution = AdminEnablementSetting::Disable; App::test((), |mut app| async move { - initialize_app( - &mut app, - CachedResources { - workspaces: vec![workspace], - }, - Arc::new(MockTeamClient::new()), - Arc::new(MockWorkspaceClient::new()), + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_id, None, ctx); + }); + let weak_view = view.downgrade(); + + app.read(|ctx| { + assert_eq!( + attribution_setting_for_view(UserWorkspaces::as_ref(ctx), &weak_view, ctx), + AdminEnablementSetting::Disable + ); + }); + }) +} + +#[test] +fn test_default_host_slug_resolves_each_windows_own_team() { + let (mut team_a, mut team_b) = two_teams(); + team_a.settings.default_host_slug = Some("host-a".to_string()); + team_b.settings.default_host_slug = Some("host-b".to_string()); + let mut workspace = workspace_for_test(&team_a); + workspace.teams.push(team_b.clone()); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_a, view_a) = create_test_window(&mut app); + let (window_b, view_b) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_a, team_a.uid, ctx); + user_workspaces.set_team_for_window(window_b, team_b.uid, ctx); + }); + let (weak_a, weak_b) = (view_a.downgrade(), view_b.downgrade()); + + app.read(|ctx| { + let user_workspaces = UserWorkspaces::as_ref(ctx); + assert_eq!( + default_host_slug_for_view(user_workspaces, &weak_a, ctx), + Some("host-a") + ); + assert_eq!( + default_host_slug_for_view(user_workspaces, &weak_b, ctx), + Some("host-b") + ); + }); + }) +} + +/// The cloud-mode host selector resolves through its `ViewContext`'s window rather than a +/// handle, because its first read runs while the view is still being constructed and a view is +/// not in `view_to_window` until that finishes. Cover that shape too, not just the handle one. +#[test] +fn test_default_host_slug_resolves_from_a_view_contexts_own_window() { + let (mut team_a, mut team_b) = two_teams(); + team_a.settings.default_host_slug = Some("host-a".to_string()); + team_b.settings.default_host_slug = Some("host-b".to_string()); + let mut workspace = workspace_for_test(&team_a); + workspace.teams.push(team_b.clone()); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_a, view_a) = create_test_window(&mut app); + let (window_b, view_b) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_a, team_a.uid, ctx); + user_workspaces.set_team_for_window(window_b, team_b.uid, ctx); + }); + + let host_for_window = |app: &mut App, view: &ViewHandle| { + view.update(app, |_, ctx| { + let user_workspaces = UserWorkspaces::as_ref(ctx); + let scope = user_workspaces.team_context_for_operation(ctx); + user_workspaces + .default_host_slug_for_scope(&scope) + .map(str::to_string) + }) + }; + + assert_eq!( + host_for_window(&mut app, &view_a).as_deref(), + Some("host-a") ); + assert_eq!( + host_for_window(&mut app, &view_b).as_deref(), + Some("host-b") + ); + }) +} + +/// A window moved onto a team with a different self-hosted default has to pick that up; the +/// host selector re-resolves rather than keeping the host the window opened with. +#[test] +fn test_default_host_slug_follows_a_window_onto_its_new_team() { + let (mut team_a, mut team_b) = two_teams(); + team_a.settings.default_host_slug = Some("host-a".to_string()); + team_b.settings.default_host_slug = Some("host-b".to_string()); + let mut workspace = workspace_for_test(&team_a); + workspace.teams.push(team_b.clone()); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_id, team_a.uid, ctx); + }); + let weak_view = view.downgrade(); app.read(|ctx| { - let setting = UserWorkspaces::as_ref(ctx).get_agent_attribution_setting(); assert_eq!( - setting, - AdminEnablementSetting::Disable, - "attribution should be Disable when forced off by the team" + default_host_slug_for_view(UserWorkspaces::as_ref(ctx), &weak_view, ctx), + Some("host-a") + ); + }); + + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.update_workspaces(vec![workspace_for_test(&team_b)], ctx); + }); + + app.read(|ctx| { + assert_eq!( + default_host_slug_for_view(UserWorkspaces::as_ref(ctx), &weak_view, ctx), + Some("host-b"), + "the window should default to its new team's self-hosted host" ); }); }) } #[test] -fn test_agent_attribution_respects_user_setting() { +fn test_default_host_slug_does_not_substitute_another_team_for_a_teamless_window() { + let mut team = team_for_test(); + team.settings.default_host_slug = Some("team-host".to_string()); + let mut workspace = workspace_for_test(&team); + workspace.settings.default_host_slug = Some("workspace-host".to_string()); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_id, None, ctx); + }); + let weak_view = view.downgrade(); + + app.read(|ctx| { + assert_eq!( + default_host_slug_for_view(UserWorkspaces::as_ref(ctx), &weak_view, ctx), + None, + "a window that is not on a team has no team host, and must not borrow one" + ); + }); + }) +} + +#[test] +fn test_default_host_slug_reads_workspace_settings_for_a_teamless_user() { let team = team_for_test(); let mut workspace = workspace_for_test(&team); - workspace.settings.enable_warp_attribution = AdminEnablementSetting::RespectUserSetting; + workspace.teams.clear(); + workspace.settings.default_host_slug = Some("workspace-host".to_string()); App::test((), |mut app| async move { - initialize_app( - &mut app, - CachedResources { - workspaces: vec![workspace], - }, - Arc::new(MockTeamClient::new()), - Arc::new(MockWorkspaceClient::new()), - ); + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.register_window(window_id, None, ctx); + }); + let weak_view = view.downgrade(); app.read(|ctx| { - let setting = UserWorkspaces::as_ref(ctx).get_agent_attribution_setting(); assert_eq!( - setting, - AdminEnablementSetting::RespectUserSetting, - "attribution should be RespectUserSetting when the team defers to user preference" + default_host_slug_for_view(UserWorkspaces::as_ref(ctx), &weak_view, ctx), + Some("workspace-host") ); }); }) } +/// The windowless `/host` gate asks whether a default host exists anywhere, and one team +/// configuring one is enough to answer yes. +#[test] +fn test_any_team_has_default_host_slug_when_one_team_configures_one() { + let (team_a, mut team_b) = two_teams(); + team_b.settings.default_host_slug = Some("host-b".to_string()); + let mut workspace = workspace_for_test(&team_a); + workspace.teams.push(team_b); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + app.read(|ctx| { + assert!(UserWorkspaces::as_ref(ctx).any_team_has_default_host_slug()); + }); + }) +} + +/// Once the user is on a team, workspace settings are one arbitrary team's data, so they must +/// not be able to answer the availability question on the teams' behalf. +#[test] +fn test_any_team_has_default_host_slug_ignores_workspace_settings_when_teams_exist() { + let (team_a, team_b) = two_teams(); + let mut workspace = workspace_for_test(&team_a); + workspace.teams.push(team_b); + workspace.settings.default_host_slug = Some("workspace-host".to_string()); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + app.read(|ctx| { + assert!(!UserWorkspaces::as_ref(ctx).any_team_has_default_host_slug()); + }); + }) +} + +#[test] +fn test_any_team_has_default_host_slug_reads_workspace_settings_for_a_teamless_user() { + let team = team_for_test(); + let mut workspace = workspace_for_test(&team); + workspace.teams.clear(); + workspace.settings.default_host_slug = Some("workspace-host".to_string()); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + app.read(|ctx| { + assert!(UserWorkspaces::as_ref(ctx).any_team_has_default_host_slug()); + }); + }) +} + #[test] fn test_team_switcher_hidden_with_zero_teams() { // When the user is in no workspace / no teams, `can_switch_teams` must return From 721d9d76645d4d533668268c5213b62d13f292cb Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:09:06 +0000 Subject: [PATCH 2/5] Address review: clear a stale default host, repaint on window team change, cover the construction-time constraint - The host-selector resync applied `Some` and swallowed `None`, so a window moving to a team that configures no default host kept the previous team's slug in both the selector and the run config. `clear_default_host` mirrors `set_default_host`, and the view model's host is now cleared too. - The Warp Agent settings page only repainted on `TeamsChanged`; `window_team_uids` is not `Tracked`, so a window team change would not repaint the attribution toggle once a team switcher lands. - Added a test that resolves a scope inside a view's own build closure, asserting the `ViewContext` shape works there and a self-handle resolves nothing. Every other test resolves post-construction, where the difference is invisible. --- app/src/settings_view/warp_agent_page.rs | 8 +- app/src/terminal/input.rs | 22 ++--- .../view/ambient_agent/host_selector.rs | 25 ++++++ app/src/workspaces/user_workspaces.rs | 11 ++- app/src/workspaces/user_workspaces_tests.rs | 80 +++++++++++++++++++ 5 files changed, 130 insertions(+), 16 deletions(-) diff --git a/app/src/settings_view/warp_agent_page.rs b/app/src/settings_view/warp_agent_page.rs index ea7aa2f9517..d69fdfcf9a4 100644 --- a/app/src/settings_view/warp_agent_page.rs +++ b/app/src/settings_view/warp_agent_page.rs @@ -622,7 +622,13 @@ impl WarpAgentPageView { let workspace = UserWorkspaces::handle(ctx); ctx.subscribe_to_model(&workspace, |me, _workspace, event, ctx| { - if let UserWorkspacesEvent::TeamsChanged = event { + // A window moving between teams changes what this page renders (the agent + // attribution toggle resolves its policy from the window's team), and + // `window_team_uids` is not `Tracked`, so autotracking will not repaint it. + if matches!( + event, + UserWorkspacesEvent::TeamsChanged | UserWorkspacesEvent::WindowTeamChanged { .. } + ) { me.sync_custom_endpoint_buttons(ctx); ctx.notify(); } diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index 900431f35c9..cc7cba320c5 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -2412,17 +2412,21 @@ impl Input { if !affects_this_window { return; } + // `None` has to be applied, not skipped: it means the window's team configures no + // self-hosted default, and leaving the previous value in place would keep the + // selector and the run config pointed at another team's worker. let effective_host = effective_default_host(ctx); - if let Some(slug) = &effective_host { - view_for_ws.update(ctx, |selector, ctx| { - selector.set_default_host(slug.clone(), ctx); - }); - } - if let Some(slug) = effective_host { - vm_for_ws.update(ctx, |model, _ctx| { - model.set_worker_host(Some(slug)); - }); + match effective_host.clone() { + Some(slug) => view_for_ws.update(ctx, |selector, ctx| { + selector.set_default_host(slug, ctx); + }), + None => view_for_ws.update(ctx, |selector, ctx| { + selector.clear_default_host(ctx); + }), } + vm_for_ws.update(ctx, |model, _ctx| { + model.set_worker_host(effective_host); + }); }); view } diff --git a/app/src/terminal/view/ambient_agent/host_selector.rs b/app/src/terminal/view/ambient_agent/host_selector.rs index f2da82a2be4..2fcda6c2db2 100644 --- a/app/src/terminal/view/ambient_agent/host_selector.rs +++ b/app/src/terminal/view/ambient_agent/host_selector.rs @@ -208,6 +208,31 @@ impl HostSelector { self.refresh_menu(ctx); } + /// Drops the default host, e.g. because the window moved to a team that configures none. + /// + /// The inverse of [`Self::set_default_host`] and it defers to a saved user selection the + /// same way. Without it, a selection that came only from the previous team's default would + /// survive the move and keep pointing at that team's self-hosted worker. + pub fn clear_default_host(&mut self, ctx: &mut ViewContext) { + if self.default_host.is_none() { + return; + } + self.default_host = None; + + let has_saved_selection = CloudAgentSettings::as_ref(ctx) + .last_selected_host + .value() + .is_some(); + if !has_saved_selection { + self.selected = Host::Warp; + let label = self.selected.display_name().to_string(); + self.button.update(ctx, |button, ctx| { + button.set_label(label, ctx); + }); + } + self.refresh_menu(ctx); + } + /// Programmatically opens the host selector popover. No-op if already open. pub fn open_menu(&mut self, ctx: &mut ViewContext) { self.set_menu_visibility(true, ctx); diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index 19692bdeffd..0051546ab18 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -216,8 +216,9 @@ impl TeamScope for TeamContextForOperation { #[cfg(test)] impl TeamContextForOperation { - // Nothing constructs a test context yet; remove this `#[allow(dead_code)]` once a Group 1 - // migration PR has a real call site. + // Currently unused: tests that need an operation scope mint a real one from a test window + // through `team_context_for_operation`, which exercises the production path as well. Kept + // for a test that needs a scope with no window to mint it from. #[allow(dead_code)] pub(crate) fn new_for_test(team_uid: ServerId) -> Self { Self { @@ -2036,10 +2037,8 @@ impl UserWorkspaces { /// /// **Not a team-neutral read**, despite reading workspace settings: see /// [`Self::teamless_workspace_settings`] for why. Sole remaining caller is - /// `ai::orchestration::resolve_default_host_slug`, which feeds the plan card, the - /// confirmation card, the TUI orchestration block and the handoff pipeline. That chain - /// still needs both a windowless accessor the TUI can reach and a pinned scope for the - /// handoff's chosen destination, so it moves as one follow-up rather than piecemeal. + /// `ai::orchestration::resolve_default_host_slug`, whose consumers have to migrate as one + /// unit and are blocked on decisions this getter cannot make. /// Do not add callers: windowed code uses [`Self::default_host_slug_for_scope`], and a /// windowless availability check uses [`Self::any_team_has_default_host_slug`]. pub fn unscoped_default_host_slug(&self) -> Option<&str> { diff --git a/app/src/workspaces/user_workspaces_tests.rs b/app/src/workspaces/user_workspaces_tests.rs index 40fa8ed8ffd..85bb2b991e8 100644 --- a/app/src/workspaces/user_workspaces_tests.rs +++ b/app/src/workspaces/user_workspaces_tests.rs @@ -1056,6 +1056,86 @@ fn create_test_window(app: &mut App) -> (WindowId, ViewHandle, + team_uid_from_own_handle: Option, +} + +impl ConstructionTimeScopeProbe { + fn new(ctx: &mut ViewContext) -> Self { + let user_workspaces = UserWorkspaces::as_ref(ctx); + let scope = user_workspaces.team_context_for_operation(ctx); + Self { + host_slug_from_view_context: user_workspaces + .default_host_slug_for_scope(&scope) + .map(str::to_string), + team_uid_from_own_handle: user_workspaces + .team_context(&ctx.handle(), ctx) + .and_then(|context| context.team_uid()), + } + } +} + +impl Entity for ConstructionTimeScopeProbe { + type Event = (); +} + +impl View for ConstructionTimeScopeProbe { + fn ui_name() -> &'static str { + "ConstructionTimeScopeProbe" + } + + fn render(&self, _: &AppContext) -> Box { + Empty::new().finish() + } +} + +impl TypedActionView for ConstructionTimeScopeProbe { + type Action = (); +} + +/// A view is not in `view_to_window` until its build closure returns, so during its own +/// construction it can resolve its team through its `ViewContext` but *not* through a handle to +/// itself. Both halves are asserted: the shape the host selector uses works, and the shape it +/// would be tempting to refactor to silently resolves nothing. Every other test here resolves +/// post-construction, where the difference is invisible. +#[test] +fn test_a_view_can_only_resolve_its_own_team_through_its_view_context_while_constructing() { + let mut team = team_for_test(); + team.settings.default_host_slug = Some("team-host".to_string()); + let workspace = workspace_for_test(&team); + + App::test((), |mut app| async move { + initialize_window_team_test_app(&mut app, vec![workspace]); + + let (window_id, view) = create_test_window(&mut app); + UserWorkspaces::handle(&app).update(&mut app, |user_workspaces, ctx| { + user_workspaces.set_team_for_window(window_id, team.uid, ctx); + }); + + let probe = view.update(&mut app, |_, ctx| { + ctx.add_typed_action_view(ConstructionTimeScopeProbe::new) + }); + + app.read(|ctx| { + let probe = probe.as_ref(ctx); + assert_eq!( + probe.host_slug_from_view_context.as_deref(), + Some("team-host"), + "a view under construction still resolves its window's team through its \ + ViewContext, whose window id is a plain field" + ); + assert_eq!( + probe.team_uid_from_own_handle, None, + "a handle to a view still being constructed resolves no window, so reading the \ + host through one would silently drop the team's configured host" + ); + }); + }) +} + fn two_teams() -> (Team, Team) { let team_a = team_for_test(); let mut team_b = team_for_test(); From 499f5960e63a6fb1bfdab66a30b94d2871e9df05 Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:45:19 +0000 Subject: [PATCH 3/5] Address review: resolve the host default through the borrowed TeamContext - `effective_default_host` minted `TeamContextForOperation`, whose whole point is that it can be carried into an operation and recorded. Nothing was carried here: it was borrowed on the next line and dropped, so the type made a false claim that this host was pinned to the team it was chosen under, contradicting the doc comment three lines above it. - It now uses the borrowed `TeamContext`, resolved from the host selector's own handle rather than the `Input`'s. A view is absent from `view_to_window` until its own construction finishes, but a child it builds along the way is already registered, so the selector's handle resolves at build time where the Input's does not. Reading through the live mapping also means the host follows the selector if it is ever moved between windows. - Retargeted the construction-time test at that asymmetry: a self-handle resolves nothing mid-construction, a child handle resolves the window's team. - Narrowed `any_team_has_default_host_slug` to `pub(crate)`; its only caller is a default trait-method body inside `warp`. - Kept one test on the owned scope, redocumented as coverage that the getter behaves identically for both `TeamScope` implementors. Leaves the teamless guard in place pending the batched follow-up. --- app/src/terminal/input.rs | 35 +++++++------ app/src/workspaces/user_workspaces.rs | 2 +- app/src/workspaces/user_workspaces_tests.rs | 55 ++++++++++++--------- 3 files changed, 52 insertions(+), 40 deletions(-) diff --git a/app/src/terminal/input.rs b/app/src/terminal/input.rs index cc7cba320c5..36d7a868d14 100644 --- a/app/src/terminal/input.rs +++ b/app/src/terminal/input.rs @@ -407,30 +407,34 @@ pub fn get_input_box_top_border_width() -> f32 { } } -/// The cloud-mode host `ctx`'s window should default to: the `WARP_CLOUD_MODE_DEFAULT_HOST` -/// developer override when set, otherwise the default host configured for that window's team. +/// The cloud-mode host `host_selector`'s window should default to: the +/// `WARP_CLOUD_MODE_DEFAULT_HOST` developer override when set, otherwise the default host +/// configured for that window's team. /// /// Re-run on every team change rather than captured once, so a window that moves to a team /// with a different self-hosted default picks that up instead of keeping the host it opened -/// with. The scope it mints is consumed here and never stored, so nothing can go stale -/// between the read and its use. +/// with. [`TeamContext`] is the scope that says so: it borrows and expires with the read, +/// where an owned scope would claim this host was pinned to the team it was chosen under. /// -/// Resolves through the `ViewContext`'s window rather than a [`WeakViewHandle`], which is the -/// one shape available at both call sites: the first read happens while `Input` is still being -/// constructed, and a view is not in `view_to_window` until construction finishes, so a handle -/// would resolve no window and silently drop the team's host. Cross-window tab drag still does -/// not re-resolve, because no signal for it exists yet (tracked on REV-2205). -fn effective_default_host(ctx: &ViewContext) -> Option { +/// Resolved from the host selector's own handle rather than the `Input`'s, because the first +/// read runs while `Input` is still being constructed and a view is absent from +/// `view_to_window` until its own construction finishes. The selector is a completed child by +/// then, so its handle resolves — and reading through the live mapping means the host follows +/// the selector if it is ever moved between windows. +fn effective_default_host( + host_selector: &WeakViewHandle, + app: &AppContext, +) -> Option { if let Some(slug) = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST") .ok() .filter(|slug| !slug.is_empty()) { return Some(slug); } - let workspaces = UserWorkspaces::as_ref(ctx); - let scope = workspaces.team_context_for_operation(ctx); + let workspaces = UserWorkspaces::as_ref(app); workspaces - .default_host_slug_for_scope(&scope) + .team_context(host_selector, app) + .and_then(|scope| workspaces.default_host_slug_for_scope(&scope)) .map(String::from) } @@ -2362,7 +2366,8 @@ impl Input { ) -> ViewHandle { let view = ctx .add_typed_action_view(|ctx| HostSelector::new(menu_positioning_provider.clone(), ctx)); - let effective_host = effective_default_host(ctx); + let weak_view = view.downgrade(); + let effective_host = effective_default_host(&weak_view, ctx); if let Some(slug) = &effective_host { view.update(ctx, |selector, ctx| { selector.set_default_host(slug.clone(), ctx); @@ -2415,7 +2420,7 @@ impl Input { // `None` has to be applied, not skipped: it means the window's team configures no // self-hosted default, and leaving the previous value in place would keep the // selector and the run config pointed at another team's worker. - let effective_host = effective_default_host(ctx); + let effective_host = effective_default_host(&weak_view, ctx); match effective_host.clone() { Some(slug) => view_for_ws.update(ctx, |selector, ctx| { selector.set_default_host(slug, ctx); diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index 0051546ab18..9e531ff91ce 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -2016,7 +2016,7 @@ impl UserWorkspaces { /// /// Falls back to workspace settings only when the user is on no team, mirroring /// [`Self::teams_allow_codebase_context`]'s empty-iterator guard. - pub fn any_team_has_default_host_slug(&self) -> bool { + pub(crate) fn any_team_has_default_host_slug(&self) -> bool { let mut team_slugs = self .workspaces .iter() diff --git a/app/src/workspaces/user_workspaces_tests.rs b/app/src/workspaces/user_workspaces_tests.rs index 85bb2b991e8..01cf1dc8ec5 100644 --- a/app/src/workspaces/user_workspaces_tests.rs +++ b/app/src/workspaces/user_workspaces_tests.rs @@ -1056,24 +1056,28 @@ fn create_test_window(app: &mut App) -> (WindowId, ViewHandle, team_uid_from_own_handle: Option, + host_slug_from_child_handle: Option, } impl ConstructionTimeScopeProbe { fn new(ctx: &mut ViewContext) -> Self { + // Mirrors `build_host_selector`: the child is created and finished before the parent is. + let child = ctx.add_typed_action_view(|_| TeamContextTestView); + let child_handle = child.downgrade(); let user_workspaces = UserWorkspaces::as_ref(ctx); - let scope = user_workspaces.team_context_for_operation(ctx); Self { - host_slug_from_view_context: user_workspaces - .default_host_slug_for_scope(&scope) - .map(str::to_string), team_uid_from_own_handle: user_workspaces .team_context(&ctx.handle(), ctx) .and_then(|context| context.team_uid()), + host_slug_from_child_handle: user_workspaces + .team_context(&child_handle, ctx) + .and_then(|context| user_workspaces.default_host_slug_for_scope(&context)) + .map(str::to_string), } } } @@ -1096,13 +1100,16 @@ impl TypedActionView for ConstructionTimeScopeProbe { type Action = (); } -/// A view is not in `view_to_window` until its build closure returns, so during its own -/// construction it can resolve its team through its `ViewContext` but *not* through a handle to -/// itself. Both halves are asserted: the shape the host selector uses works, and the shape it -/// would be tempting to refactor to silently resolves nothing. Every other test here resolves -/// post-construction, where the difference is invisible. +/// A view enters `view_to_window` only when its own build closure returns, but a child view it +/// builds on the way is complete and registered before that. So mid-construction a handle to +/// *itself* resolves nothing while a handle to its *child* resolves normally — which is why the +/// host selector reads through the selector's handle rather than the `Input`'s. +/// +/// Both halves are asserted. Every other test here resolves post-construction, where the +/// difference is invisible, so without this one a refactor to the parent's own handle would +/// silently drop every self-hosted team's default host and still pass the suite. #[test] -fn test_a_view_can_only_resolve_its_own_team_through_its_view_context_while_constructing() { +fn test_only_a_child_handle_resolves_a_team_during_a_views_own_construction() { let mut team = team_for_test(); team.settings.default_host_slug = Some("team-host".to_string()); let workspace = workspace_for_test(&team); @@ -1122,15 +1129,14 @@ fn test_a_view_can_only_resolve_its_own_team_through_its_view_context_while_cons app.read(|ctx| { let probe = probe.as_ref(ctx); assert_eq!( - probe.host_slug_from_view_context.as_deref(), - Some("team-host"), - "a view under construction still resolves its window's team through its \ - ViewContext, whose window id is a plain field" + probe.team_uid_from_own_handle, None, + "a handle to a view still being constructed resolves no window" ); assert_eq!( - probe.team_uid_from_own_handle, None, - "a handle to a view still being constructed resolves no window, so reading the \ - host through one would silently drop the team's configured host" + probe.host_slug_from_child_handle.as_deref(), + Some("team-host"), + "a handle to a child completed during that construction resolves the window's \ + team, which is what lets the host selector read its own team at build time" ); }); }) @@ -1774,11 +1780,12 @@ fn test_default_host_slug_resolves_each_windows_own_team() { }) } -/// The cloud-mode host selector resolves through its `ViewContext`'s window rather than a -/// handle, because its first read runs while the view is still being constructed and a view is -/// not in `view_to_window` until that finishes. Cover that shape too, not just the handle one. +/// The getter takes `&impl TeamScope`, so it has to behave identically for both implementors. +/// Every other test here passes the borrowed [`TeamContext`], which is what production uses; +/// this one passes the owned `TeamContextForOperation` so a future caller that legitimately +/// needs a pinned scope inherits the same per-window answer rather than a surprise. #[test] -fn test_default_host_slug_resolves_from_a_view_contexts_own_window() { +fn test_default_host_slug_is_identical_through_an_owned_scope() { let (mut team_a, mut team_b) = two_teams(); team_a.settings.default_host_slug = Some("host-a".to_string()); team_b.settings.default_host_slug = Some("host-b".to_string()); From 32b4a3232bb4d47ffb1fd26bae5819c2796279df Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:11:54 +0000 Subject: [PATCH 4/5] Restore dead_code allows on the owned scope, which now has no production caller Switching `effective_default_host` to the borrowed `TeamContext` removed the last production mint of `TeamContextForOperation`, so both the struct and `team_context_for_operation` became dead in the lib build and clippy failed on all four targets under -D warnings. Master carries these allows; my earlier rebase conflict resolution dropped them because at that point my code was the consumer that made them unnecessary. The comment now records why they are back, which is the useful signal: the pinning half of the contract has no production user yet. --- app/src/workspaces/user_workspaces.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index 9e531ff91ce..a40d2900d84 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -185,6 +185,11 @@ pub struct CreateTeamResponse { /// [`TeamScope`]'s contract. Code with no window at all (e.g. background GEAP token refresh) /// is not this type's job -- it needs its own accessor that reads across every one of the /// user's teams explicitly, in the shape of `UserWorkspaces::teams_allow_codebase_context`. +// No production code pins a scope yet: the reads migrated so far are all live policy, which +// resolves through the borrowed [`TeamContext`] instead. Only this module's tests construct +// one. Drop the `#[allow(dead_code)]` when a real caller needs a scope that outlives its mint +// point -- a chosen destination recorded against the team it was chosen under. +#[allow(dead_code)] pub(crate) struct TeamContextForOperation { team_uid: Option, } @@ -439,6 +444,8 @@ impl UserWorkspaces { /// [`TeamContextForOperation`]. This is the only way application code mints one. Always /// succeeds -- a window with no team selected still yields a scope, just one whose /// `team_uid()` is `None`; see [`TeamScope`]'s contract for what that means to a getter. + // Only tests mint one today; see [`TeamContextForOperation`]. + #[allow(dead_code)] pub(crate) fn team_context_for_operation( &self, ctx: &ViewContext, From 7251d43f02fba065e07ba91cbaacd74382a550da Mon Sep 17 00:00:00 2001 From: "warp-agent-staging[bot]" <240773466+warp-agent-staging[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:13:12 +0000 Subject: [PATCH 5/5] Name warp#15443 as the incoming caller in the dead_code comments --- app/src/workspaces/user_workspaces.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/workspaces/user_workspaces.rs b/app/src/workspaces/user_workspaces.rs index a40d2900d84..85c71e7b195 100644 --- a/app/src/workspaces/user_workspaces.rs +++ b/app/src/workspaces/user_workspaces.rs @@ -185,10 +185,11 @@ pub struct CreateTeamResponse { /// [`TeamScope`]'s contract. Code with no window at all (e.g. background GEAP token refresh) /// is not this type's job -- it needs its own accessor that reads across every one of the /// user's teams explicitly, in the shape of `UserWorkspaces::teams_allow_codebase_context`. -// No production code pins a scope yet: the reads migrated so far are all live policy, which -// resolves through the borrowed [`TeamContext`] instead. Only this module's tests construct -// one. Drop the `#[allow(dead_code)]` when a real caller needs a scope that outlives its mint -// point -- a chosen destination recorded against the team it was chosen under. +// No *production* code pins a scope yet, so only this module's tests construct one: every read +// migrated so far is live policy, which resolves through the borrowed [`TeamContext`] instead. +// warp#15443 is the incoming real caller -- it moves one by value into +// `apply_onboarding_settings` from `root_view.rs`, where the scope genuinely outlives its mint +// point. Drop the `#[allow(dead_code)]` when that lands. #[allow(dead_code)] pub(crate) struct TeamContextForOperation { team_uid: Option, @@ -444,7 +445,7 @@ impl UserWorkspaces { /// [`TeamContextForOperation`]. This is the only way application code mints one. Always /// succeeds -- a window with no team selected still yields a scope, just one whose /// `team_uid()` is `None`; see [`TeamScope`]'s contract for what that means to a getter. - // Only tests mint one today; see [`TeamContextForOperation`]. + // Only tests mint one until warp#15443 lands; see [`TeamContextForOperation`]. #[allow(dead_code)] pub(crate) fn team_context_for_operation( &self,