Skip to content

Commit c50e9fd

Browse files
committed
Add harness availability model to the client.
1 parent 74bdbd1 commit c50e9fd

17 files changed

Lines changed: 489 additions & 48 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src/ai/agent_management/view.rs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use crate::ai::conversation_details_panel::{
3636
ConversationDetailsData, ConversationDetailsPanel, ConversationDetailsPanelEvent,
3737
};
3838
use crate::ai::conversation_status_ui::render_status_element;
39+
use crate::ai::harness_availability::HarnessAvailabilityModel;
3940
use crate::ai::harness_display;
4041
use crate::app_state::PersistedAgentManagementFilters;
4142
use crate::appearance::Appearance;
@@ -222,6 +223,13 @@ impl AgentManagementView {
222223
Self::handle_agent_management_model_event,
223224
);
224225

226+
ctx.subscribe_to_model(
227+
&HarnessAvailabilityModel::handle(ctx),
228+
|me, _, _event, ctx| {
229+
me.update_harness_dropdown(ctx);
230+
},
231+
);
232+
225233
let list_state = Self::construct_fresh_list_state(ctx.handle());
226234

227235
let all_filter_button = ctx.add_typed_action_view(|_ctx| {
@@ -672,15 +680,25 @@ impl AgentManagementView {
672680
let mut dropdown = Dropdown::new(ctx);
673681
Self::setup_filter_menu(&mut dropdown, "Harness", ctx);
674682

675-
// "All" has no leading icon, matching the Status dropdown's "All" row.
683+
let items = Self::build_harness_dropdown_items(ctx);
684+
dropdown.set_rich_items(items, ctx);
685+
dropdown.set_selected_by_index(0, ctx);
686+
dropdown
687+
}
688+
689+
fn build_harness_dropdown_items(
690+
app: &AppContext,
691+
) -> Vec<MenuItem<DropdownAction<AgentManagementViewAction>>> {
676692
let mut items = vec![MenuItem::Item(
677693
MenuItemFields::new("All").with_on_select_action(DropdownAction::SelectActionAndClose(
678694
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::All),
679695
)),
680696
)];
681697

682-
for harness in [Harness::Oz, Harness::Claude, Harness::Gemini] {
683-
let mut fields = MenuItemFields::new(harness_display::display_name(harness))
698+
let availability = HarnessAvailabilityModel::as_ref(app);
699+
for entry in availability.available_harnesses() {
700+
let harness = entry.harness;
701+
let mut fields = MenuItemFields::new(entry.display_name.clone())
684702
.with_icon(harness_display::icon_for(harness))
685703
.with_on_select_action(DropdownAction::SelectActionAndClose(
686704
AgentManagementViewAction::SetHarnessFilter(HarnessFilter::Specific(harness)),
@@ -691,9 +709,7 @@ impl AgentManagementView {
691709
items.push(MenuItem::Item(fields));
692710
}
693711

694-
dropdown.set_rich_items(items, ctx);
695-
dropdown.set_selected_by_index(0, ctx);
696-
dropdown
712+
items
697713
}
698714

699715
fn create_environment_dropdown(
@@ -755,6 +771,13 @@ impl AgentManagementView {
755771
dropdown.set_button_variant(ButtonVariant::Secondary);
756772
}
757773

774+
fn update_harness_dropdown(&mut self, ctx: &mut ViewContext<Self>) {
775+
let items = Self::build_harness_dropdown_items(ctx);
776+
self.harness_dropdown.update(ctx, |dropdown, ctx| {
777+
dropdown.set_rich_items(items, ctx);
778+
});
779+
}
780+
758781
/// Since the valid set of environments depends on what tasks we have loaded in,
759782
/// we use this function to update the available options depending on the most recent
760783
/// set of tasks.
@@ -1836,11 +1859,12 @@ impl AgentManagementView {
18361859
metadata_parts.push(format!("Source: {}", source.display_name()));
18371860
}
18381861

1839-
if FeatureFlag::AgentHarness.is_enabled() {
1862+
let availability = HarnessAvailabilityModel::as_ref(app);
1863+
if availability.should_show_harness_selector() {
18401864
if let Some(harness) = card_data.harness(app) {
18411865
metadata_parts.push(format!(
18421866
"Harness: {}",
1843-
harness_display::display_name(harness)
1867+
availability.display_name_for(harness)
18441868
));
18451869
}
18461870
}
@@ -1972,7 +1996,7 @@ impl AgentManagementView {
19721996
.with_child(ChildView::new(&self.created_on_dropdown).finish())
19731997
.with_child(ChildView::new(&self.artifact_dropdown).finish());
19741998

1975-
if FeatureFlag::AgentHarness.is_enabled() {
1999+
if HarnessAvailabilityModel::as_ref(app).should_show_harness_selector() {
19762000
filters_wrap.add_child(ChildView::new(&self.harness_dropdown).finish());
19772001
}
19782002

app/src/ai/conversation_details_panel.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ use pathfinder_color::ColorU;
99
use warp_cli::agent::Harness;
1010
use warp_cli::skill::SkillSpec;
1111
use warp_core::channel::ChannelState;
12-
use warp_core::features::FeatureFlag;
1312
use warp_core::ui::color::coloru_with_opacity;
1413
use warpui::{
1514
clipboard::ClipboardContent,
@@ -39,6 +38,7 @@ use crate::ai::ambient_agents::{cancel_task_with_toast, AmbientAgentTaskId};
3938
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
4039
use crate::ai::blocklist::BlocklistAIHistoryModel;
4140
use crate::ai::cloud_environments::{AmbientAgentEnvironment, CloudAmbientAgentEnvironment};
41+
use crate::ai::harness_availability::HarnessAvailabilityModel;
4242
use crate::ai::harness_display;
4343
use crate::appearance::Appearance;
4444
use crate::auth::UserUid;
@@ -982,8 +982,13 @@ impl ConversationDetailsPanel {
982982
)
983983
}
984984

985-
fn render_harness_section(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
986-
if !FeatureFlag::AgentHarness.is_enabled() {
985+
fn render_harness_section(
986+
&self,
987+
appearance: &Appearance,
988+
app: &AppContext,
989+
) -> Option<Box<dyn Element>> {
990+
let availability = HarnessAvailabilityModel::as_ref(app);
991+
if !availability.should_show_harness_selector() {
987992
return None;
988993
}
989994
let harness = self.data.harness?;
@@ -1012,7 +1017,7 @@ impl ConversationDetailsPanel {
10121017
.finish();
10131018

10141019
let name = Text::new(
1015-
harness_display::display_name(harness).to_string(),
1020+
availability.display_name_for(harness).to_string(),
10161021
appearance.ui_font_family(),
10171022
ui_font_size,
10181023
)
@@ -1659,7 +1664,7 @@ impl View for ConversationDetailsPanel {
16591664
);
16601665
}
16611666

1662-
if let Some(harness_section) = self.render_harness_section(appearance) {
1667+
if let Some(harness_section) = self.render_harness_section(appearance, app) {
16631668
content.add_child(
16641669
Container::new(harness_section)
16651670
.with_margin_bottom(FIELD_SPACING)

app/src/ai/harness_availability.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
use serde::{Deserialize, Serialize};
2+
use warp_cli::agent::Harness;
3+
use warp_core::features::FeatureFlag;
4+
use warp_core::user_preferences::GetUserPreferences;
5+
use warpui::{Entity, ModelContext, SingletonEntity};
6+
7+
use crate::ai::harness_display;
8+
use crate::auth::auth_manager::{AuthManager, AuthManagerEvent};
9+
use crate::auth::AuthStateProvider;
10+
use crate::network::{NetworkStatus, NetworkStatusEvent, NetworkStatusKind};
11+
use crate::report_error;
12+
use crate::server::server_api::ServerApiProvider;
13+
use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent};
14+
15+
const CACHE_KEY: &str = "AvailableHarnesses";
16+
17+
/// Server-resolved harness availability entry.
18+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
19+
pub struct HarnessAvailability {
20+
pub harness: Harness,
21+
pub display_name: String,
22+
pub enabled: bool,
23+
}
24+
25+
/// Default fallback used before the server responds.
26+
/// Oz is enabled by default so the UI is usable pre-fetch; the server
27+
/// list (which respects admin overrides) replaces this once available.
28+
fn default_harnesses() -> Vec<HarnessAvailability> {
29+
vec![HarnessAvailability {
30+
harness: Harness::Oz,
31+
display_name: "Warp".to_string(),
32+
enabled: true,
33+
}]
34+
}
35+
36+
pub enum HarnessAvailabilityEvent {
37+
Changed,
38+
}
39+
40+
pub struct HarnessAvailabilityModel {
41+
harnesses: Vec<HarnessAvailability>,
42+
}
43+
44+
impl HarnessAvailabilityModel {
45+
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
46+
let harnesses = get_cached(ctx).unwrap_or_else(default_harnesses);
47+
48+
ctx.subscribe_to_model(&NetworkStatus::handle(ctx), |me, event, ctx| {
49+
if let NetworkStatusEvent::NetworkStatusChanged {
50+
new_status: NetworkStatusKind::Online,
51+
} = event
52+
{
53+
me.refresh(ctx);
54+
}
55+
});
56+
57+
ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, event, ctx| {
58+
if let AuthManagerEvent::AuthComplete = event {
59+
me.refresh(ctx);
60+
}
61+
});
62+
63+
ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, event, ctx| {
64+
if let UserWorkspacesEvent::TeamsChanged = event {
65+
me.refresh(ctx);
66+
}
67+
});
68+
69+
let me = Self { harnesses };
70+
me.refresh(ctx);
71+
me
72+
}
73+
74+
pub fn available_harnesses(&self) -> &[HarnessAvailability] {
75+
&self.harnesses
76+
}
77+
78+
pub fn display_name_for(&self, harness: Harness) -> &str {
79+
self.harnesses
80+
.iter()
81+
.find(|h| h.harness == harness)
82+
.map(|h| h.display_name.as_str())
83+
.unwrap_or_else(|| harness_display::display_name(harness))
84+
}
85+
86+
pub fn enabled_harness_count(&self) -> usize {
87+
self.harnesses.iter().filter(|h| h.enabled).count()
88+
}
89+
90+
/// Whether the harness selector should be shown (>1 enabled harness).
91+
pub fn should_show_harness_selector(&self) -> bool {
92+
FeatureFlag::AgentHarness.is_enabled() && self.enabled_harness_count() > 1
93+
}
94+
95+
/// Whether any harness is available at all (at least one enabled).
96+
pub fn has_any_enabled_harness(&self) -> bool {
97+
self.harnesses.iter().any(|h| h.enabled)
98+
}
99+
100+
/// Whether a harness is both known and enabled.
101+
pub fn is_harness_enabled(&self, harness: Harness) -> bool {
102+
self.harnesses
103+
.iter()
104+
.any(|h| h.harness == harness && h.enabled)
105+
}
106+
107+
pub fn refresh(&self, ctx: &mut ModelContext<Self>) {
108+
// The endpoint queries `user`, which requires auth.
109+
if !AuthStateProvider::as_ref(ctx).get().is_logged_in() {
110+
return;
111+
}
112+
113+
let ai_client = ServerApiProvider::as_ref(ctx).get_ai_client();
114+
ctx.spawn(
115+
async move { ai_client.get_available_harnesses().await },
116+
|me, result, ctx| match result {
117+
Ok(new_harnesses) => {
118+
if new_harnesses != me.harnesses {
119+
me.harnesses = new_harnesses;
120+
me.cache(ctx);
121+
ctx.emit(HarnessAvailabilityEvent::Changed);
122+
}
123+
}
124+
Err(e) => {
125+
report_error!(e.context("Failed to fetch available harnesses"));
126+
}
127+
},
128+
);
129+
}
130+
131+
fn cache(&self, ctx: &ModelContext<Self>) {
132+
if let Ok(serialized) = serde_json::to_string(&self.harnesses) {
133+
if let Err(e) = ctx
134+
.private_user_preferences()
135+
.write_value(CACHE_KEY, serialized)
136+
{
137+
report_error!(anyhow::anyhow!(e).context("Failed to cache available harnesses"));
138+
}
139+
}
140+
}
141+
}
142+
143+
fn get_cached(ctx: &ModelContext<HarnessAvailabilityModel>) -> Option<Vec<HarnessAvailability>> {
144+
let raw = ctx
145+
.private_user_preferences()
146+
.read_value(CACHE_KEY)
147+
.ok()??;
148+
serde_json::from_str::<Vec<HarnessAvailability>>(&raw).ok()
149+
}
150+
151+
impl Entity for HarnessAvailabilityModel {
152+
type Event = HarnessAvailabilityEvent;
153+
}
154+
155+
impl SingletonEntity for HarnessAvailabilityModel {}

app/src/ai/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pub(crate) mod conversation_status_ui;
2424
pub(crate) mod conversation_utils;
2525
pub(crate) mod document;
2626
pub(crate) mod get_relevant_files;
27+
pub mod harness_availability;
2728
pub(crate) mod harness_display;
2829
pub(crate) mod llms;
2930
pub mod onboarding;

app/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ use workflows::manager::WorkflowManager;
196196
use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier;
197197
use crate::ai::document::ai_document_model::AIDocumentModel;
198198
use crate::ai::facts::manager::AIFactManager;
199+
use crate::ai::harness_availability::HarnessAvailabilityModel;
199200
use crate::ai::llms::LLMPreferences;
200201
use crate::ai::mcp::MCPGalleryManager;
201202
use crate::ai::mcp::TemplatableMCPServerManager;
@@ -1794,6 +1795,7 @@ fn initialize_app(
17941795
ctx.add_singleton_model(LocalWorkflows::new);
17951796

17961797
ctx.add_singleton_model(LLMPreferences::new);
1798+
ctx.add_singleton_model(HarnessAvailabilityModel::new);
17971799

17981800
ctx.add_singleton_model(|ctx| {
17991801
ai::agent_tips::AITipModel::<ai::AgentTip>::new_for_agent_tips(ctx)

0 commit comments

Comments
 (0)