15 KiB
TECH.md
Companion to specs/REMOTE-1457/PRODUCT.md.
Context
Add a Harness dropdown to AgentManagementView. Behavior lives in PRODUCT.md; this plan covers the filter plumbing, where the dropdown plugs in, and how harness resolution reuses the helpers introduced by REMOTE-1455.
Relevant code:
app/src/ai/agent_conversations_model.rs (60-150)— existingStatusFilter/SourceFilter/ArtifactFilter/EnvironmentFilterenums and theAgentManagementFiltersaggregate (incl.reset_all_but_owner/is_filtering). NewHarnessFilterlands here.app/src/ai/agent_conversations_model.rs (332-728)—ConversationOrTaskimpl (source,environment_id,matches_status,matches_artifact,matches_owner_and_creator).app/src/ai/agent_conversations_model.rs (1379-1443)—get_tasks_and_conversations: where the per-item*_filterclosures are chained. The harness filter plugs in here.app/src/ai/agent_conversations_model.rs (1585-1647)—build_task_list_filter: convertsAgentManagementFiltersinto the server-sideTaskListFilter. Harness is not added toTaskListFilterin this change — client-side filtering only (see "Scope" below).app/src/ai/agent_management/view.rs (154-198)—AgentManagementViewstruct: newharness_dropdownfield goes here.app/src/ai/agent_management/view.rs (265-450)— dropdown construction +sync_with_loaded_filters+update_filter_buttons.app/src/ai/agent_management/view.rs (468-702)— existing dropdown builders (create_status_dropdown,create_artifact_dropdown,setup_filter_menu): the newcreate_harness_dropdownmirrors these.app/src/ai/agent_management/view.rs (847-881)—apply_environment_filter_from_link: must reset the new harness dropdown too (invariant 16).app/src/ai/agent_management/view.rs (1876-1892)—filters_wrapinrender_task_list_header: the new dropdown gets added to this wrap row.app/src/ai/agent_management/view.rs (2142-2247)—AgentManagementViewActionenum +handle_action(incl.ClearFilters): newSetHarnessFiltervariant and handler.app/src/ai/agent_management/view.rs (1741-1770)—render_metadata_row: buildsmetadata_parts: Vec<String>joined by•(Source, Run time, Credits used). The card Harness segment is inserted here.app/src/ai/agent_management/telemetry.rs (43-52)—FilterTypeenum: addHarness.app/src/ai/harness_display.rs—display_name,icon_for,brand_coloralready exist (from REMOTE-1455). Reused unchanged.app/src/ai/ambient_agents/task.rs (57-83)—AgentConfigSnapshot.harness: Option<HarnessConfig>. Authoritative source for a task's harness.app/src/app_state.rs (37-59)—PersistedAgentManagementFilterswrapsAgentManagementFiltersvia serde. Adding a new field with#[serde(default)]keeps old persisted state compatible.
Proposed changes
1. HarnessFilter enum + plumbing in AgentManagementFilters
In agent_conversations_model.rs, add:
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
pub enum HarnessFilter {
#[default]
All,
Specific(Harness),
}
Harness is Copy and already Serialize + Deserialize via warp_cli::agent::Harness (used elsewhere). HarnessFilter stays Copy so it threads through actions cheaply.
Extend AgentManagementFilters:
pub struct AgentManagementFilters {
// ...existing fields...
#[serde(default)]
pub harness: HarnessFilter,
}
#[serde(default)] keeps PersistedAgentManagementFilters backwards compatible with existing on-disk state (invariant 11). Update reset_all_but_owner to zero harness and is_filtering to include self.harness != HarnessFilter::default() (invariants 9, 10).
2. Resolve harness on ConversationOrTask
Add on ConversationOrTask:
pub fn harness(&self) -> Option<Harness> {
match self {
ConversationOrTask::Task(task) => task.agent_config_snapshot.as_ref().and_then(|c| {
c.harness
.as_ref()
.map(|h| h.harness_type)
.or(Some(Harness::Oz))
}),
// Local/interactive conversations always run on Warp Agent.
ConversationOrTask::Conversation(_) => Some(Harness::Oz),
}
}
fn matches_harness(&self, f: &HarnessFilter) -> bool {
match f {
HarnessFilter::All => true,
HarnessFilter::Specific(h) => self.harness() == Some(*h),
}
}
Resolution mirrors ConversationDetailsData::from_task from REMOTE-1455 exactly, so the filter and the details panel agree on which harness label belongs to a row:
- snapshot present +
harnessset →Some(harness_type), - snapshot present + no
harnessfield →Some(Harness::Oz)(runtime default for an Oz-managed task), - snapshot not loaded yet (stub) →
None("don't know yet"), - local/interactive conversation →
Some(Harness::Oz).HarnessConfig.harness_typeis already a parsedwarp_cli::agent::Harness, so the resolver no longer needs to re-parse a raw string — unknown values are already collapsed toHarness::Ozby the snapshot deserializer (harness_from_nameinambient_agents/task.rs). PRODUCT invariant 6 bullet 3 follows directly fromharness() == Nonenot matching anyHarnessFilter::Specific(_).HarnessFilter'sDeserializeimpl uses clap'sHarness::from_strto coerce persisted"oz" | "claude" | "gemini" | "all" | <unknown>strings, falling back toHarnessFilter::Allfor unknown values;harness()itself doesn't parse strings. Wirematches_harnessintoget_tasks_and_conversationsas another closure in the.filter(...)chain, alongside the existingsource_filter/status_filter/environment_filter(invariant 8).
3. View: dropdown construction and wiring
In AgentManagementView:
- New field
harness_dropdown: ViewHandle<Dropdown<AgentManagementViewAction>>. - New builder
create_harness_dropdown(ctx)modeled oncreate_status_dropdown:Self::setup_filter_menu(&mut dropdown, "Harness", ctx)for theHarness: <selected>button label (invariant 2).- Items:
All, then one perHarnessvariant in the order listed in PRODUCT invariant 3 (Oz,Claude,Gemini). Each non-Allitem usesMenuItemFields::new(display_name(h)).with_icon(icon_for(h))and, whenbrand_color(h)isSome(c),.with_override_icon_color(Fill::from(c)). Warp Agent renders with the default theme-foreground tint (invariant 4) becausebrand_color(Harness::Oz)isNone. Relies onharness_display::display_name/icon_for/brand_colorand theMenuItemFieldsicon helpers fromapp/src/menu.rs.
sync_with_loaded_filtersadds a block that callsharness_dropdown.set_selected_by_action(SetHarnessFilter(self.filters.harness), ctx), mirroring the existing status/source/created_on/artifact blocks.ClearFiltershandler resets the harness dropdown to its zeroth item (invariant 9).apply_environment_filter_from_linkresets the harness dropdown to its zeroth item (invariant 16);reset_all_but_owneralready clearsfilters.harnessvia (1).render_task_list_header: appendChildView::new(&self.harness_dropdown)to thefilters_wrap(invariant 1). Exact placement: afterartifact_dropdown, beforeenvironment_dropdown, to keep related filters adjacent. Visible under the same conditions as the other dropdowns, no feature-flag gating.
4. Action, telemetry, and refresh
- Add
AgentManagementViewAction::SetHarnessFilter(HarnessFilter). - Add
FilterType::Harnesstoapp/src/ai/agent_management/telemetry.rs(invariant 17).#[serde(rename_all = "snake_case")]is already set on that enum, so the payload string is"harness";warp_core::telemetry::enum_eventspicks up the new variant automatically via the existingEnumDiscriminants/EnumIterderives. handle_actionbranch mirrors the status branch: sendFilterChanged { filter_type: FilterType::Harness }, updateself.filters.harness, callself.on_filter_changed(ctx)(invariant 12).
5. Card metadata row
Extend render_metadata_row in view.rs with an if let Some(h) = card_data.harness() block that pushes format!("Harness: {}", harness_display::display_name(h)) into metadata_parts immediately after the Source push, where h is card_data.harness() from change (2). The if let mirrors the details panel: when harness() returns None (a server-side stub task whose snapshot hasn't been fetched yet), the Harness segment is omitted instead of guessing — matching invariants 20–21. Once the snapshot loads in a follow-up update, the segment appears on the next render. Use the unconditional display_name mapping for the three known variants; no icon, no color override, no click handler (invariant 22).
Ordering: pushed between the Source push and the Run time push so the resulting •-joined string matches Source: … • Harness: … • Run time: … • Credits used: …. If Source is absent, a present Harness segment becomes the leading segment with no leading separator because metadata_parts.join(" • ") skips empty positions by construction (invariant 21).
No new view state, mouse state, or subscription is introduced for the card segment — it is pure data derived from ConversationOrTask::harness() at render time.
6. Scope: client-side only
This change deliberately does not add harness to TaskListFilter / build_task_list_filter / the server query string (app/src/server/server_api/ai.rs (492-650)). Invariant 12 is satisfied because on_filter_changed already re-issues trigger_filter_fetch with the remaining filters and calls get_tasks_from_model, and matches_harness enforces the harness constraint on the client over everything in the model — the server simply returns a possibly-larger superset which the client narrows. If we later want server support, the extension is a new Option<Harness> field on TaskListFilter + a harness= query param; listed under Follow-ups.
Testing and validation
Behavior invariants from PRODUCT.md map as follows:
- Invariants 6, 8, 15 — unit tests in
agent_conversations_modeltests exercisingget_tasks_and_conversationswith fixtures that cover: (a) cloud task withharness_type = Harness::Claude→ matchesClaudeonly, (b) cloud task withagent_config_snapshot = None→ matches onlyAll(harness() == None), (c) cloud task withagent_config_snapshot = Some { harness: None }→ matchesWarp Agentonly, (d) local conversation → matchesWarp Agentonly, (e) combinations of harness + status + owner to lock AND semantics and independence from thePersonal/Alltoggle. Unknownharness_typestrings are not separately tested at this layer becauseHarnessConfig's deserializer collapses them toHarness::Ozbefore they reachConversationOrTask::harness(); that mapping is exercised by thetask.rsdeserializer tests. - Invariants 9, 10, 16 — unit tests in the same module asserting
AgentManagementFilters::is_filtering()returnstruewhen onlyharnessis set, andreset_all_but_owner()zeroesharnessback toHarnessFilter::All. Dropdown re-selection onClearFilters/apply_environment_filter_from_linkis review-only. - Invariant 11 — a serde round-trip test on
AgentManagementFilters: deserializing a JSON object without aharnesskey yieldsHarnessFilter::All(backwards compat with existingPersistedAgentManagementFilters), and serialize+deserialize preserves aSpecific(Claude)value. - Invariants 2, 3, 4, 5 — structural: the dropdown is built from the same ordered array of
Harnessvariants and usesharness_display::display_name/icon_for/brand_color, which are themselves tested inharness_display_tests.rsfrom REMOTE-1455. A smoke snapshot of the dropdown items (labels only) guards against accidental reordering. - Invariant 17 — assertion that
FilterType::Harnessserializes as"harness"(snake_case rename) and that the existing registered telemetry enumeration picks up the new variant viaAgentManagementTelemetryEventDiscriminants::iter(). - Invariants 1, 7, 13, 14, 18 — covered by manual verification, not dedicated tests: structurally,
get_tasks_from_modelis the only consumer that readsself.filters.harness, and the dropdown is placed inside the samefilters_wrapas its siblings so it inherits their wrapping/focus/keybinding behavior. - Invariants 19, 20, 21, 22 — review-only:
render_metadata_rowpushes theHarness: <display_name>segment fromConversationOrTask::harness(), whose resolution is unit-tested. Invariant 23 (per-card harness string aligns with filter selection) follows from both the card and the filter using the sameharness()resolver, already exercised by thematches_harnesstests. - Manual verification —
cargo run, open the management view, and in order: (a) open Harness dropdown → exactly four options in the documented order with brand-tinted icons on the three non-Allrows; (b) pickClaude Code→ list narrows; (c) toggle OwnerPersonal↔All→ harness constraint still applies; (d) pickWarp Agent→ see local conversations and cloud tasks with noharness_typeset; (e) clickClear all→ harness returns toAll; (f) set toGemini CLI, restart Warp → selection is restored; (g) follow a deep-link that invokesapply_environment_filter_from_link→ harness resets toAll. - Pre-PR —
./script/presubmitper repo rules. No WASM-specific paths;warp_cli::agent::Harnessalready compiles for WASM.
Risks and mitigations
- Server returns tasks whose harness does not match the selected filter. Expected under invariant 12 when the server does not yet filter by harness; the client drops them in
get_tasks_and_conversations. Result is a potentially smaller rendered page than the server intended, which is cosmetic. Mitigation: add server-sideharnesstoTaskListFilterwhen available (see Follow-ups). - Persisted filters from older clients carry unknown fields. Guarded by
#[serde(default)]onharnessand serde's default laxness on unknown fields already assumed elsewhere on this struct. - New
Harnessvariants added inwarp_cliwould render only in three-item order.create_harness_dropdowniterates the variants explicitly to control order and icon/color mapping, so adding a variant requires touching the dropdown builder. This matches PRODUCT invariant 3's "adding it is out of scope" stance — mitigation is a future PR that extends the explicit list.
Follow-ups
- Add
harness: Option<Harness>toTaskListFilter+&harness=query param inbuild_list_agent_runs_urlonce the public API supports it, and wire throughbuild_task_list_filter. Reduces client-side filtering overhead but is not required for any invariant. - If a new
Harnessvariant ships, extendcreate_harness_dropdown's ordered variant list in one place.