Skip to content

Commit c288976

Browse files
[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.
1 parent e2a0802 commit c288976

7 files changed

Lines changed: 520 additions & 101 deletions

File tree

app/src/ai/orchestration/providers.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,12 @@ pub fn first_filtered_model_id(harness_type: &str, ctx: &AppContext) -> Option<S
8787
/// Resolves the workspace-configured default host slug, honoring the
8888
/// `WARP_CLOUD_MODE_DEFAULT_HOST` env var override for developer
8989
/// testing. Mirrors the single-agent ambient flow.
90+
///
91+
/// Still reads ambient workspace settings rather than the window's team. Every consumer of
92+
/// this chain — the plan card, the confirmation card, the TUI orchestration block, and the
93+
/// handoff pipeline — has to move together, and two of them need decisions this function
94+
/// cannot make on its own: the TUI has no window to scope to, and the handoff bakes the host
95+
/// into a cloud run's config, which pins rather than resolves late.
9096
pub fn resolve_default_host_slug(ctx: &AppContext) -> Option<String> {
9197
if let Ok(slug) = std::env::var(DEFAULT_HOST_ENV_VAR) {
9298
let trimmed = slug.trim();
@@ -95,7 +101,7 @@ pub fn resolve_default_host_slug(ctx: &AppContext) -> Option<String> {
95101
}
96102
}
97103
UserWorkspaces::as_ref(ctx)
98-
.default_host_slug()
104+
.unscoped_default_host_slug()
99105
.map(str::to_string)
100106
.filter(|s| !s.trim().is_empty())
101107
}

app/src/settings/ai.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,8 +2069,8 @@ define_settings_group!(AISettings, settings: [
20692069

20702070
// Whether Oz should add attribution (co-author line) to commit messages and PRs.
20712071
// This is the user-level preference; it may be overridden by the team-level
2072-
// `enable_warp_attribution` AdminEnablementSetting (see
2073-
// `UserWorkspaces::get_agent_attribution_setting`).
2072+
// `enable_warp_attribution` AdminEnablementSetting of the window's team (see
2073+
// `UserWorkspaces::agent_attribution_setting_for_scope`).
20742074
agent_attribution_enabled: AgentAttributionEnabled {
20752075
type: bool,
20762076
default: true,

app/src/settings_view/warp_agent_page.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1994,7 +1994,7 @@ impl WarpAgentPageView {
19941994

19951995
categories.push(Category::new(
19961996
"Agent Attribution",
1997-
vec![Box::new(AgentAttributionWidget::default())],
1997+
vec![Box::new(AgentAttributionWidget::new(ctx))],
19981998
));
19991999

20002000
#[cfg_attr(not(feature = "local_fs"), allow(unused_mut))]
@@ -4169,9 +4169,18 @@ pub(crate) fn derive_agent_attribution_toggle_state(
41694169
}
41704170
}
41714171

4172-
#[derive(Default)]
41734172
struct AgentAttributionWidget {
41744173
toggle: SwitchStateHandle,
4174+
view_handle: WeakViewHandle<WarpAgentPageView>,
4175+
}
4176+
4177+
impl AgentAttributionWidget {
4178+
fn new(ctx: &ViewContext<WarpAgentPageView>) -> Self {
4179+
Self {
4180+
toggle: Default::default(),
4181+
view_handle: ctx.handle(),
4182+
}
4183+
}
41754184
}
41764185

41774186
impl SettingsWidget for AgentAttributionWidget {
@@ -4190,7 +4199,16 @@ impl SettingsWidget for AgentAttributionWidget {
41904199
let ai_settings = AISettings::as_ref(app);
41914200
let is_any_ai_enabled = ai_settings.is_any_ai_enabled(app);
41924201

4193-
let org_setting = UserWorkspaces::as_ref(app).get_agent_attribution_setting();
4202+
// Whether this toggle is locked is live policy, so it is resolved from the window
4203+
// being painted rather than captured when the page was built: moving the window to a
4204+
// team with a different attribution policy has to change the toggle on the next
4205+
// frame. A page rendered outside any window has no team to read, so the user keeps
4206+
// control of their own preference.
4207+
let workspaces = UserWorkspaces::as_ref(app);
4208+
let org_setting = workspaces
4209+
.team_context(&self.view_handle, app)
4210+
.map(|scope| workspaces.agent_attribution_setting_for_scope(&scope))
4211+
.unwrap_or_default();
41944212
let state = derive_agent_attribution_toggle_state(
41954213
&org_setting,
41964214
*ai_settings.agent_attribution_enabled,

app/src/terminal/input.rs

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,33 @@ pub fn get_input_box_top_border_width() -> f32 {
407407
}
408408
}
409409

410+
/// The cloud-mode host `ctx`'s window should default to: the `WARP_CLOUD_MODE_DEFAULT_HOST`
411+
/// developer override when set, otherwise the default host configured for that window's team.
412+
///
413+
/// Re-run on every team change rather than captured once, so a window that moves to a team
414+
/// with a different self-hosted default picks that up instead of keeping the host it opened
415+
/// with. The scope it mints is consumed here and never stored, so nothing can go stale
416+
/// between the read and its use.
417+
///
418+
/// Resolves through the `ViewContext`'s window rather than a [`WeakViewHandle`], which is the
419+
/// one shape available at both call sites: the first read happens while `Input` is still being
420+
/// constructed, and a view is not in `view_to_window` until construction finishes, so a handle
421+
/// would resolve no window and silently drop the team's host. Cross-window tab drag still does
422+
/// not re-resolve, because no signal for it exists yet (tracked on REV-2205).
423+
fn effective_default_host(ctx: &ViewContext<Input>) -> Option<String> {
424+
if let Some(slug) = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
425+
.ok()
426+
.filter(|slug| !slug.is_empty())
427+
{
428+
return Some(slug);
429+
}
430+
let workspaces = UserWorkspaces::as_ref(ctx);
431+
let scope = workspaces.team_context_for_operation(ctx);
432+
workspaces
433+
.default_host_slug_for_scope(&scope)
434+
.map(String::from)
435+
}
436+
410437
pub const COMPLETIONS_MENU_WIDTH: f32 = 330.;
411438
pub const OPEN_COMPLETIONS_KEYBINDING_NAME: &str = "input:open_completion_suggestions";
412439
pub const INPUT_A11Y_LABEL: &str = "Command Input.";
@@ -2335,15 +2362,7 @@ impl Input {
23352362
) -> ViewHandle<HostSelector> {
23362363
let view = ctx
23372364
.add_typed_action_view(|ctx| HostSelector::new(menu_positioning_provider.clone(), ctx));
2338-
// Env var takes priority over workspace setting for developer testing.
2339-
let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
2340-
.ok()
2341-
.filter(|s| !s.is_empty())
2342-
.or_else(|| {
2343-
UserWorkspaces::as_ref(ctx)
2344-
.default_host_slug()
2345-
.map(String::from)
2346-
});
2365+
let effective_host = effective_default_host(ctx);
23472366
if let Some(slug) = &effective_host {
23482367
view.update(ctx, |selector, ctx| {
23492368
selector.set_default_host(slug.clone(), ctx);
@@ -2376,22 +2395,24 @@ impl Input {
23762395
});
23772396
}
23782397
});
2379-
// Keep the host selector and view model in sync when workspace metadata refreshes (e.g.
2380-
// admin changes default_host_slug).
2398+
// Keep the host selector and view model in sync when the host this window should
2399+
// default to changes: because the admin edited the team's `default_host_slug`, or
2400+
// because the window moved to a team that configures a different one.
23812401
let view_for_ws = view.clone();
23822402
let vm_for_ws = view_model.clone();
23832403
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), move |_me, _, event, ctx| {
2384-
if !matches!(event, UserWorkspacesEvent::TeamsChanged) {
2404+
// Windows are independent, so a sibling window switching team must not retarget
2405+
// this one.
2406+
let affects_this_window = matches!(event, UserWorkspacesEvent::TeamsChanged)
2407+
|| matches!(
2408+
event,
2409+
UserWorkspacesEvent::WindowTeamChanged { window_id }
2410+
if *window_id == ctx.window_id()
2411+
);
2412+
if !affects_this_window {
23852413
return;
23862414
}
2387-
let effective_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
2388-
.ok()
2389-
.filter(|s| !s.is_empty())
2390-
.or_else(|| {
2391-
UserWorkspaces::as_ref(ctx)
2392-
.default_host_slug()
2393-
.map(String::from)
2394-
});
2415+
let effective_host = effective_default_host(ctx);
23952416
if let Some(slug) = &effective_host {
23962417
view_for_ws.update(ctx, |selector, ctx| {
23972418
selector.set_default_host(slug.clone(), ctx);

app/src/terminal/input/slash_commands/data_source/core.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,12 +420,15 @@ pub trait SlashCommandDataSource {
420420

421421
fn common_command_gates(&self, ctx: &AppContext) -> CommonCommandGates {
422422
let ai_settings = AISettings::as_ref(ctx);
423-
// Hide /host when no default host is configured (env var or workspace setting).
423+
// Hide /host when no default host is configured (env var or team setting). This data
424+
// source is shared with the TUI and runs on a `ModelContext`, so it has no window to
425+
// scope the read to; it asks the cross-team availability question instead, and the
426+
// command itself resolves the window's own host when it runs.
424427
let has_default_host = std::env::var("WARP_CLOUD_MODE_DEFAULT_HOST")
425428
.ok()
426429
.filter(|s| !s.is_empty())
427430
.is_some()
428-
|| UserWorkspaces::as_ref(ctx).default_host_slug().is_some();
431+
|| UserWorkspaces::as_ref(ctx).any_team_has_default_host_slug();
429432
CommonCommandGates {
430433
is_orchestration_enabled: ai_settings.is_orchestration_enabled(ctx),
431434
is_cloud_handoff_enabled: ai_settings.is_cloud_handoff_enabled(ctx),

app/src/workspaces/user_workspaces.rs

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ use crate::settings::{
3939
AISettings, AISettingsChangedEvent, CodeSettings, CodeSettingsChangedEvent, PrivacySettings,
4040
};
4141
#[cfg(test)]
42-
use crate::workspaces::workspace::{AIAutonomyPolicy, WorkspaceMember, WorkspaceSettings};
42+
use crate::workspaces::workspace::{AIAutonomyPolicy, WorkspaceMember};
4343
use crate::workspaces::workspace::{
4444
AiAutonomySettings, AiOverages, PurchaseAddOnCreditsPolicy, SandboxedAgentSettings,
45-
UsageBasedPricingSettings,
45+
UsageBasedPricingSettings, WorkspaceSettings,
4646
};
4747

4848
const STRIPE_SUBSCRIPTION_INTERVAL_PAGE_PREFIX: &str = "/upgrade";
@@ -185,10 +185,6 @@ pub struct CreateTeamResponse {
185185
/// [`TeamScope`]'s contract. Code with no window at all (e.g. background GEAP token refresh)
186186
/// is not this type's job -- it needs its own accessor that reads across every one of the
187187
/// user's teams explicitly, in the shape of `UserWorkspaces::teams_allow_codebase_context`.
188-
// Nothing constructs or consumes one outside this module's own tests yet; remove this
189-
// `#[allow(dead_code)]`, and widen visibility to `pub`, once a Group 1 migration PR has a real
190-
// call site.
191-
#[allow(dead_code)]
192188
pub(crate) struct TeamContextForOperation {
193189
team_uid: Option<ServerId>,
194190
}
@@ -208,9 +204,6 @@ pub(crate) struct TeamContextForOperation {
208204
/// workspace-level data. Code with no window at all must not construct a scope to route around
209205
/// this; it should read across every team explicitly, the way
210206
/// `UserWorkspaces::teams_allow_codebase_context` does.
211-
// Only tests call `team_uid()` today; remove this `#[allow(dead_code)]` once a Group 1
212-
// migration PR has a real getter generic over this trait.
213-
#[allow(dead_code)]
214207
pub(crate) trait TeamScope {
215208
fn team_uid(&self) -> Option<ServerId>;
216209
}
@@ -241,9 +234,6 @@ impl TeamContextForOperation {
241234
/// a [`TeamContextForOperation`]. A [`WeakViewHandle`] locates a window to read from; it is
242235
/// not evidence that the holder is running in that window, which is what minting operation
243236
/// scope requires.
244-
// Only tests construct one today; remove this once a Group 1 migration PR resolves one from a
245-
// real render.
246-
#[allow(dead_code)]
247237
pub(crate) struct TeamContext<'a> {
248238
team_uid: Option<&'a ServerId>,
249239
}
@@ -451,8 +441,6 @@ impl UserWorkspaces {
451441
/// [`TeamContextForOperation`]. This is the only way application code mints one. Always
452442
/// succeeds -- a window with no team selected still yields a scope, just one whose
453443
/// `team_uid()` is `None`; see [`TeamScope`]'s contract for what that means to a getter.
454-
// Only tests call this today; remove once a Group 1 migration PR has a real call site.
455-
#[allow(dead_code)]
456444
pub(crate) fn team_context_for_operation<T: Entity>(
457445
&self,
458446
ctx: &ViewContext<T>,
@@ -463,8 +451,6 @@ impl UserWorkspaces {
463451
}
464452

465453
/// Resolves `view`'s window team for one render. See [`TeamContext`].
466-
// Only tests call this today; remove once a Group 1 migration PR has a real call site.
467-
#[allow(dead_code)]
468454
pub(crate) fn team_context<'a, T: Entity>(
469455
&'a self,
470456
view: &WeakViewHandle<T>,
@@ -1974,19 +1960,107 @@ impl UserWorkspaces {
19741960
}
19751961
}
19761962

1977-
pub fn default_host_slug(&self) -> Option<&str> {
1963+
/// The current workspace's settings, but only when the user belongs to no team at all.
1964+
///
1965+
/// `WorkspaceSettings` is not team-neutral data. Whenever the user has any team,
1966+
/// `GetEffectiveWorkspaceSettingsForWorkspace` resolves one arbitrarily-chosen team
1967+
/// server-side and falls through to a literal `workspaceTeamIDs[0]`, so reading it as a
1968+
/// default hands back some other team's policy. It is trustworthy only for a genuinely
1969+
/// teamless user, whose settings the server computes from tier defaults. This applies the
1970+
/// same guard as [`Self::teams_allow_codebase_context`], which is the shape scoped getters
1971+
/// reuse for their no-team branch.
1972+
fn teamless_workspace_settings(&self) -> Option<&WorkspaceSettings> {
1973+
let is_on_a_team = self
1974+
.workspaces
1975+
.iter()
1976+
.any(|workspace| !workspace.teams.is_empty());
1977+
if is_on_a_team {
1978+
return None;
1979+
}
19781980
self.current_workspace()
1979-
.and_then(|workspace| workspace.settings.default_host_slug.as_deref())
1981+
.map(|workspace| &workspace.settings)
19801982
}
19811983

1982-
/// Returns the team-level agent attribution setting.
1984+
/// The default self-hosted worker host slug configured for `scope`'s team.
19831985
///
1984-
/// Use this to decide whether the user's attribution toggle should be locked
1985-
/// (`Enable`/`Disable`) or editable (`RespectUserSetting`).
1986-
pub fn get_agent_attribution_setting(&self) -> AdminEnablementSetting {
1986+
/// Returns `None` when that team configures none, and also when the scope has no team
1987+
/// while the user is on some other team: another team's host is not a substitute. See
1988+
/// [`TeamScope`].
1989+
pub(crate) fn default_host_slug_for_scope(&self, scope: &impl TeamScope) -> Option<&str> {
1990+
match scope.team_uid() {
1991+
Some(team_uid) => self
1992+
.team_from_uid(team_uid)
1993+
.and_then(|team| team.settings.default_host_slug.as_deref()),
1994+
None => self
1995+
.teamless_workspace_settings()
1996+
.and_then(|settings| settings.default_host_slug.as_deref()),
1997+
}
1998+
}
1999+
2000+
/// Whether *some* team the user belongs to configures a default self-hosted worker host.
2001+
///
2002+
/// This answers only the availability question a windowless surface can honestly ask: the
2003+
/// `/host` slash command is worth offering when a default host exists anywhere. It
2004+
/// deliberately does not choose *which* slug, because there is no defensible ordering over
2005+
/// host slugs the way [`AdminEnablementSetting`] has a most-restrictive direction — with
2006+
/// two teams configuring different hosts, any pick is arbitrary. Windowed callers must use
2007+
/// [`Self::default_host_slug_for_scope`] instead; picking a slug without a window is a
2008+
/// product decision that has not been made.
2009+
///
2010+
/// Falls back to workspace settings only when the user is on no team, mirroring
2011+
/// [`Self::teams_allow_codebase_context`]'s empty-iterator guard.
2012+
pub fn any_team_has_default_host_slug(&self) -> bool {
2013+
let mut team_slugs = self
2014+
.workspaces
2015+
.iter()
2016+
.flat_map(|workspace| workspace.teams.iter())
2017+
.map(|team| &team.settings.default_host_slug)
2018+
.peekable();
2019+
2020+
if team_slugs.peek().is_none() {
2021+
return self
2022+
.teamless_workspace_settings()
2023+
.is_some_and(|settings| settings.default_host_slug.is_some());
2024+
}
2025+
2026+
team_slugs.any(Option::is_some)
2027+
}
2028+
2029+
/// The default self-hosted worker host slug from the current workspace's settings.
2030+
///
2031+
/// **Not a team-neutral read**, despite reading workspace settings: see
2032+
/// [`Self::teamless_workspace_settings`] for why. Sole remaining caller is
2033+
/// `ai::orchestration::resolve_default_host_slug`, which feeds the plan card, the
2034+
/// confirmation card, the TUI orchestration block and the handoff pipeline. That chain
2035+
/// still needs both a windowless accessor the TUI can reach and a pinned scope for the
2036+
/// handoff's chosen destination, so it moves as one follow-up rather than piecemeal.
2037+
/// Do not add callers: windowed code uses [`Self::default_host_slug_for_scope`], and a
2038+
/// windowless availability check uses [`Self::any_team_has_default_host_slug`].
2039+
pub fn unscoped_default_host_slug(&self) -> Option<&str> {
19872040
self.current_workspace()
1988-
.map(|workspace| workspace.settings.enable_warp_attribution.clone())
1989-
.unwrap_or_default()
2041+
.and_then(|workspace| workspace.settings.default_host_slug.as_deref())
2042+
}
2043+
2044+
/// The agent attribution policy for `scope`'s team: `Enable` or `Disable` lock the user's
2045+
/// attribution toggle, `RespectUserSetting` leaves it editable.
2046+
///
2047+
/// This is live UI state rather than a recorded fact, so resolve it from the rendering
2048+
/// window's [`TeamContext`] on each frame; a value captured when a surface opened goes
2049+
/// stale the moment that window switches team.
2050+
pub(crate) fn agent_attribution_setting_for_scope(
2051+
&self,
2052+
scope: &impl TeamScope,
2053+
) -> AdminEnablementSetting {
2054+
match scope.team_uid() {
2055+
Some(team_uid) => self
2056+
.team_from_uid(team_uid)
2057+
.map(|team| team.settings.enable_warp_attribution.clone())
2058+
.unwrap_or_default(),
2059+
None => self
2060+
.teamless_workspace_settings()
2061+
.map(|settings| settings.enable_warp_attribution.clone())
2062+
.unwrap_or_default(),
2063+
}
19902064
}
19912065

19922066
pub fn teams_allow_codebase_context(&self) -> AdminEnablementSetting {

0 commit comments

Comments
 (0)