Add client-side user setting for agent commit attribution (#9323)

This commit is contained in:
lucieleblanc
2026-04-28 18:33:15 -04:00
committed by GitHub
parent 929944198c
commit 389716a905
11 changed files with 582 additions and 27 deletions
+14
View File
@@ -1446,6 +1446,20 @@ define_settings_group!(AISettings, settings: [
sync_to_cloud: SyncToCloud::Never, sync_to_cloud: SyncToCloud::Never,
private: true, private: true,
} }
// 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`).
agent_attribution_enabled: AgentAttributionEnabled {
type: bool,
default: true,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
private: false,
toml_path: "agents.oz.other.agent_attribution_enabled",
description: "Whether Oz adds an attribution co-author line to commit messages and pull requests it creates.",
}
]); ]);
impl AISettings { impl AISettings {
+189 -25
View File
@@ -121,7 +121,7 @@ use crate::server::telemetry::{
}; };
use crate::ui_components::icons::Icon; use crate::ui_components::icons::Icon;
use crate::view_components::dropdown::DropdownAction; use crate::view_components::dropdown::DropdownAction;
use crate::workspaces::workspace::CustomerType; use crate::workspaces::workspace::{AdminEnablementSetting, CustomerType};
use crate::{ use crate::{
appearance::Appearance, appearance::Appearance,
editor::Event as EditorEvent, editor::Event as EditorEvent,
@@ -148,8 +148,7 @@ const AI_SETTINGS_DROPDOWN_WIDTH: f32 = 250.;
const AI_SETTINGS_DROPDOWN_MAX_HEIGHT: f32 = 250.; const AI_SETTINGS_DROPDOWN_MAX_HEIGHT: f32 = 250.;
const NEXT_COMMAND_DESCRIPTION: &str = "Let AI suggest the next command to run based on your command history, outputs, and common workflows."; const NEXT_COMMAND_DESCRIPTION: &str = "Let AI suggest the next command to run based on your command history, outputs, and common workflows.";
const PROMPT_SUGGESTIONS_DESCRIPTION: &str = "Let AI suggest natural language prompts, as inline banners in the input, based on recent commands and their outputs."; const PROMPT_SUGGESTIONS_DESCRIPTION: &str = "Let AI suggest natural language prompts, as inline banners in the input, based on recent commands and their outputs.";
const SUGGESTED_CODE_BANNERS_DESCRIPTION: &str = const SUGGESTED_CODE_BANNERS_DESCRIPTION: &str = "Let AI suggest code diffs and queries as inline banners in the blocklist, based on recent commands and their outputs.";
"Let AI suggest code diffs and queries as inline banners in the blocklist, based on recent commands and their outputs.";
const NATURAL_LANGUAGE_AUTOSUGGESTIONS: &str = const NATURAL_LANGUAGE_AUTOSUGGESTIONS: &str =
"Let AI suggest natural language autosuggestions, based on recent commands and their outputs."; "Let AI suggest natural language autosuggestions, based on recent commands and their outputs.";
const SHARED_BLOCK_TITLE_GENERATION_DESCRIPTION: &str = const SHARED_BLOCK_TITLE_GENERATION_DESCRIPTION: &str =
@@ -1470,6 +1469,7 @@ impl AISettingsPageView {
widgets.push(Box::new(CLIAgentWidget::default())); widgets.push(Box::new(CLIAgentWidget::default()));
widgets.push(Box::new(ApiKeysWidget::new(ctx))); widgets.push(Box::new(ApiKeysWidget::new(ctx)));
widgets.push(Box::new(AwsBedrockWidget::new(ctx))); widgets.push(Box::new(AwsBedrockWidget::new(ctx)));
widgets.push(Box::new(AgentAttributionWidget::default()));
widgets.push(Box::new(OtherAIWidget::default())); widgets.push(Box::new(OtherAIWidget::default()));
if FeatureFlag::AgentModeComputerUse.is_enabled() { if FeatureFlag::AgentModeComputerUse.is_enabled() {
widgets.push(Box::new(CloudAgentComputerUseWidget::default())); widgets.push(Box::new(CloudAgentComputerUseWidget::default()));
@@ -1509,6 +1509,7 @@ impl AISettingsPageView {
} }
widgets.push(Box::new(ApiKeysWidget::new(ctx))); widgets.push(Box::new(ApiKeysWidget::new(ctx)));
widgets.push(Box::new(AwsBedrockWidget::new(ctx))); widgets.push(Box::new(AwsBedrockWidget::new(ctx)));
widgets.push(Box::new(AgentAttributionWidget::default()));
widgets.push(Box::new(OtherAIWidget::default())); widgets.push(Box::new(OtherAIWidget::default()));
if FeatureFlag::AgentModeComputerUse.is_enabled() { if FeatureFlag::AgentModeComputerUse.is_enabled() {
widgets.push(Box::new(CloudAgentComputerUseWidget::default())); widgets.push(Box::new(CloudAgentComputerUseWidget::default()));
@@ -2112,6 +2113,7 @@ pub enum AISettingsPageAction {
ToggleCloudAgentComputerUse, ToggleCloudAgentComputerUse,
ToggleFileBasedMcp, ToggleFileBasedMcp,
ToggleIncludeAgentCommandsInHistory, ToggleIncludeAgentCommandsInHistory,
ToggleAgentAttribution,
#[cfg(feature = "local_fs")] #[cfg(feature = "local_fs")]
SetConversationLayout(crate::util::file::external_editor::settings::OpenConversationPreference), SetConversationLayout(crate::util::file::external_editor::settings::OpenConversationPreference),
ToggleOrchestration, ToggleOrchestration,
@@ -2269,7 +2271,9 @@ impl TypedActionView for AISettingsPageView {
); );
} }
Err(e) => { Err(e) => {
log::warn!("Failed to set value for Natural Language Autosuggestions setting: {e:?}"); log::warn!(
"Failed to set value for Natural Language Autosuggestions setting: {e:?}"
);
} }
} }
ctx.notify(); ctx.notify();
@@ -2825,6 +2829,17 @@ impl TypedActionView for AISettingsPageView {
}); });
ctx.notify(); ctx.notify();
} }
AISettingsPageAction::ToggleAgentAttribution => {
// The updated value syncs to warp-server automatically via
// `CloudPreferencesSyncer` as a `JsonPreference` GSO keyed
// `Global_AgentAttributionEnabled`; no bespoke server call needed.
AISettings::handle(ctx).update(ctx, |settings, ctx| {
report_if_error!(settings
.agent_attribution_enabled
.toggle_and_save_value(ctx));
});
ctx.notify();
}
} }
} }
} }
@@ -4314,10 +4329,13 @@ impl AgentsWidget {
render_dropdown_item( render_dropdown_item(
appearance, appearance,
"Base model", "Base model",
Some("This model serves as the primary engine behind the Warp Agent. It powers most interactions and invokes other models for tasks like planning or code generation when necessary. Warp may automatically switch to alternate models based on model availability or for auxiliary tasks such as conversation summarization."), Some(
"This model serves as the primary engine behind the Warp Agent. It powers most interactions and invokes other models for tasks like planning or code generation when necessary. Warp may automatically switch to alternate models based on model availability or for auxiliary tasks such as conversation summarization.",
),
Some(show_in_prompt_checkbox), Some(show_in_prompt_checkbox),
LocalOnlyIconState::Hidden, LocalOnlyIconState::Hidden,
(!ai_settings.is_any_ai_enabled(app)).then(|| appearance.theme().disabled_ui_text_color()), (!ai_settings.is_any_ai_enabled(app))
.then(|| appearance.theme().disabled_ui_text_color()),
&view.base_model_dropdown, &view.base_model_dropdown,
) )
} }
@@ -4343,7 +4361,7 @@ impl AgentsWidget {
let codebase_context_description = vec![ let codebase_context_description = vec![
FormattedTextFragment::plain_text( FormattedTextFragment::plain_text(
"Allow the Warp Agent to generate an outline of your codebase that can be used for context. No code is ever stored on our servers. " "Allow the Warp Agent to generate an outline of your codebase that can be used for context. No code is ever stored on our servers. ",
), ),
FormattedTextFragment::hyperlink( FormattedTextFragment::hyperlink(
"Learn more", "Learn more",
@@ -4415,10 +4433,18 @@ impl AgentsWidget {
let subtext = { let subtext = {
let subtext_fragments = vec![ let subtext_fragments = vec![
FormattedTextFragment::plain_text("You haven't added any MCP servers yet. Once you do, you'll be able to control how much autonomy the Warp Agent has when interacting with them. "), FormattedTextFragment::plain_text(
FormattedTextFragment::hyperlink_action("Add a server", AISettingsPageAction::OpenMCPServerCollection), "You haven't added any MCP servers yet. Once you do, you'll be able to control how much autonomy the Warp Agent has when interacting with them. ",
),
FormattedTextFragment::hyperlink_action(
"Add a server",
AISettingsPageAction::OpenMCPServerCollection,
),
FormattedTextFragment::plain_text(" or "), FormattedTextFragment::plain_text(" or "),
FormattedTextFragment::hyperlink("learn more about MCPs.", "https://docs.warp.dev/agent-platform/capabilities/mcp"), FormattedTextFragment::hyperlink(
"learn more about MCPs.",
"https://docs.warp.dev/agent-platform/capabilities/mcp",
),
]; ];
Container::new( Container::new(
@@ -4764,11 +4790,16 @@ impl AIInputWidget {
Vec<FormattedTextFragment>, Vec<FormattedTextFragment>,
> = LazyLock::new(|| { > = LazyLock::new(|| {
vec![ vec![
FormattedTextFragment::plain_text( FormattedTextFragment::plain_text(
"Enabling natural language detection will detect when natural language is written in the terminal input, and then automatically switch to Agent Mode for AI queries." "Enabling natural language detection will detect when natural language is written in the terminal input, and then automatically switch to Agent Mode for AI queries.",
), ),
FormattedTextFragment::plain_text(" Encountered an incorrect input detection? "), FormattedTextFragment::plain_text(
FormattedTextFragment::hyperlink("Let us know", "https://warpdotdev.typeform.com/to/offrTIpq"), " Encountered an incorrect input detection? ",
),
FormattedTextFragment::hyperlink(
"Let us know",
"https://warpdotdev.typeform.com/to/offrTIpq",
),
] ]
}); });
@@ -4871,7 +4902,7 @@ impl SettingsWidget for MCPServersWidget {
let mcp_description = vec![ let mcp_description = vec![
FormattedTextFragment::plain_text( FormattedTextFragment::plain_text(
"Add MCP servers to extend the Warp Agent's capabilities. \ "Add MCP servers to extend the Warp Agent's capabilities. \
MCP servers expose data sources or tools to agents through a standardized interface, essentially acting like plugins. ", MCP servers expose data sources or tools to agents through a standardized interface, essentially acting like plugins. ",
), ),
FormattedTextFragment::hyperlink( FormattedTextFragment::hyperlink(
@@ -5170,10 +5201,12 @@ impl VoiceWidget {
)); ));
let voice_input_description_text_fragments = vec![ let voice_input_description_text_fragments = vec![
FormattedTextFragment::plain_text("Voice input allows you to control Warp by speaking directly to your terminal (powered by "), FormattedTextFragment::plain_text(
FormattedTextFragment::hyperlink("Wispr Flow", WISPR_FLOW_URL), "Voice input allows you to control Warp by speaking directly to your terminal (powered by ",
FormattedTextFragment::plain_text(")."), ),
]; FormattedTextFragment::hyperlink("Wispr Flow", WISPR_FLOW_URL),
FormattedTextFragment::plain_text(")."),
];
let voice_input_description = FormattedTextElement::new( let voice_input_description = FormattedTextElement::new(
FormattedText::new([FormattedTextLine::Line( FormattedText::new([FormattedTextLine::Line(
@@ -5679,6 +5712,139 @@ impl SettingsWidget for CLIAgentWidget {
} }
} }
/// The presentation state of the agent attribution toggle, derived from the
/// org-level [`AdminEnablementSetting`], the user's stored preference, and
/// whether AI is globally enabled.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AgentAttributionToggleState {
/// Whether the toggle is rendered in the checked state.
pub(crate) is_enabled: bool,
/// Whether the org has forced the value (locking the toggle with a tooltip).
pub(crate) is_forced_by_org: bool,
/// Whether the toggle should be rendered as non-interactive overall
/// (forced by the org, or AI globally disabled).
pub(crate) is_disabled: bool,
}
/// Derive the toggle state from its three inputs.
pub(crate) fn derive_agent_attribution_toggle_state(
org_setting: &AdminEnablementSetting,
user_pref: bool,
is_any_ai_enabled: bool,
) -> AgentAttributionToggleState {
let is_forced_by_org = match org_setting {
AdminEnablementSetting::Enable | AdminEnablementSetting::Disable => true,
AdminEnablementSetting::RespectUserSetting => false,
};
let is_enabled = match org_setting {
AdminEnablementSetting::Enable => true,
AdminEnablementSetting::Disable => false,
AdminEnablementSetting::RespectUserSetting => user_pref,
};
AgentAttributionToggleState {
is_enabled,
is_forced_by_org,
is_disabled: is_forced_by_org || !is_any_ai_enabled,
}
}
#[derive(Default)]
struct AgentAttributionWidget {
toggle: SwitchStateHandle,
}
impl SettingsWidget for AgentAttributionWidget {
type View = AISettingsPageView;
fn search_terms(&self) -> &str {
"agent attribution commit pull request co-author author credit oz warp"
}
fn render(
&self,
_view: &Self::View,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
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();
let state = derive_agent_attribution_toggle_state(
&org_setting,
*ai_settings.agent_attribution_enabled,
is_any_ai_enabled,
);
let ui_builder = appearance.ui_builder();
let toggle = if state.is_forced_by_org {
ui_builder
.switch(self.toggle.clone())
.check(state.is_enabled)
.with_tooltip(TooltipConfig {
text: "This option is enforced by your organization's settings and cannot be customized.".to_string(),
styles: ui_builder.default_tool_tip_styles(),
})
.disable()
.build()
.finish()
} else if !is_any_ai_enabled {
ui_builder
.switch(self.toggle.clone())
.check(state.is_enabled)
.with_disabled(true)
.build()
.finish()
} else {
ui_builder
.switch(self.toggle.clone())
.check(state.is_enabled)
.build()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AISettingsPageAction::ToggleAgentAttribution);
})
.finish()
};
let toggle_row = build_toggle_element(
render_body_item_label::<AISettingsPageAction>(
"Enable agent attribution".to_string(),
Some(styles::header_font_color(!state.is_disabled, app)),
None,
LocalOnlyIconState::Hidden,
ToggleState::Enabled,
appearance,
),
toggle,
appearance,
None,
);
Flex::column()
.with_child(render_separator(appearance))
.with_child(
build_sub_header(
appearance,
"Agent Attribution",
Some(styles::header_font_color(is_any_ai_enabled, app)),
)
.with_padding_bottom(HEADER_PADDING)
.finish(),
)
.with_child(toggle_row)
.with_child(render_ai_setting_description(
"Oz can add attribution to commit messages and pull requests it creates",
!state.is_disabled,
app,
))
.finish()
}
}
#[cfg(test)]
#[path = "ai_page_tests.rs"]
mod tests;
#[derive(Default)] #[derive(Default)]
struct CloudAgentComputerUseWidget { struct CloudAgentComputerUseWidget {
toggle: SwitchStateHandle, toggle: SwitchStateHandle,
@@ -6028,11 +6194,9 @@ impl ApiKeysWidget {
FormattedTextFragment::plain_text(" to use your own API keys."), FormattedTextFragment::plain_text(" to use your own API keys."),
] ]
} else { } else {
vec![ vec![FormattedTextFragment::plain_text(
FormattedTextFragment::plain_text( "Ask your team's admin to upgrade to the Build plan to use your own API keys.",
"Ask your team's admin to upgrade to the Build plan to use your own API keys.", )]
),
]
} }
} }
} else { } else {
+93
View File
@@ -0,0 +1,93 @@
use super::{derive_agent_attribution_toggle_state, AgentAttributionToggleState};
use crate::workspaces::workspace::AdminEnablementSetting;
#[test]
fn respect_user_setting_returns_user_pref_unlocked() {
let state = derive_agent_attribution_toggle_state(
&AdminEnablementSetting::RespectUserSetting,
true,
true,
);
assert_eq!(
state,
AgentAttributionToggleState {
is_enabled: true,
is_forced_by_org: false,
is_disabled: false,
}
);
}
#[test]
fn respect_user_setting_with_user_off_returns_unchecked_unlocked() {
let state = derive_agent_attribution_toggle_state(
&AdminEnablementSetting::RespectUserSetting,
false,
true,
);
assert_eq!(
state,
AgentAttributionToggleState {
is_enabled: false,
is_forced_by_org: false,
is_disabled: false,
}
);
}
#[test]
fn team_enable_locks_toggle_on_regardless_of_user_pref() {
let state = derive_agent_attribution_toggle_state(&AdminEnablementSetting::Enable, false, true);
assert_eq!(
state,
AgentAttributionToggleState {
is_enabled: true,
is_forced_by_org: true,
is_disabled: true,
}
);
}
#[test]
fn team_disable_locks_toggle_off_regardless_of_user_pref() {
let state = derive_agent_attribution_toggle_state(&AdminEnablementSetting::Disable, true, true);
assert_eq!(
state,
AgentAttributionToggleState {
is_enabled: false,
is_forced_by_org: true,
is_disabled: true,
}
);
}
#[test]
fn ai_globally_disabled_marks_toggle_disabled_but_not_forced() {
let state = derive_agent_attribution_toggle_state(
&AdminEnablementSetting::RespectUserSetting,
true,
false,
);
assert_eq!(
state,
AgentAttributionToggleState {
is_enabled: true,
is_forced_by_org: false,
is_disabled: true,
}
);
}
#[test]
fn team_force_takes_precedence_over_global_ai_disabled() {
let state =
derive_agent_attribution_toggle_state(&AdminEnablementSetting::Enable, false, false);
assert_eq!(
state,
AgentAttributionToggleState {
is_enabled: true,
is_forced_by_org: true,
is_disabled: true,
}
);
}
+4
View File
@@ -827,6 +827,10 @@ impl From<GqlWorkspaceSettings> for WorkspaceSettings {
.map(|denylist| denylist.to_predicates()), .map(|denylist| denylist.to_predicates()),
} }
}), }),
enable_warp_attribution: gql_workspace_settings
.ambient_agent_settings
.map(|s| s.enable_warp_attribution.into())
.unwrap_or_default(),
} }
} }
} }
+10
View File
@@ -1456,6 +1456,16 @@ impl UserWorkspaces {
} }
} }
/// Returns the team-level agent attribution setting.
///
/// 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 {
self.current_team()
.map(|team| team.organization_settings.enable_warp_attribution.clone())
.unwrap_or_default()
}
/// Returns only the organization-specific codebase context enablement setting. /// Returns only the organization-specific codebase context enablement setting.
/// Do not use this function to determine whether codebase context is generally enabled -- /// Do not use this function to determine whether codebase context is generally enabled --
/// use `is_codebase_context_enabled` instead. /// use `is_codebase_context_enabled` instead.
+102
View File
@@ -614,6 +614,108 @@ fn test_joining_team_moves_objects() {
}) })
} }
#[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()),
);
app.read(|ctx| {
let setting = UserWorkspaces::as_ref(ctx).get_agent_attribution_setting();
assert_eq!(
setting,
AdminEnablementSetting::RespectUserSetting,
"attribution should default to RespectUserSetting when there is no workspace"
);
});
})
}
#[test]
fn test_agent_attribution_forced_on_by_team() {
let mut team = team_for_test();
team.organization_settings.enable_warp_attribution = AdminEnablementSetting::Enable;
let workspace = workspace_for_test(&team);
App::test((), |mut app| async move {
initialize_app(
&mut app,
CachedResources {
workspaces: vec![workspace],
},
Arc::new(MockTeamClient::new()),
Arc::new(MockWorkspaceClient::new()),
);
app.read(|ctx| {
let setting = UserWorkspaces::as_ref(ctx).get_agent_attribution_setting();
assert_eq!(
setting,
AdminEnablementSetting::Enable,
"attribution should be Enable when forced on by the team"
);
});
})
}
#[test]
fn test_agent_attribution_forced_off_by_team() {
let mut team = team_for_test();
team.organization_settings.enable_warp_attribution = AdminEnablementSetting::Disable;
let workspace = workspace_for_test(&team);
App::test((), |mut app| async move {
initialize_app(
&mut app,
CachedResources {
workspaces: vec![workspace],
},
Arc::new(MockTeamClient::new()),
Arc::new(MockWorkspaceClient::new()),
);
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"
);
});
})
}
#[test]
fn test_agent_attribution_respects_user_setting() {
let mut team = team_for_test();
team.organization_settings.enable_warp_attribution = AdminEnablementSetting::RespectUserSetting;
let workspace = workspace_for_test(&team);
App::test((), |mut app| async move {
initialize_app(
&mut app,
CachedResources {
workspaces: vec![workspace],
},
Arc::new(MockTeamClient::new()),
Arc::new(MockWorkspaceClient::new()),
);
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"
);
});
})
}
#[test] #[test]
fn test_leaving_team_moves_objects() { fn test_leaving_team_moves_objects() {
let _flag = FeatureFlag::SharedWithMe.override_enabled(true); let _flag = FeatureFlag::SharedWithMe.override_enabled(true);
+4
View File
@@ -774,4 +774,8 @@ pub struct WorkspaceSettings {
pub addon_credits_settings: AddonCreditsSettings, pub addon_credits_settings: AddonCreditsSettings,
pub codebase_context_settings: CodebaseContextSettings, pub codebase_context_settings: CodebaseContextSettings,
pub sandboxed_agent_settings: Option<SandboxedAgentSettings>, pub sandboxed_agent_settings: Option<SandboxedAgentSettings>,
/// The team-level agent attribution setting. When `Enable` or `Disable`, the
/// user toggle is locked. When `RespectUserSetting` (or absent), the user can choose.
#[serde(default)]
pub enable_warp_attribution: AdminEnablementSetting,
} }
+6
View File
@@ -120,6 +120,11 @@ pub struct CodebaseContextSettings {
pub setting: AdminEnablementSetting, pub setting: AdminEnablementSetting,
} }
#[derive(cynic::QueryFragment, Debug, Clone)]
pub struct AmbientAgentSettings {
pub enable_warp_attribution: AdminEnablementSetting,
}
#[derive(cynic::QueryFragment, Debug, Clone)] #[derive(cynic::QueryFragment, Debug, Clone)]
pub struct WorkspaceSettings { pub struct WorkspaceSettings {
pub is_discoverable: bool, pub is_discoverable: bool,
@@ -136,6 +141,7 @@ pub struct WorkspaceSettings {
pub addon_credits_settings: AddonCreditsSettings, pub addon_credits_settings: AddonCreditsSettings,
pub codebase_context_settings: CodebaseContextSettings, pub codebase_context_settings: CodebaseContextSettings,
pub sandboxed_agent_settings: Option<SandboxedAgentSettings>, pub sandboxed_agent_settings: Option<SandboxedAgentSettings>,
pub ambient_agent_settings: Option<AmbientAgentSettings>,
} }
#[derive(cynic::QueryFragment, Debug, Clone)] #[derive(cynic::QueryFragment, Debug, Clone)]
@@ -380,14 +380,14 @@ input AiPermissionsSettingsInput {
type AmbientAgentSettings { type AmbientAgentSettings {
defaultHostSlug: String defaultHostSlug: String
enableWarpAttribution: Boolean! enableWarpAttribution: AdminEnablementSetting!
instanceShape: InstanceShape instanceShape: InstanceShape
warpHostedAgentsEnabled: Boolean! warpHostedAgentsEnabled: Boolean!
} }
input AmbientAgentSettingsInput { input AmbientAgentSettingsInput {
defaultHostSlug: String defaultHostSlug: String
enableWarpAttribution: Boolean enableWarpAttribution: AdminEnablementSetting
instanceShape: InstanceShapeInput instanceShape: InstanceShapeInput
warpHostedAgentsEnabled: Boolean warpHostedAgentsEnabled: Boolean
} }
@@ -3586,6 +3586,7 @@ type UpdateOnboardingSurveyStatusOutput implements Response {
union UpdateOnboardingSurveyStatusResult = UpdateOnboardingSurveyStatusOutput | UserFacingError union UpdateOnboardingSurveyStatusResult = UpdateOnboardingSurveyStatusOutput | UserFacingError
input UpdateUserSettingsInput { input UpdateUserSettingsInput {
agentAttributionEnabled: Boolean
cloudConversationStorageEnabled: Boolean cloudConversationStorageEnabled: Boolean
crashReportingEnabled: Boolean crashReportingEnabled: Boolean
telemetryEnabled: Boolean telemetryEnabled: Boolean
@@ -3868,6 +3869,7 @@ Workspace specific settings like telemetry enablement override these user settin
UserSettings types and mutations are the v2 equivalent of the v1 user_settings.graphqls file. UserSettings types and mutations are the v2 equivalent of the v1 user_settings.graphqls file.
""" """
type UserSettings { type UserSettings {
isAgentAttributionEnabled: Boolean!
isCloudConversationStorageEnabled: Boolean! isCloudConversationStorageEnabled: Boolean!
isCrashReportingEnabled: Boolean! isCrashReportingEnabled: Boolean!
isTelemetryEnabled: Boolean! isTelemetryEnabled: Boolean!
+64
View File
@@ -0,0 +1,64 @@
# APP-3585: Setting for agent commit and PR attribution
Linear: https://linear.app/warpdotdev/issue/APP-3585
## Summary
Oz agent currently always adds a `Co-Authored-By: Oz <oz-agent@warp.dev>` attribution line to commit messages and pull request descriptions it creates. This behavior is gated by a binary team-level setting managed by admins. This feature upgrades that gate to a two-level setting: admins choose Yes / No / Respect User Choice, and individual users can control the setting when their team has not forced it.
## Problem
Teams and individual users may not want Oz's name in their git history or PR descriptions. Currently, admins can only turn attribution on or off for the entire team. There is no per-user opt-out, and users who want to control the behavior independently cannot do so unless their admin acts first.
## Goals
- Add a user-level toggle in the AI settings page for "Enable agent attribution."
- Give team admins a three-way choice (Yes / No / Respect User Choice) rather than a binary toggle.
- When a team has forced the setting on or off, reflect that in the user's UI and prevent the user from overriding it.
- Gate the attribution instructions in the Oz agent prompt on the effective combined setting.
## Non-goals
- Changing the content or format of the attribution line itself.
- Controlling attribution granularly per-repo or per-project.
- Any changes to attribution on shared agent runs.
## Figma / design references
Figma: none provided. The toggle follows the existing pattern used for "Store AI conversations in the cloud" and "Codebase Context."
## User experience
### AI settings page — user toggle
- A new widget appears in the AI settings page under the **Oz** section, in its own row.
- Label: **"Enable agent attribution"**
- Description: **"Oz can add attribution to commit messages and pull requests it creates"**
- Rendered as a standard boolean toggle switch.
**Default behavior:**
- When the user has no team, or their team's setting is "Respect User Choice," the toggle defaults to **on** (attribution enabled).
**Team-forced-on:**
- When the admin panel setting is "Yes" (force-enable), the toggle is shown in the **checked / on** state and is **non-interactive** (grayed out).
- A tooltip on the disabled toggle reads: **"This option is enforced by your organization's settings and cannot be customized."** (the standard AI-page wording shared with other AI settings such as Computer use in Cloud Agents).
**Team-forced-off:**
- When the admin panel setting is "No" (force-disable), the toggle is shown in the **unchecked / off** state and is **non-interactive**.
- Same tooltip as above.
**Team respects user choice:**
- When the admin panel setting is "Respect User Choice," the toggle is **interactive** and reflects the user's stored preference (default: on).
- Toggling it updates the user's preference immediately and persists across sessions.
### Admin panel — team-level setting
- The existing binary (on/off) control for agent commit attribution in the admin panel is replaced with a three-way selector: **Yes / No / Respect User Choice**.
- Default: **Respect User Choice** (matching the current behavior for teams that have not set this explicitly).
### Agent prompt
- Attribution instructions are only included in the Oz agent prompt when the **effective** setting resolves to **on**:
- Team = Yes → always on (attribution instructions included).
- Team = No → always off (attribution instructions excluded).
- Team = Respect User Choice → on if user's preference is on, off if user's preference is off.
## Success criteria
- The "Enable agent attribution" toggle appears in the AI settings page under the Oz section.
- When no team or team setting is "Respect User Choice": the toggle is interactive, defaults to checked, and the user can turn it off.
- When team setting is "Yes": the toggle is visible, checked, and non-interactive with the managed-by-org tooltip.
- When team setting is "No": the toggle is visible, unchecked, and non-interactive with the managed-by-org tooltip.
- A commit or PR created by Oz **contains** the attribution line when the effective setting is on.
- A commit or PR created by Oz **does not contain** the attribution line when the effective setting is off.
- The user's preference persists across app restarts and across devices (cloud-synced).
- The admin panel shows a three-way selector (Yes / No / Respect User Choice) for team attribution.
## Validation
- Manual: verify toggle appears in Settings > AI under the Oz section with the correct label and description.
- Manual: verify toggle is locked (with tooltip) when team setting is Yes or No.
- Manual: verify toggling the setting off and creating a commit/PR via Oz does not include the attribution line.
- Manual: verify toggling the setting on and creating a commit/PR via Oz includes the attribution line.
- Manual: verify user preference persists after restarting the app.
- Manual: verify admin panel shows the three-way selector, and that each team-level option is correctly reflected in the user's toggle state.
## Open questions
- None at this time.
+92
View File
@@ -0,0 +1,92 @@
# APP-3585: Setting for agent commit and PR attribution — Client Tech Spec
Product spec: `specs/APP-3585/PRODUCT.md`.
Server tech spec: `warp-server/specs/APP-3585/TECH.md` (PR https://github.com/warpdotdev/warp-server/pull/10476).
## Problem
The attribution setting (whether Oz adds a `Co-Authored-By` line to commits and PRs) is currently a binary team-level flag. We need to turn it into a two-level setting: team admins get a three-way `AdminEnablementSetting` (Enable / Disable / RespectUserSetting), and individual users get their own boolean preference that takes effect when the team delegates.
This spec covers the warp-internal (client) changes. Anything about how the server stores or resolves the effective value lives in the server spec.
## Relevant code
- `app/src/settings/ai.rs``define_settings_group!(AISettings, ...)`: new user-level `agent_attribution_enabled` bool.
- `app/src/workspaces/workspace.rs (631776)` — domain types for team settings (`AdminEnablementSetting`, `WorkspaceSettings`); flat `enable_warp_attribution: AdminEnablementSetting` added directly on `WorkspaceSettings`.
- `app/src/workspaces/user_workspaces.rs (13631458)` — accessor methods for team settings; new `get_agent_attribution_setting()`.
- `app/src/workspaces/gql_convert.rs``From<GqlAdminEnablementSetting>` mapping for the new field.
- `crates/graphql/src/api/workspace.rs (124146)``WorkspaceSettings` cynic fragment; new `AmbientAgentSettings` fragment.
- `app/src/settings_view/ai_page.rs` — new `AgentAttributionWidget`; follows `CloudConversationStorageWidget` in `privacy_page.rs (16671770)` for team-override rendering.
- `app/src/settings/cloud_preferences_syncer.rs` — existing syncer that publishes any `SyncToCloud::Globally(...)` setting to warp-server as a `JsonPreference` GSO. No changes required — reused as the user-preference transport.
## Current state
`AmbientAgentSettings` on the server exposes `enableWarpAttribution: Boolean!`; the client does not fetch it. There is no user-level attribution preference, and attribution instructions are unconditionally present or absent in the agent prompt based solely on the server-side team boolean.
The client already has the transport we need for a user-level preference: any setting declared with `SyncToCloud::Globally(...)` via `define_settings_group!` is uploaded to warp-server by `CloudPreferencesSyncer` as a `JsonPreference` GSO with `unique_key = Global_<StorageKey>` (scoped per user). Other `AISettings` fields (e.g. `IncludeAgentCommandsInHistory`, `ShowConversationHistory`) already use this pattern, and warp-server reads them back by GSO lookup.
## Proposed changes
### 1. New user-level client setting
Add `agent_attribution_enabled` to `define_settings_group!(AISettings, ...)` in `app/src/settings/ai.rs`:
- default `true`
- `SyncToCloud::Globally(RespectUserSyncSetting::Yes)` (AI/coding preference, not a privacy/safety one)
- implicit storage key `AgentAttributionEnabled`
On toggle, the setting is persisted locally and the existing `CloudPreferencesSyncer` upserts the user's `JsonPreference` GSO (`unique_key = {firebaseUID}_Global_AgentAttributionEnabled` on the server side). No bespoke server call. See the server spec for the read path.
Because we use `RespectUserSyncSetting::Yes`, users who have turned "Sync settings across devices" off do not publish their preference to the server; the server falls back to the default (`true`) for those users. This matches how all other `AISettings` fields behave today.
### 2. Team-level field on `WorkspaceSettings` (flat, not wrapped)
Add a flat `enable_warp_attribution: AdminEnablementSetting` field (with `#[serde(default)]`) directly on `WorkspaceSettings` in `app/src/workspaces/workspace.rs`. No wrapper struct — the GQL field name matches the server directly.
`UserWorkspaces::get_agent_attribution_setting()` reads this field off the current team:
```rust
pub fn get_agent_attribution_setting(&self) -> AdminEnablementSetting {
self.current_team()
.map(|team| team.organization_settings.enable_warp_attribution.clone())
.unwrap_or_default()
}
```
This matches the shape of every other admin-enablement accessor on `UserWorkspaces` (`team_allows_codebase_context`, `is_ai_allowed_in_remote_sessions`, `get_cloud_conversation_storage_enablement_setting`).
### 3. GraphQL fetch
Add an `AmbientAgentSettings` cynic fragment in `crates/graphql/src/api/workspace.rs` with `enable_warp_attribution: AdminEnablementSetting`, and include it on the `WorkspaceSettings` fragment as `ambient_agent_settings: Option<AmbientAgentSettings>`. The wrapper is `Option` because `WorkspaceSettings.ambientAgentSettings` is nullable in the GQL schema (no `!`); this matches the existing convention for `sandboxed_agent_settings`. When the wrapper is `None`, `gql_convert.rs` falls back to `AdminEnablementSetting::default()` (= `RespectUserSetting`). The inner `enable_warp_attribution` is non-optional, as the server guarantees a value when the wrapper is present.
### 4. AI settings page widget
Add `AgentAttributionWidget` in `app/src/settings_view/ai_page.rs`. It reads `UserWorkspaces::get_agent_attribution_setting()` and derives the toggle state:
- `Enable` → locked on
- `Disable` → locked off
- `RespectUserSetting` → interactive, reflects `AISettings.agent_attribution_enabled`
Included in both the legacy full-page (`None` subpage) and the `Some(AISubpage::WarpAgent)` subpage. The widget always renders; when AI is globally disabled, the toggle is shown disabled with a greyed-out label and description, matching the behavior of every other widget on the AI page (e.g. `CloudAgentComputerUseWidget`, `CLIAgentWidget`).
Visual structure follows the AI-page convention: a `render_separator` and a `build_sub_header("Agent Attribution", ...)` precede the toggle row and description, mirroring `CloudAgentComputerUseWidget`. The cited `CloudConversationStorageWidget` reference applies only to the team-override state machine (locked vs. interactive based on `AdminEnablementSetting`), not to the visual structure, since that widget lives on the Privacy page which has different conventions.
For the locked-toggle tooltip, the widget uses `WORKSPACE_OVERRIDE_TOOLTIP_MESSAGE` from `app/src/ai/execution_profiles/editor/ui_helpers.rs` ("This option is enforced by your organization's settings and cannot be customized."), matching `CloudAgentComputerUseWidget` and the rest of the AI page. The Privacy page uses a shorter "This setting is managed by your organization." string; we deliberately follow the AI-page wording here for consistency.
## End-to-end flow (user toggles attribution off)
1. User opens Settings → AI → Oz.
2. Client reads the team's `enable_warp_attribution` from cached `WorkspaceSettings` via `get_agent_attribution_setting()`.
3. Setting is `RespectUserSetting`, so the toggle is interactive and reflects `AISettings.agent_attribution_enabled` (default `true`, showing as checked).
4. User clicks the toggle → `ToggleAgentAttribution` action sets `AISettings.agent_attribution_enabled` to `false` (persisted locally).
5. `CloudPreferencesSyncer` picks up the change and upserts the user's `JsonPreference` GSO (`unique_key = {firebaseUID}_Global_AgentAttributionEnabled`, `serialized_model.value = false`).
6. Next Oz run: warp-server resolves the effective setting (team = RespectUserSetting → read user GSO = false) and excludes attribution instructions from the prompt. Oz creates a commit without the `Co-Authored-By` line.
```mermaid
flowchart TD
A[User toggles off] --> B[AISettings.agent_attribution_enabled = false]
B --> C[CloudPreferencesSyncer upsert]
C --> D[(warp-server generic_string_objects\nunique_key = firebaseUID_Global_AgentAttributionEnabled)]
D --> E[Next Oz run]
E --> F{Team setting}
F -->|Enable| G[Attribution included]
F -->|Disable| H[Attribution excluded]
F -->|RespectUserSetting| I[Read user GSO = false]
I --> H
```
## Risks and mitigations
- **Client ships ahead of server**: the cynic fragment requires `enable_warp_attribution: AdminEnablementSetting!` on the inner `AmbientAgentSettings` type; if the client lands first, `GetWorkspaceSettings` fails to parse. Mitigation: sequence the server change first (covered by the server spec). The outer `Option<AmbientAgentSettings>` wrapper already absorbs the case where the whole sub-object is missing.
- **User has settings sync off**: their preference never reaches the server; the server defaults to `true`. This is the documented behavior for any `RespectUserSyncSetting::Yes` setting.
- **Widget enablement**: when AI is globally disabled, the widget renders in a disabled state (greyed-out toggle, label, and description), consistent with every other widget on the AI page.
## Testing and validation
- Unit tests in `app/src/workspaces/user_workspaces_tests.rs`:
- `test_agent_attribution_default_with_no_workspace` — no workspace → `RespectUserSetting`
- `test_agent_attribution_forced_on_by_team` — team `Enable` → accessor returns `Enable`
- `test_agent_attribution_forced_off_by_team` — team `Disable` → accessor returns `Disable`
- `test_agent_attribution_respects_user_setting` — team `RespectUserSetting` → accessor returns `RespectUserSetting`
- UI manual test: verify each team-setting state renders the toggle in the correct locked/interactive state, in both the legacy and Oz subpages.
- End-to-end (once the server branch merges): run an Oz agent task that creates a commit with the setting off → verify no attribution line. Run with team-level `Enable` and verify the client toggle is locked on; same for `Disable`.
## Follow-ups
- Admin-panel UI (separate warp-server PR) to change the team-level control from a binary toggle to a three-way selector.