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..5703ab8f5 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; @@ -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 { 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 { 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, - /// 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, + /// 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, /// 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, }, } } diff --git a/app/src/ui_components/agent_icon_tests.rs b/app/src/ui_components/agent_icon_tests.rs index f890a669c..263b7e443 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,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);