From 3bca24a9208f3a2a71670f072cbcb92cbc054f90 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 5 May 2026 10:26:57 -0400 Subject: [PATCH 1/2] use correct cloud agent icons for 3p conversation transcripts --- app/src/ai/agent_conversations_model.rs | 14 +-- app/src/ai/agent_management/view.rs | 2 +- app/src/terminal/view/pane_impl.rs | 13 ++- app/src/ui_components/agent_icon.rs | 105 +++++++++++++++------- app/src/ui_components/agent_icon_tests.rs | 74 ++++++++++----- 5 files changed, 144 insertions(+), 64 deletions(-) diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index d102b40a4..77716ab31 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -607,7 +607,7 @@ impl ConversationOrTask<'_> { } /// Resolve the effective execution harness for this run. - pub fn harness(&self) -> Option { + pub fn harness(&self, app: &AppContext) -> Option { 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), } } @@ -1571,7 +1574,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 diff --git a/app/src/ai/agent_management/view.rs b/app/src/ai/agent_management/view.rs index d83e6d82f..e51332921 100644 --- a/app/src/ai/agent_management/view.rs +++ b/app/src/ai/agent_management/view.rs @@ -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) diff --git a/app/src/terminal/view/pane_impl.rs b/app/src/terminal/view/pane_impl.rs index 48b245775..e1c371bcd 100644 --- a/app/src/terminal/view/pane_impl.rs +++ b/app/src/terminal/view/pane_impl.rs @@ -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; @@ -1018,6 +1020,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, diff --git a/app/src/ui_components/agent_icon.rs b/app/src/ui_components/agent_icon.rs index ba3416150..c01809fc6 100644 --- a/app/src/ui_components/agent_icon.rs +++ b/app/src/ui_components/agent_icon.rs @@ -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; @@ -26,8 +27,15 @@ use crate::ui_components::icon_with_status::IconWithStatusVariant; /// 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. +/// 2. An ambient run with a selected third-party harness uses the harness's CLI brand. The +/// harness can come from any of: +/// - the live [`AmbientAgentViewModel`] (so the brand circle shows even before the CLI +/// has started), +/// - the selected conversation's server metadata (live cloud Oz runs whose conversation +/// is loaded), or +/// - the [`AmbientAgentTask`] looked up by the model's transcript task id (transcripts +/// of cloud Claude/Codex/Gemini runs whose VM has shut down — these have no +/// materialized [`AIConversation`] carrying server metadata). /// 3. A selected conversation or ambient Oz run falls back to the Oz agent variant. /// 4. Everything else returns `None` so the caller renders a plain-terminal indicator. pub(crate) fn terminal_view_agent_icon_variant( @@ -35,17 +43,45 @@ pub(crate) fn terminal_view_agent_icon_variant( app: &AppContext, ) -> Option { let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id()); + let conversation_metadata = terminal_view.selected_conversation_server_metadata(app); + let ambient_task_id = terminal_view.ambient_agent_task_id_for_details_panel(app); + let task_harness = ambient_task_id.and_then(|task_id| { + AgentConversationsModel::as_ref(app) + .get_task_data(&task_id) + .and_then(|task| { + task.agent_config_snapshot + .as_ref() + .and_then(|s| s.harness.as_ref()) + .map(|h| h.harness_type) + }) + }); + + // Treat the terminal as ambient if any of the cloud signals are set: the live ambient + // model, the selected conversation's server metadata, or a transcript task id stored + // on the terminal model. The last covers CLI-agent transcripts, which carry no + // [`AmbientAgentViewModel`] and no server-metadata-bearing conversation. + let is_ambient = terminal_view.is_ambient_agent_session(app) + || conversation_metadata.is_some_and(|m| m.ambient_agent_task_id.is_some()) + || ambient_task_id.is_some(); + let selected_third_party_cli_agent = terminal_view + .ambient_agent_view_model() + .and_then(|model| model.as_ref(app).selected_third_party_cli_agent()) + .or_else(|| { + conversation_metadata + .map(|m| Harness::from(m.harness)) + .and_then(CLIAgent::from_harness) + }) + .or_else(|| task_harness.and_then(CLIAgent::from_harness)); + 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 - .ambient_agent_view_model() - .and_then(|model| model.as_ref(app).selected_third_party_cli_agent()), + selected_third_party_cli_agent, selected_conversation_status: terminal_view.selected_conversation_status_for_display(app), has_selected_conversation: terminal_view .selected_conversation_display_title(app) @@ -56,22 +92,20 @@ 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 { 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 @@ -80,9 +114,11 @@ pub(crate) fn conversation_or_task_agent_icon_variant( struct TerminalIconInputs { is_ambient: bool, cli_session: Option, - /// 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, + /// The third-party CLI agent associated with this run, sourced from the live + /// [`AmbientAgentViewModel`], the selected conversation's server metadata, or the + /// [`AmbientAgentTask`] looked up by the model's transcript task id. + /// `None` for Oz or when no harness is known. + selected_third_party_cli_agent: Option, /// The conversation status that the terminal view would surface in its status-icon slot. selected_conversation_status: Option, /// Whether the terminal view currently has a selected conversation (ambient or local). @@ -122,13 +158,15 @@ 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. Ambient run with a known third-party harness. Render the harness's brand circle + // whether the harness came from the live `AmbientAgentViewModel` (live cloud run, + // possibly pre-dispatch), the selected conversation's server metadata, or a task + // lookup keyed by the transcript task id (CLI-agent transcripts whose VM has shut + // down). `Unknown` is filtered so a harness this client doesn't recognize doesn't + // render 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 +188,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 +203,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, }, } } diff --git a/app/src/ui_components/agent_icon_tests.rs b/app/src/ui_components/agent_icon_tests.rs index f890a669c..e803fdac6 100644 --- a/app/src/ui_components/agent_icon_tests.rs +++ b/app/src/ui_components/agent_icon_tests.rs @@ -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,11 @@ 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 CLI session, + /// no live AmbientAgentViewModel harness selection, but the conversation's server metadata + /// reports a Codex harness and an ambient task id. Tabs/headers must still render the + /// Codex brand circle with the cloud-lobe overlay so the transcript reads 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 +94,7 @@ impl CanonicalRunState { CloudOzInProgress, CloudClaudePreDispatch, CloudClaudeInProgress, + ViewingCloudCodexTranscript, LocalClaudePluginInProgress, LocalClaudePluginBlocked, LocalClaudeCommandDetected, @@ -124,6 +131,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 +167,48 @@ 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 live ambient model is gone, so the caller resolves + // `is_ambient` and the third-party CLI agent from the conversation's server + // metadata. The waterfall sees the same shape as a live cloud Codex 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 +217,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 +231,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 +243,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 +286,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 +338,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); From 06bcd1a635a7bfc75240695964c30e4332636a22 Mon Sep 17 00:00:00 2001 From: Harry Date: Tue, 5 May 2026 11:05:00 -0400 Subject: [PATCH 2/2] fix status --- app/src/ui_components/agent_icon.rs | 85 +++++++++-------------- app/src/ui_components/agent_icon_tests.rs | 12 ++-- 2 files changed, 38 insertions(+), 59 deletions(-) diff --git a/app/src/ui_components/agent_icon.rs b/app/src/ui_components/agent_icon.rs index c01809fc6..5703ab8f5 100644 --- a/app/src/ui_components/agent_icon.rs +++ b/app/src/ui_components/agent_icon.rs @@ -25,54 +25,39 @@ 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 run with a selected third-party harness uses the harness's CLI brand. The -/// harness can come from any of: -/// - the live [`AmbientAgentViewModel`] (so the brand circle shows even before the CLI -/// has started), -/// - the selected conversation's server metadata (live cloud Oz runs whose conversation -/// is loaded), or -/// - the [`AmbientAgentTask`] looked up by the model's transcript task id (transcripts -/// of cloud Claude/Codex/Gemini runs whose VM has shut down — these have no -/// materialized [`AIConversation`] carrying server metadata). -/// 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 { let cli_agent_session = CLIAgentSessionsModel::as_ref(app).session(terminal_view.id()); - let conversation_metadata = terminal_view.selected_conversation_server_metadata(app); - let ambient_task_id = terminal_view.ambient_agent_task_id_for_details_panel(app); - let task_harness = ambient_task_id.and_then(|task_id| { - AgentConversationsModel::as_ref(app) - .get_task_data(&task_id) - .and_then(|task| { - task.agent_config_snapshot - .as_ref() - .and_then(|s| s.harness.as_ref()) - .map(|h| h.harness_type) - }) - }); - // Treat the terminal as ambient if any of the cloud signals are set: the live ambient - // model, the selected conversation's server metadata, or a transcript task id stored - // on the terminal model. The last covers CLI-agent transcripts, which carry no - // [`AmbientAgentViewModel`] and no server-metadata-bearing conversation. - let is_ambient = terminal_view.is_ambient_agent_session(app) - || conversation_metadata.is_some_and(|m| m.ambient_agent_task_id.is_some()) - || ambient_task_id.is_some(); - let selected_third_party_cli_agent = terminal_view - .ambient_agent_view_model() - .and_then(|model| model.as_ref(app).selected_third_party_cli_agent()) + // 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(|| { - conversation_metadata - .map(|m| Harness::from(m.harness)) - .and_then(CLIAgent::from_harness) - }) - .or_else(|| task_harness.and_then(CLIAgent::from_harness)); + 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, cli_session: cli_agent_session.map(|session| CLISessionInputs { @@ -81,7 +66,9 @@ pub(crate) fn terminal_view_agent_icon_variant( status: session.status.to_conversation_status(), supports_rich_status: agent_supports_rich_status(&session.agent), }), - selected_third_party_cli_agent, + 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), has_selected_conversation: terminal_view .selected_conversation_display_title(app) @@ -109,15 +96,12 @@ pub(crate) fn conversation_or_task_agent_icon_variant( } /// 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, - /// The third-party CLI agent associated with this run, sourced from the live - /// [`AmbientAgentViewModel`], the selected conversation's server metadata, or the - /// [`AmbientAgentTask`] looked up by the model's transcript task id. - /// `None` for Oz or when no harness is known. + /// 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, /// The conversation status that the terminal view would surface in its status-icon slot. selected_conversation_status: Option, @@ -158,12 +142,9 @@ fn agent_icon_variant_from_terminal_inputs( }); } - // 2. Ambient run with a known third-party harness. Render the harness's brand circle - // whether the harness came from the live `AmbientAgentViewModel` (live cloud run, - // possibly pre-dispatch), the selected conversation's server metadata, or a task - // lookup keyed by the transcript task id (CLI-agent transcripts whose VM has shut - // down). `Unknown` is filtered so a harness this client doesn't recognize doesn't - // render an unbranded gray circle. + // 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 .selected_third_party_cli_agent diff --git a/app/src/ui_components/agent_icon_tests.rs b/app/src/ui_components/agent_icon_tests.rs index e803fdac6..263b7e443 100644 --- a/app/src/ui_components/agent_icon_tests.rs +++ b/app/src/ui_components/agent_icon_tests.rs @@ -72,10 +72,9 @@ 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 CLI session, - /// no live AmbientAgentViewModel harness selection, but the conversation's server metadata - /// reports a Codex harness and an ambient task id. Tabs/headers must still render the - /// Codex brand circle with the cloud-lobe overlay so the transcript reads as cloud Codex. + /// 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, @@ -200,9 +199,8 @@ impl CanonicalRunState { has_selected_conversation: false, }, ViewingCloudCodexTranscript => TerminalIconInputs { - // VM has shut down: the live ambient model is gone, so the caller resolves - // `is_ambient` and the third-party CLI agent from the conversation's server - // metadata. The waterfall sees the same shape as a live cloud Codex run. + // 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),