use correct cloud agent icons for 3p conversation transcripts (#10148)

## Description
<!-- Please remember to add your design buddy onto the PR for review, if
it contains any UI changes! -->

We weren't properly checking/respecting 3p agent harness information for
cloud conversation transcripts when deciding which icon to display in
the conversation list, vertical tabs, and pane header.

## Screenshots / Videos
<!-- Attach screenshots or a short video demonstrating the change, where
appropriate. Remove this section if it is not relevant to your PR. -->


![image.png](https://app.graphite.com/user-attachments/assets/96a893c8-bee1-46f7-b2cf-643276e9c763.png)

## Agent Mode
- [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode
This commit is contained in:
Harry Albert
2026-05-05 15:32:43 -04:00
committed by GitHub
parent 8e837a0ffd
commit 34d311df65
5 changed files with 126 additions and 67 deletions

View File

@@ -607,7 +607,7 @@ impl ConversationOrTask<'_> {
}
/// Resolve the effective execution harness for this run.
pub fn harness(&self) -> Option<Harness> {
pub fn harness(&self, app: &AppContext) -> Option<Harness> {
match self {
ConversationOrTask::Task(task) => {
task.agent_config_snapshot.as_ref().and_then(|config| {
@@ -618,7 +618,10 @@ impl ConversationOrTask<'_> {
.or(Some(Harness::Oz))
})
}
ConversationOrTask::Conversation(_) => Some(Harness::Oz),
ConversationOrTask::Conversation(metadata) => BlocklistAIHistoryModel::as_ref(app)
.get_server_conversation_metadata(&metadata.nav_data.id)
.map(|m| Harness::from(m.harness))
.or(Some(Harness::Oz)),
}
}
@@ -741,10 +744,10 @@ impl ConversationOrTask<'_> {
}
/// Check if this item matches the harness filter.
fn matches_harness(&self, harness_filter: &HarnessFilter) -> bool {
fn matches_harness(&self, harness_filter: &HarnessFilter, app: &AppContext) -> bool {
match harness_filter {
HarnessFilter::All => true,
HarnessFilter::Specific(h) => self.harness() == Some(*h),
HarnessFilter::Specific(h) => self.harness(app) == Some(*h),
}
}
@@ -1572,7 +1575,8 @@ impl AgentConversationsModel {
};
let harness_filter_value = filters.harness;
let harness_filter = move |t: &ConversationOrTask| t.matches_harness(&harness_filter_value);
let harness_filter =
move |t: &ConversationOrTask| t.matches_harness(&harness_filter_value, app);
let tasks_iter = self.tasks.values().map(ConversationOrTask::Task);
let conversations_iter = self

View File

@@ -1837,7 +1837,7 @@ impl AgentManagementView {
}
if FeatureFlag::AgentHarness.is_enabled() {
if let Some(harness) = card_data.harness() {
if let Some(harness) = card_data.harness(app) {
metadata_parts.push(format!(
"Harness: {}",
harness_display::display_name(harness)

View File

@@ -3,7 +3,9 @@
use super::ambient_agent::is_cloud_agent_pre_first_exchange;
use super::shared_session::adapter::Kind as SharedSessionKind;
use super::{Event, PaneConfiguration, TerminalAction, TerminalViewState, Viewer};
use crate::ai::agent::conversation::{AIConversation, ConversationStatus};
use crate::ai::agent::conversation::{
AIConversation, ConversationStatus, ServerAIConversationMetadata,
};
use crate::ai::blocklist::agent_view::agent_view_bg_fill;
use crate::ai::blocklist::agent_view::orchestration_conversation_links::parent_conversation_navigation_card;
use crate::ai::blocklist::agent_view::render_orchestration_breadcrumbs;
@@ -1020,6 +1022,15 @@ impl TerminalView {
})
}
/// Server metadata for the selected conversation, if any.
pub fn selected_conversation_server_metadata<'a>(
&'a self,
ctx: &'a AppContext,
) -> Option<&'a ServerAIConversationMetadata> {
self.selected_conversation_for_user_facing_chrome(ctx)
.and_then(AIConversation::server_metadata)
}
pub fn selected_conversation_latest_user_prompt_for_tab_name(
&self,
ctx: &AppContext,

View File

@@ -13,7 +13,8 @@ use warpui::AppContext;
use warpui::SingletonEntity;
use crate::ai::agent::conversation::ConversationStatus;
use crate::ai::agent_conversations_model::ConversationOrTask;
use crate::ai::agent_conversations_model::{AgentConversationsModel, ConversationOrTask};
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::terminal::cli_agent_sessions::listener::agent_supports_rich_status;
use crate::terminal::cli_agent_sessions::CLIAgentSessionsModel;
use crate::terminal::view::TerminalView;
@@ -24,26 +25,48 @@ use crate::ui_components::icon_with_status::IconWithStatusVariant;
/// not an agent surface (plain terminal / shell / empty conversation).
///
/// Resolution order:
/// 1. A [`CLIAgentSessionsModel`] session with a known agent (observed reality) wins.
/// Plugin-backed sessions surface rich status; command-detected sessions don't.
/// 2. An ambient agent with a selected third-party harness uses the harness's CLI brand
/// even before the harness CLI has started running in the sandbox.
/// 3. A selected conversation or ambient Oz run falls back to the Oz agent variant.
/// 1. A [`CLIAgentSessionsModel`] session with a known agent wins. Plugin-backed sessions
/// surface rich status; command-detected sessions don't.
/// 2. A task-backed run defers to [`conversation_or_task_agent_icon_variant`] so the
/// terminal chrome and the matching conversation list card stay in lockstep.
/// 3. Live ambient pre-dispatch or a selected local conversation falls through to the
/// no-task waterfall.
/// 4. Everything else returns `None` so the caller renders a plain-terminal indicator.
pub(crate) fn terminal_view_agent_icon_variant(
terminal_view: &TerminalView,
app: &AppContext,
) -> Option<IconWithStatusVariant> {
let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id());
// Resolve the ambient task id from [`TerminalView::ambient_agent_task_id_for_details_panel`],
// falling back to the selected conversation's server metadata for restored cloud transcripts.
let ambient_task_id = terminal_view
.ambient_agent_task_id_for_details_panel(app)
.or_else(|| {
terminal_view
.selected_conversation_server_metadata(app)
.and_then(|m| m.ambient_agent_task_id)
});
let task_data = ambient_task_id
.and_then(|task_id| AgentConversationsModel::as_ref(app).get_task_data(&task_id));
// Defer to the card helper when we have task data and no CLI session takes precedence.
if cli_agent_session.is_none() {
if let Some(task) = task_data.as_ref() {
return conversation_or_task_agent_icon_variant(&ConversationOrTask::Task(task), app);
}
}
let is_ambient = terminal_view.is_ambient_agent_session(app) || ambient_task_id.is_some();
let inputs = TerminalIconInputs {
is_ambient: terminal_view.is_ambient_agent_session(app),
is_ambient,
cli_session: cli_agent_session.map(|session| CLISessionInputs {
agent: session.agent,
has_listener: session.listener.is_some(),
status: session.status.to_conversation_status(),
supports_rich_status: agent_supports_rich_status(&session.agent),
}),
ambient_selected_third_party_cli_agent: terminal_view
selected_third_party_cli_agent: terminal_view
.ambient_agent_view_model()
.and_then(|model| model.as_ref(app).selected_third_party_cli_agent()),
selected_conversation_status: terminal_view.selected_conversation_status_for_display(app),
@@ -56,33 +79,30 @@ pub(crate) fn terminal_view_agent_icon_variant(
/// Returns the agent-icon variant for a [`ConversationOrTask`] card row.
///
/// Task rows resolve their harness from [`ConversationOrTask::harness`]; conversation
/// rows have no harness signal and always render as local Oz per the product spec.
/// Both tasks and conversations resolve their harness through [`ConversationOrTask::harness`].
pub(crate) fn conversation_or_task_agent_icon_variant(
src: &ConversationOrTask<'_>,
app: &AppContext,
) -> Option<IconWithStatusVariant> {
let status = src.status(app);
Some(match src {
ConversationOrTask::Task(_) => {
agent_icon_variant_for_task(src.harness().unwrap_or(Harness::Oz), status)
}
ConversationOrTask::Conversation(_) => IconWithStatusVariant::OzAgent {
status: Some(status),
is_ambient: false,
},
})
let harness = src.harness(app).unwrap_or(Harness::Oz);
let is_ambient = match src {
ConversationOrTask::Task(_) => true,
ConversationOrTask::Conversation(metadata) => BlocklistAIHistoryModel::as_ref(app)
.get_server_conversation_metadata(&metadata.nav_data.id)
.is_some_and(|m| m.ambient_agent_task_id.is_some()),
};
Some(agent_icon_variant_for_run(harness, status, is_ambient))
}
/// Primitive inputs to the terminal-view waterfall, gathered once from the live
/// [`TerminalView`] / [`AppContext`]. Keeping the decision logic in terms of these
/// primitives makes it testable without a live app.
/// [`TerminalView`] / [`AppContext`].
struct TerminalIconInputs {
is_ambient: bool,
cli_session: Option<CLISessionInputs>,
/// The CLI agent corresponding to the currently selected cloud harness, when the selection
/// is a third-party (non-Oz) harness. `None` for Oz or when no harness is selected.
ambient_selected_third_party_cli_agent: Option<CLIAgent>,
/// Third-party CLI agent for a live ambient run before task data is available (e.g.
/// Claude pre-dispatch). `None` otherwise; task-derived harnesses are handled upstream.
selected_third_party_cli_agent: Option<CLIAgent>,
/// The conversation status that the terminal view would surface in its status-icon slot.
selected_conversation_status: Option<ConversationStatus>,
/// Whether the terminal view currently has a selected conversation (ambient or local).
@@ -122,13 +142,12 @@ fn agent_icon_variant_from_terminal_inputs(
});
}
// 2. Ambient agent with a selected third-party harness. Render the harness's brand
// circle immediately once the user commits, even before the harness CLI starts
// running in the sandbox. `Unknown` is filtered to avoid rendering an unbranded
// gray circle for a harness this client doesn't recognize.
// 2. Live ambient run with a third-party harness selected, before task data is
// available (e.g. Claude pre-dispatch). `Unknown` is filtered so an unrecognized
// harness doesn't render as an unbranded gray circle.
if inputs.is_ambient {
if let Some(agent) = inputs
.ambient_selected_third_party_cli_agent
.selected_third_party_cli_agent
.filter(|agent| !matches!(agent, CLIAgent::Unknown))
{
return Some(IconWithStatusVariant::CLIAgent {
@@ -150,13 +169,14 @@ fn agent_icon_variant_from_terminal_inputs(
None
}
/// Pure task-card logic: maps a [`Harness`] and the task's current status into an
/// [`IconWithStatusVariant`]. Task cards are always ambient. Falls back to the Oz
/// variant for [`Harness::Oz`] and [`Harness::Unknown`], the latter so a future-server
/// harness this client doesn't recognize doesn't render an unbranded gray circle.
fn agent_icon_variant_for_task(
/// Pure run-card logic: maps a [`Harness`], status, and ambient flag into an
/// [`IconWithStatusVariant`]. Falls back to the Oz variant for [`Harness::Oz`] and
/// [`Harness::Unknown`], the latter so a future-server harness this client doesn't
/// recognize doesn't render an unbranded gray circle.
fn agent_icon_variant_for_run(
harness: Harness,
status: ConversationStatus,
is_ambient: bool,
) -> IconWithStatusVariant {
let cli_agent =
CLIAgent::from_harness(harness).filter(|agent| !matches!(agent, CLIAgent::Unknown));
@@ -164,11 +184,11 @@ fn agent_icon_variant_for_task(
Some(agent) => IconWithStatusVariant::CLIAgent {
agent,
status: Some(status),
is_ambient: true,
is_ambient,
},
None => IconWithStatusVariant::OzAgent {
status: Some(status),
is_ambient: true,
is_ambient,
},
}
}

View File

@@ -4,7 +4,8 @@
//! the same [`IconWithStatusVariant`]. Surfaces today are:
//! - Terminal view (vertical tabs + pane header) via
//! [`super::agent_icon_variant_from_terminal_inputs`]
//! - Task cards (conversation list) via [`super::agent_icon_variant_for_task`]
//! - Run cards (conversation list, agent management view) via
//! [`super::agent_icon_variant_for_run`]
//! - Notification mailbox — exercised in `notifications/item_tests.rs`
//!
//! Adding a new canonical state is a one-enum-variant + one `expected` arm + one `*_inputs`
@@ -12,7 +13,7 @@
use warp_cli::agent::Harness;
use super::{
agent_icon_variant_for_task, agent_icon_variant_from_terminal_inputs, CLISessionInputs,
agent_icon_variant_for_run, agent_icon_variant_from_terminal_inputs, CLISessionInputs,
TerminalIconInputs,
};
use crate::ai::agent::conversation::ConversationStatus;
@@ -71,6 +72,10 @@ enum CanonicalRunState {
CloudClaudePreDispatch,
/// Cloud Claude harness selected, dispatch in flight (status = InProgress, no session).
CloudClaudeInProgress,
/// Viewing a finished cloud Codex transcript whose VM has shut down. No live ambient
/// model exists, so the harness comes from the conversation's server metadata; the icon
/// must still render as cloud Codex.
ViewingCloudCodexTranscript,
/// Local Claude CLI session with a plugin listener (rich status), in-progress.
LocalClaudePluginInProgress,
/// Local Claude CLI session with a plugin listener (rich status), blocked.
@@ -88,6 +93,7 @@ impl CanonicalRunState {
CloudOzInProgress,
CloudClaudePreDispatch,
CloudClaudeInProgress,
ViewingCloudCodexTranscript,
LocalClaudePluginInProgress,
LocalClaudePluginBlocked,
LocalClaudeCommandDetected,
@@ -124,6 +130,12 @@ impl CanonicalRunState {
status: Some(ConversationStatus::InProgress),
is_ambient: true,
}),
ViewingCloudCodexTranscript => Some(AgentIconFields {
is_cli: true,
cli_agent: Some(CLIAgent::Codex),
status: Some(ConversationStatus::Success),
is_ambient: true,
}),
LocalClaudePluginInProgress => Some(AgentIconFields {
is_cli: true,
cli_agent: Some(CLIAgent::Claude),
@@ -154,38 +166,47 @@ impl CanonicalRunState {
PlainTerminal => TerminalIconInputs {
is_ambient: false,
cli_session: None,
ambient_selected_third_party_cli_agent: None,
selected_third_party_cli_agent: None,
selected_conversation_status: None,
has_selected_conversation: false,
},
LocalOzInProgress => TerminalIconInputs {
is_ambient: false,
cli_session: None,
ambient_selected_third_party_cli_agent: None,
selected_third_party_cli_agent: None,
selected_conversation_status: Some(ConversationStatus::InProgress),
has_selected_conversation: true,
},
CloudOzInProgress => TerminalIconInputs {
is_ambient: true,
cli_session: None,
ambient_selected_third_party_cli_agent: None,
selected_third_party_cli_agent: None,
selected_conversation_status: Some(ConversationStatus::InProgress),
has_selected_conversation: false,
},
CloudClaudePreDispatch => TerminalIconInputs {
is_ambient: true,
cli_session: None,
ambient_selected_third_party_cli_agent: Some(CLIAgent::Claude),
selected_third_party_cli_agent: Some(CLIAgent::Claude),
selected_conversation_status: None,
has_selected_conversation: false,
},
CloudClaudeInProgress => TerminalIconInputs {
is_ambient: true,
cli_session: None,
ambient_selected_third_party_cli_agent: Some(CLIAgent::Claude),
selected_third_party_cli_agent: Some(CLIAgent::Claude),
selected_conversation_status: Some(ConversationStatus::InProgress),
has_selected_conversation: false,
},
ViewingCloudCodexTranscript => TerminalIconInputs {
// VM has shut down: the caller resolves these fields from the conversation's
// server metadata, so the waterfall sees the same shape as a live run.
is_ambient: true,
cli_session: None,
selected_third_party_cli_agent: Some(CLIAgent::Codex),
selected_conversation_status: Some(ConversationStatus::Success),
has_selected_conversation: true,
},
LocalClaudePluginInProgress => TerminalIconInputs {
is_ambient: false,
cli_session: Some(CLISessionInputs {
@@ -194,7 +215,7 @@ impl CanonicalRunState {
status: ConversationStatus::InProgress,
supports_rich_status: true,
}),
ambient_selected_third_party_cli_agent: None,
selected_third_party_cli_agent: None,
selected_conversation_status: None,
has_selected_conversation: false,
},
@@ -208,7 +229,7 @@ impl CanonicalRunState {
},
supports_rich_status: true,
}),
ambient_selected_third_party_cli_agent: None,
selected_third_party_cli_agent: None,
selected_conversation_status: None,
has_selected_conversation: false,
},
@@ -220,21 +241,24 @@ impl CanonicalRunState {
status: ConversationStatus::InProgress,
supports_rich_status: false,
}),
ambient_selected_third_party_cli_agent: None,
selected_third_party_cli_agent: None,
selected_conversation_status: None,
has_selected_conversation: false,
},
}
}
/// Task-card inputs for this state, if it can surface as a task card.
/// Run-card inputs for this state, if it can surface as a run card.
/// Cards only exist for cloud/ambient runs; local states return `None`.
fn task_inputs(&self) -> Option<(Harness, ConversationStatus)> {
fn run_inputs(&self) -> Option<(Harness, ConversationStatus, bool)> {
use CanonicalRunState::*;
match self {
CloudOzInProgress => Some((Harness::Oz, ConversationStatus::InProgress)),
CloudOzInProgress => Some((Harness::Oz, ConversationStatus::InProgress, true)),
CloudClaudePreDispatch | CloudClaudeInProgress => {
Some((Harness::Claude, ConversationStatus::InProgress))
Some((Harness::Claude, ConversationStatus::InProgress, true))
}
ViewingCloudCodexTranscript => {
Some((Harness::Codex, ConversationStatus::Success, true))
}
PlainTerminal
| LocalOzInProgress
@@ -260,17 +284,17 @@ fn every_canonical_state_produces_consistent_icon_across_surfaces() {
"terminal surface disagreed for {state:?}"
);
if let Some((harness, status)) = state.task_inputs() {
let task_variant = agent_icon_variant_for_task(harness, status.clone());
let task_actual = AgentIconFields::from_variant(&task_variant);
// Task cards always populate status (they derive it from `ConversationOrTask::status`).
let expected_for_task = expected.clone().map(|mut fields| {
if let Some((harness, status, is_ambient)) = state.run_inputs() {
let run_variant = agent_icon_variant_for_run(harness, status.clone(), is_ambient);
let run_actual = AgentIconFields::from_variant(&run_variant);
// Run cards always populate status (they derive it from `ConversationOrTask::status`).
let expected_for_run = expected.clone().map(|mut fields| {
fields.status = Some(status);
fields
});
assert_eq!(
task_actual, expected_for_task,
"task surface disagreed for {state:?}"
run_actual, expected_for_run,
"run-card surface disagreed for {state:?}"
);
}
}
@@ -312,16 +336,16 @@ fn cli_agent_from_harness_maps_known_harnesses() {
}
#[test]
fn task_with_oz_or_unknown_harness_renders_as_oz() {
fn run_card_with_oz_or_unknown_harness_renders_as_oz() {
// Oz harness explicitly: local Oz is the spec-defined fallback.
let variant = agent_icon_variant_for_task(Harness::Oz, ConversationStatus::Success);
let variant = agent_icon_variant_for_run(Harness::Oz, ConversationStatus::Success, true);
let fields = AgentIconFields::from_variant(&variant).unwrap();
assert!(!fields.is_cli);
assert!(fields.is_ambient);
// Unknown harness (e.g. server surfaced a future variant): also falls back to Oz so we
// don't render an unbranded gray circle.
let variant = agent_icon_variant_for_task(Harness::Unknown, ConversationStatus::Success);
let variant = agent_icon_variant_for_run(Harness::Unknown, ConversationStatus::Success, true);
let fields = AgentIconFields::from_variant(&variant).unwrap();
assert!(!fields.is_cli);
assert!(fields.is_ambient);