2004 lines
72 KiB
Rust
2004 lines
72 KiB
Rust
//! A reusable side panel component for displaying conversation metadata.
|
|
|
|
use std::{collections::HashMap, str::FromStr, sync::Arc};
|
|
|
|
use chrono::{DateTime, Duration, Local};
|
|
use instant::Instant;
|
|
use parking_lot::RwLock;
|
|
use pathfinder_color::ColorU;
|
|
use warp_cli::agent::Harness;
|
|
use warp_cli::skill::SkillSpec;
|
|
use warp_core::channel::ChannelState;
|
|
use warp_core::features::FeatureFlag;
|
|
use warp_core::ui::color::coloru_with_opacity;
|
|
use warpui::{
|
|
clipboard::ClipboardContent,
|
|
elements::{
|
|
new_scrollable::{NewScrollable, SingleAxisConfig},
|
|
resizable_state_handle, Border, ChildView, ClippedScrollStateHandle, ConstrainedBox,
|
|
Container, CornerRadius, CrossAxisAlignment, DragBarSide, Empty, Expanded, Flex,
|
|
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Resizable,
|
|
ResizableStateHandle, SelectableArea, SelectionHandle, Shrinkable, Text, Wrap,
|
|
},
|
|
fonts::{Properties, Weight},
|
|
keymap::FixedBinding,
|
|
platform::Cursor,
|
|
ui_components::components::UiComponent,
|
|
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
|
};
|
|
|
|
use crate::ai::agent::api::ServerConversationToken;
|
|
#[cfg(target_family = "wasm")]
|
|
use crate::ai::agent::conversation::AIConversation;
|
|
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
|
|
use crate::ai::agent_conversations_model::AgentRunDisplayStatus;
|
|
use crate::ai::agent_management::details_action_buttons::{
|
|
ActionButtonsConfig, AgentDetailsButtonEvent, ConversationActionButtonsRow,
|
|
};
|
|
use crate::ai::agent_management::telemetry::{AgentManagementTelemetryEvent, OpenedFrom};
|
|
use crate::ai::ambient_agents::{cancel_task_with_toast, AmbientAgentTaskId};
|
|
use crate::ai::artifacts::{Artifact, ArtifactButtonsRow, ArtifactButtonsRowEvent};
|
|
use crate::ai::blocklist::BlocklistAIHistoryModel;
|
|
use crate::ai::cloud_environments::{AmbientAgentEnvironment, CloudAmbientAgentEnvironment};
|
|
use crate::ai::harness_display;
|
|
use crate::appearance::Appearance;
|
|
#[cfg(target_family = "wasm")]
|
|
use crate::auth::UserUid;
|
|
use crate::notebooks::NotebookId;
|
|
use crate::send_telemetry_from_ctx;
|
|
use crate::server::ids::{ServerId, SyncId};
|
|
use crate::server::server_api::ai::AmbientAgentTask;
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::settings::ai::{AISettings, AISettingsChangedEvent};
|
|
use crate::ui_components::avatar::{Avatar, AvatarContent};
|
|
use crate::ui_components::blended_colors;
|
|
use crate::ui_components::buttons::icon_button;
|
|
use crate::ui_components::icons::Icon;
|
|
use crate::util::bindings::CustomAction;
|
|
use crate::util::time_format::{format_approx_duration_from_now, human_readable_precise_duration};
|
|
#[cfg(not(target_family = "wasm"))]
|
|
use crate::view_components::action_button::PrimaryTheme;
|
|
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
|
|
use crate::view_components::copyable_text_field::{
|
|
render_copyable_text_field, CopyableTextFieldConfig, COPY_FEEDBACK_DURATION,
|
|
};
|
|
use crate::view_components::DismissibleToast;
|
|
use crate::workspace::{ForkedConversationDestination, ToastStack, WorkspaceAction};
|
|
#[cfg(target_family = "wasm")]
|
|
use crate::workspaces::user_profiles::UserProfiles;
|
|
|
|
const FIELD_SPACING: f32 = 16.0;
|
|
const HEADER_SPACING: f32 = 12.0;
|
|
const STATUS_ICON_SIZE: f32 = 12.0;
|
|
const LABEL_VALUE_GAP: f32 = 4.0;
|
|
const SECTION_HEADER_GAP: f32 = 8.0;
|
|
|
|
/// Panel rendering mode.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
enum PanelMode {
|
|
Conversation {
|
|
/// Working directory where the conversation took place.
|
|
directory: Option<String>,
|
|
/// Unique identifier for the conversation (server token).
|
|
server_conversation_id: Option<String>,
|
|
/// Internal conversation ID (for action buttons).
|
|
ai_conversation_id: Option<AIConversationId>,
|
|
/// Status of the conversation.
|
|
status: Option<ConversationStatus>,
|
|
},
|
|
Task {
|
|
/// Unique identifier for the task.
|
|
task_id: Option<AmbientAgentTaskId>,
|
|
/// Working directory from the linked conversation, if available.
|
|
directory: Option<String>,
|
|
/// User-visible status derived from task and conversation state.
|
|
display_status: Option<AgentRunDisplayStatus>,
|
|
/// Error message, if we have one.
|
|
error_message: Option<String>,
|
|
/// Environment ID.
|
|
environment_id: Option<String>,
|
|
/// Server conversation ID (for copy link).
|
|
conversation_id: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl Default for PanelMode {
|
|
fn default() -> Self {
|
|
PanelMode::Conversation {
|
|
directory: None,
|
|
server_conversation_id: None,
|
|
ai_conversation_id: None,
|
|
status: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Groups mouse state handles for the panel.
|
|
#[derive(Default)]
|
|
struct PanelMouseStates {
|
|
close_button: MouseStateHandle,
|
|
copy_directory: MouseStateHandle,
|
|
copy_conversation_id: MouseStateHandle,
|
|
copy_run_id: MouseStateHandle,
|
|
copy_environment_id: MouseStateHandle,
|
|
copy_docker_image: MouseStateHandle,
|
|
copy_error: MouseStateHandle,
|
|
copy_setup_commands: MouseStateHandle,
|
|
inference_info_tooltip: MouseStateHandle,
|
|
compute_info_tooltip: MouseStateHandle,
|
|
skill_link: MouseStateHandle,
|
|
skill_source_link: MouseStateHandle,
|
|
}
|
|
|
|
/// Tracks which copy button action was last triggered (for checkmark feedback).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
enum CopyButtonKind {
|
|
Directory,
|
|
ConversationId,
|
|
RunId,
|
|
EnvironmentId,
|
|
DockerImage,
|
|
Error,
|
|
SetupCommands,
|
|
}
|
|
|
|
/// Information about the creator of a conversation.
|
|
#[derive(Debug, Clone)]
|
|
struct CreatorInfo {
|
|
/// Display name of the creator (or fallback identifier).
|
|
pub display_name: String,
|
|
/// Optional photo URL for the avatar.
|
|
pub photo_url: Option<String>,
|
|
}
|
|
|
|
impl CreatorInfo {
|
|
/// Create a new CreatorInfo with a display name and optional photo URL.
|
|
pub fn new(display_name: String, photo_url: Option<String>) -> Self {
|
|
Self {
|
|
display_name,
|
|
photo_url,
|
|
}
|
|
}
|
|
|
|
/// Create a CreatorInfo with just the first character as a fallback.
|
|
#[cfg(target_family = "wasm")]
|
|
pub fn from_uid_fallback(uid: &str) -> Self {
|
|
let first_char = uid.chars().next().unwrap_or('?').to_uppercase().to_string();
|
|
Self::new(first_char, None)
|
|
}
|
|
}
|
|
|
|
/// Credit usage information for a conversation or task.
|
|
#[derive(Debug, Clone)]
|
|
enum CreditsInfo {
|
|
LocalConversation(f32),
|
|
AmbientConversation { inference: f32, compute: f32 },
|
|
}
|
|
|
|
/// Data model for the conversation details panel.
|
|
/// Any field that is left as None will not be rendered.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ConversationDetailsData {
|
|
mode: PanelMode,
|
|
title: String,
|
|
/// Information about the creator.
|
|
creator: Option<CreatorInfo>,
|
|
/// When the conversation was created.
|
|
created_at: Option<DateTime<Local>>,
|
|
credits: Option<CreditsInfo>,
|
|
/// Total duration of the conversation.
|
|
run_time: Option<Duration>,
|
|
/// Artifacts created during the conversation (plans, PRs, branches).
|
|
artifacts: Vec<Artifact>,
|
|
/// Action to dispatch when "Open" button is clicked.
|
|
open_action: Option<WorkspaceAction>,
|
|
/// Source prompt that initiated this conversation/task.
|
|
source_prompt: Option<String>,
|
|
/// Copy link URL (session link if sandbox running, otherwise conversation link).
|
|
copy_link_url: Option<String>,
|
|
/// Parsed skill spec referenced by the task configuration.
|
|
skill_spec: Option<SkillSpec>,
|
|
/// Execution harness for this conversation/task.
|
|
harness: Option<Harness>,
|
|
}
|
|
|
|
impl ConversationDetailsData {
|
|
fn directory_for_task(task: &AmbientAgentTask, app: &AppContext) -> Option<String> {
|
|
let history_model = BlocklistAIHistoryModel::as_ref(app);
|
|
let conversation_id = history_model
|
|
.conversation_id_for_agent_id(&task.task_id.to_string())
|
|
.or_else(|| {
|
|
task.conversation_id.as_ref().and_then(|conversation_id| {
|
|
history_model.find_conversation_id_by_server_token(
|
|
&ServerConversationToken::new(conversation_id.clone()),
|
|
)
|
|
})
|
|
})?;
|
|
|
|
history_model
|
|
.conversation(&conversation_id)
|
|
.and_then(|conversation| conversation.initial_working_directory())
|
|
.or_else(|| {
|
|
history_model
|
|
.get_conversation_metadata(&conversation_id)
|
|
.and_then(|metadata| metadata.initial_working_directory.clone())
|
|
})
|
|
}
|
|
#[cfg(target_family = "wasm")]
|
|
pub fn from_conversation(conversation: &AIConversation, app: &AppContext) -> Self {
|
|
let mut directory = None;
|
|
let mut conversation_id = None;
|
|
|
|
// Server metadata (creator, timestamps)
|
|
let mut creator = None;
|
|
if let Some(server_metadata) = conversation.server_metadata() {
|
|
if let Some(creator_uid_str) = &server_metadata.metadata.creator_uid {
|
|
let creator_uid = UserUid::new(creator_uid_str);
|
|
let user_profiles = UserProfiles::handle(app).as_ref(app);
|
|
|
|
if let Some(profile) = user_profiles.profile_for_uid(creator_uid) {
|
|
let display_name = profile.displayable_identifier();
|
|
let photo_url = Some(profile.photo_url.clone()).filter(|url| !url.is_empty());
|
|
creator = Some(CreatorInfo::new(display_name, photo_url));
|
|
} else {
|
|
// Fallback to first character of UID
|
|
creator = Some(CreatorInfo::from_uid_fallback(creator_uid_str));
|
|
}
|
|
}
|
|
|
|
// Conversation ID (from server token)
|
|
conversation_id = Some(
|
|
server_metadata
|
|
.server_conversation_token
|
|
.as_str()
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
// Calculate run time from exchanges
|
|
let first_exchange = conversation.first_exchange();
|
|
let last_exchange = conversation.latest_exchange();
|
|
let mut run_time = None;
|
|
let mut created_at = None;
|
|
if let (Some(first), Some(last)) = (first_exchange, last_exchange) {
|
|
if let Some(finish_time) = last.finish_time {
|
|
let duration = finish_time.signed_duration_since(first.start_time);
|
|
if duration.num_seconds() >= 0 {
|
|
run_time = Some(duration);
|
|
}
|
|
}
|
|
// Created at from first exchange
|
|
created_at = Some(first.start_time);
|
|
}
|
|
|
|
// Working directory from first exchange
|
|
if let Some(first_exchange) = first_exchange {
|
|
directory = first_exchange.working_directory.clone();
|
|
}
|
|
|
|
let copy_link_url = conversation_id
|
|
.as_ref()
|
|
.map(|id| ServerConversationToken::new(id.clone()).conversation_link());
|
|
|
|
let harness = conversation
|
|
.server_metadata()
|
|
.map(|m| Harness::from(m.harness))
|
|
.or(Some(Harness::Oz));
|
|
|
|
ConversationDetailsData {
|
|
mode: PanelMode::Conversation {
|
|
directory,
|
|
server_conversation_id: conversation_id,
|
|
ai_conversation_id: None,
|
|
status: Some(conversation.status().clone()),
|
|
},
|
|
title: conversation
|
|
.title()
|
|
.unwrap_or_else(|| "Conversation".to_string()),
|
|
creator,
|
|
created_at,
|
|
credits: Some(CreditsInfo::LocalConversation(conversation.credits_spent())),
|
|
run_time,
|
|
artifacts: conversation.artifacts().to_vec(),
|
|
open_action: None,
|
|
source_prompt: conversation.initial_query(),
|
|
copy_link_url,
|
|
skill_spec: None,
|
|
harness,
|
|
}
|
|
}
|
|
|
|
pub fn from_task(
|
|
task: &AmbientAgentTask,
|
|
open_action: Option<WorkspaceAction>,
|
|
copy_link_url: Option<String>,
|
|
app: &AppContext,
|
|
) -> Self {
|
|
let error_message = if task.state.is_failure_like() {
|
|
task.status_message.as_ref().map(|m| m.message.clone())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let environment_id = task
|
|
.agent_config_snapshot
|
|
.as_ref()
|
|
.and_then(|config| config.environment_id.clone());
|
|
|
|
let credits = task.request_usage.as_ref().and_then(|u| {
|
|
Some(CreditsInfo::AmbientConversation {
|
|
inference: u.inference_cost? as f32,
|
|
compute: u.compute_cost? as f32,
|
|
})
|
|
});
|
|
|
|
let skill_spec = task
|
|
.agent_config_snapshot
|
|
.as_ref()
|
|
.and_then(|config| config.skill_spec.as_ref())
|
|
.and_then(|spec_str| SkillSpec::from_str(spec_str).ok());
|
|
|
|
let harness = task.agent_config_snapshot.as_ref().and_then(|config| {
|
|
config
|
|
.harness
|
|
.as_ref()
|
|
.map(|h| h.harness_type)
|
|
.or(Some(Harness::Oz))
|
|
});
|
|
|
|
ConversationDetailsData {
|
|
mode: PanelMode::Task {
|
|
task_id: Some(task.task_id),
|
|
directory: Self::directory_for_task(task, app),
|
|
display_status: Some(AgentRunDisplayStatus::from_task(task, app)),
|
|
error_message,
|
|
environment_id,
|
|
conversation_id: task.conversation_id.clone(),
|
|
},
|
|
title: task.title.clone(),
|
|
created_at: Some(task.created_at.with_timezone(&Local)),
|
|
artifacts: task.artifacts.clone(),
|
|
credits,
|
|
run_time: task.run_time(),
|
|
open_action,
|
|
creator: task
|
|
.creator_display_name()
|
|
.map(|name| CreatorInfo::new(name, None)),
|
|
source_prompt: Some(task.prompt.clone()),
|
|
copy_link_url,
|
|
skill_spec,
|
|
harness,
|
|
}
|
|
}
|
|
|
|
/// Minimal details data for when we only know the task id (e.g. shared sessions)
|
|
/// but have not loaded the full `AmbientAgentTask` yet.
|
|
pub fn from_task_id(task_id: AmbientAgentTaskId) -> Self {
|
|
ConversationDetailsData {
|
|
mode: PanelMode::Task {
|
|
task_id: Some(task_id),
|
|
directory: None,
|
|
display_status: None,
|
|
error_message: None,
|
|
environment_id: None,
|
|
conversation_id: None,
|
|
},
|
|
title: "Cloud agent run".to_string(),
|
|
creator: None,
|
|
created_at: None,
|
|
credits: None,
|
|
run_time: None,
|
|
artifacts: vec![],
|
|
open_action: None,
|
|
source_prompt: None,
|
|
copy_link_url: None,
|
|
skill_spec: None,
|
|
harness: None,
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
/// Used to populate the details panel from the management view, where we don't always have access
|
|
/// to the full `AIConversation`.
|
|
pub fn from_conversation_metadata(
|
|
ai_conversation_id: AIConversationId,
|
|
title: String,
|
|
creator_name: Option<String>,
|
|
created_at: DateTime<Local>,
|
|
directory: Option<String>,
|
|
credits_used: Option<f32>,
|
|
conversation_id: Option<String>,
|
|
artifacts: Vec<Artifact>,
|
|
open_action: Option<WorkspaceAction>,
|
|
status: Option<ConversationStatus>,
|
|
initial_query: Option<String>,
|
|
copy_link_url: Option<String>,
|
|
harness: Option<Harness>,
|
|
) -> Self {
|
|
ConversationDetailsData {
|
|
mode: PanelMode::Conversation {
|
|
directory,
|
|
server_conversation_id: conversation_id,
|
|
ai_conversation_id: Some(ai_conversation_id),
|
|
status,
|
|
},
|
|
title,
|
|
creator: creator_name.map(|name| CreatorInfo::new(name, None)),
|
|
created_at: Some(created_at),
|
|
credits: credits_used.map(CreditsInfo::LocalConversation),
|
|
run_time: None,
|
|
open_action,
|
|
artifacts,
|
|
source_prompt: initial_query,
|
|
copy_link_url,
|
|
skill_spec: None,
|
|
harness,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Events emitted by the ConversationDetailsPanel.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ConversationDetailsPanelEvent {
|
|
Close,
|
|
OpenPlanNotebook { notebook_uid: NotebookId },
|
|
}
|
|
|
|
/// Actions for the ConversationDetailsPanel.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ConversationDetailsPanelAction {
|
|
Close,
|
|
CopyDirectory,
|
|
CopyConversationId,
|
|
CopyRunId,
|
|
CopyEnvironmentId,
|
|
CopyDockerImage,
|
|
CopyError,
|
|
CopySetupCommands(String),
|
|
Focus,
|
|
CopySelectedText,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
ContinueLocally,
|
|
OpenInOz,
|
|
}
|
|
|
|
pub fn init(app: &mut AppContext) {
|
|
use warpui::keymap::macros::*;
|
|
|
|
app.register_fixed_bindings([FixedBinding::custom(
|
|
CustomAction::Copy,
|
|
ConversationDetailsPanelAction::CopySelectedText,
|
|
"Copy",
|
|
id!(ConversationDetailsPanel::ui_name()) & !id!("IMEOpen"),
|
|
)]);
|
|
}
|
|
|
|
/// A reusable panel for displaying conversation details and metadata.
|
|
pub struct ConversationDetailsPanel {
|
|
data: ConversationDetailsData,
|
|
mouse_states: PanelMouseStates,
|
|
artifact_buttons_row: ViewHandle<ArtifactButtonsRow>,
|
|
resizable_state_handle: ResizableStateHandle,
|
|
scroll_state: ClippedScrollStateHandle,
|
|
action_buttons: ViewHandle<ConversationActionButtonsRow>,
|
|
/// Whether to show the "Open conversation" button (we don't want to show a navigate to
|
|
/// conversation button in the transcript view, but do in the management details view).
|
|
show_open_button: bool,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
continue_locally_button: ViewHandle<ActionButton>,
|
|
/// Text button "View in Oz" shown next to "Continue locally".
|
|
open_in_oz_button: ViewHandle<ActionButton>,
|
|
/// Tracks when each copy button was last clicked (for checkmark feedback).
|
|
copy_feedback_times: HashMap<CopyButtonKind, Instant>,
|
|
/// Selection state for cmd+C copy.
|
|
selection_handle: SelectionHandle,
|
|
selected_text: Arc<RwLock<Option<String>>>,
|
|
}
|
|
|
|
impl ConversationDetailsPanel {
|
|
/// Create a new panel.
|
|
/// - `show_open_button`: whether to show the "Open" button (management view: true, transcript: false)
|
|
/// - `initial_width`: starting width of the panel in pixels
|
|
pub fn new(show_open_button: bool, initial_width: f32, ctx: &mut ViewContext<Self>) -> Self {
|
|
let artifact_buttons_row =
|
|
ctx.add_typed_action_view(|ctx| ArtifactButtonsRow::new(&[], ctx));
|
|
ctx.subscribe_to_view(&artifact_buttons_row, |this, _, event, ctx| {
|
|
this.handle_artifact_buttons_event(event, ctx)
|
|
});
|
|
|
|
let action_buttons = ctx.add_typed_action_view(ConversationActionButtonsRow::new);
|
|
ctx.subscribe_to_view(&action_buttons, Self::handle_action_buttons_event);
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let continue_locally_button = ctx.add_typed_action_view(|_| {
|
|
ActionButton::new("Continue locally", PrimaryTheme)
|
|
.with_tooltip("Fork this conversation locally")
|
|
.with_size(ButtonSize::Small)
|
|
.on_click(|ctx| {
|
|
ctx.dispatch_typed_action(ConversationDetailsPanelAction::ContinueLocally);
|
|
})
|
|
});
|
|
let open_in_oz_button = ctx.add_typed_action_view(|_| {
|
|
ActionButton::new("View in Oz", SecondaryTheme)
|
|
.with_tooltip("View this run in the Oz web app")
|
|
.with_size(ButtonSize::Small)
|
|
.on_click(|ctx| {
|
|
ctx.dispatch_typed_action(ConversationDetailsPanelAction::OpenInOz);
|
|
})
|
|
});
|
|
#[cfg(not(target_family = "wasm"))]
|
|
ctx.subscribe_to_model(&AISettings::handle(ctx), |_, _, event, ctx| {
|
|
if matches!(event, AISettingsChangedEvent::IsAnyAIEnabled { .. }) {
|
|
ctx.notify();
|
|
}
|
|
});
|
|
|
|
Self {
|
|
data: ConversationDetailsData::default(),
|
|
mouse_states: PanelMouseStates::default(),
|
|
artifact_buttons_row,
|
|
action_buttons,
|
|
show_open_button,
|
|
#[cfg(not(target_family = "wasm"))]
|
|
continue_locally_button,
|
|
open_in_oz_button,
|
|
resizable_state_handle: resizable_state_handle(initial_width),
|
|
scroll_state: ClippedScrollStateHandle::default(),
|
|
copy_feedback_times: HashMap::new(),
|
|
selection_handle: SelectionHandle::default(),
|
|
selected_text: Default::default(),
|
|
}
|
|
}
|
|
|
|
pub fn set_conversation_details(
|
|
&mut self,
|
|
data: ConversationDetailsData,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
self.set_artifacts(&data, ctx);
|
|
self.set_action_buttons(&data, ctx);
|
|
self.data = data;
|
|
ctx.notify();
|
|
}
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
fn continue_locally_conversation_id(&self, app: &AppContext) -> Option<AIConversationId> {
|
|
if !AISettings::as_ref(app).is_any_ai_enabled(app) {
|
|
return None;
|
|
}
|
|
|
|
match &self.data.mode {
|
|
PanelMode::Conversation {
|
|
ai_conversation_id,
|
|
status,
|
|
..
|
|
} => {
|
|
let status = status.as_ref()?;
|
|
if status.is_in_progress() {
|
|
return None;
|
|
}
|
|
Some(*ai_conversation_id.as_ref()?)
|
|
}
|
|
PanelMode::Task {
|
|
display_status,
|
|
conversation_id,
|
|
..
|
|
} => {
|
|
let status = display_status.as_ref()?;
|
|
if status.is_working() {
|
|
return None;
|
|
}
|
|
// Hide for non-Oz harnesses (e.g. Claude, Gemini): they can't be
|
|
// forked into a local Warp conversation.
|
|
if matches!(self.data.harness, Some(h) if h != Harness::Oz) {
|
|
return None;
|
|
}
|
|
|
|
let server_token = ServerConversationToken::new(conversation_id.as_ref()?.clone());
|
|
BlocklistAIHistoryModel::as_ref(app)
|
|
.find_conversation_id_by_server_token(&server_token)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_artifacts(&mut self, data: &ConversationDetailsData, ctx: &mut ViewContext<Self>) {
|
|
self.artifact_buttons_row.update(ctx, |view, ctx| {
|
|
view.update_artifacts(&data.artifacts, ctx);
|
|
});
|
|
}
|
|
|
|
fn handle_artifact_buttons_event(
|
|
&mut self,
|
|
event: &ArtifactButtonsRowEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
ArtifactButtonsRowEvent::OpenPlan { notebook_uid } => {
|
|
ctx.emit(ConversationDetailsPanelEvent::OpenPlanNotebook {
|
|
notebook_uid: *notebook_uid,
|
|
});
|
|
}
|
|
ArtifactButtonsRowEvent::CopyBranch { branch } => {
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(branch.clone()));
|
|
|
|
let window_id = ctx.window_id();
|
|
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
|
|
let toast = DismissibleToast::default("Copied branch name".to_string());
|
|
toast_stack.add_ephemeral_toast(toast, window_id, ctx);
|
|
});
|
|
}
|
|
ArtifactButtonsRowEvent::OpenPullRequest { url } => {
|
|
ctx.open_url(url);
|
|
}
|
|
ArtifactButtonsRowEvent::ViewScreenshots { artifact_uids } => {
|
|
crate::ai::artifacts::open_screenshot_lightbox(artifact_uids, ctx);
|
|
}
|
|
ArtifactButtonsRowEvent::DownloadFile { artifact_uid } => {
|
|
crate::ai::artifacts::download_file_artifact(artifact_uid, ctx);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds the Oz web UI URL for a task, if a task_id is available.
|
|
fn oz_run_url(data: &ConversationDetailsData) -> Option<String> {
|
|
if let PanelMode::Task {
|
|
task_id: Some(task_id),
|
|
..
|
|
} = &data.mode
|
|
{
|
|
let oz_root_url = ChannelState::oz_root_url();
|
|
Some(format!("{oz_root_url}/runs/{task_id}"))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn action_buttons_config_from_data(
|
|
&self,
|
|
data: &ConversationDetailsData,
|
|
) -> Option<ActionButtonsConfig> {
|
|
let open_action = self
|
|
.show_open_button
|
|
.then(|| data.open_action.clone())
|
|
.flatten();
|
|
match &data.mode {
|
|
PanelMode::Task {
|
|
task_id,
|
|
display_status,
|
|
..
|
|
} => {
|
|
let task_id = *task_id.as_ref()?;
|
|
let display_status = display_status.as_ref()?;
|
|
Some(ActionButtonsConfig::for_task(
|
|
task_id,
|
|
display_status,
|
|
open_action,
|
|
data.copy_link_url.clone(),
|
|
))
|
|
}
|
|
PanelMode::Conversation {
|
|
ai_conversation_id, ..
|
|
} => {
|
|
let conversation_id = *ai_conversation_id.as_ref()?;
|
|
Some(ActionButtonsConfig::for_conversation(
|
|
conversation_id,
|
|
open_action,
|
|
data.copy_link_url.clone(),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn set_action_buttons(&mut self, data: &ConversationDetailsData, ctx: &mut ViewContext<Self>) {
|
|
let config = self
|
|
.action_buttons_config_from_data(data)
|
|
.unwrap_or_default();
|
|
self.action_buttons
|
|
.update(ctx, |row, ctx| row.set_config(config, ctx));
|
|
}
|
|
|
|
fn handle_action_buttons_event(
|
|
&mut self,
|
|
_: ViewHandle<ConversationActionButtonsRow>,
|
|
event: &AgentDetailsButtonEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
AgentDetailsButtonEvent::Open => {
|
|
// Send telemetry based on panel mode
|
|
match &self.data.mode {
|
|
PanelMode::Conversation {
|
|
ai_conversation_id: Some(conversation_id),
|
|
..
|
|
} => {
|
|
send_telemetry_from_ctx!(
|
|
AgentManagementTelemetryEvent::ConversationOpened {
|
|
conversation_id: conversation_id.to_string(),
|
|
opened_from: OpenedFrom::DetailsPanel,
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
PanelMode::Task {
|
|
task_id: Some(task_id),
|
|
..
|
|
} => {
|
|
send_telemetry_from_ctx!(
|
|
AgentManagementTelemetryEvent::CloudRunOpened {
|
|
task_id: task_id.to_string(),
|
|
opened_from: OpenedFrom::DetailsPanel,
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if let Some(action) = &self.data.open_action {
|
|
ctx.dispatch_typed_action(action);
|
|
}
|
|
}
|
|
AgentDetailsButtonEvent::CancelTask { task_id } => {
|
|
send_telemetry_from_ctx!(
|
|
AgentManagementTelemetryEvent::CloudRunCancelled {
|
|
task_id: task_id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
|
|
cancel_task_with_toast(*task_id, ctx);
|
|
}
|
|
AgentDetailsButtonEvent::ForkConversation { conversation_id } => {
|
|
send_telemetry_from_ctx!(
|
|
AgentManagementTelemetryEvent::ConversationForked {
|
|
conversation_id: conversation_id.to_string(),
|
|
},
|
|
ctx
|
|
);
|
|
|
|
ctx.dispatch_typed_action(&WorkspaceAction::ForkAIConversation {
|
|
conversation_id: *conversation_id,
|
|
fork_from_exchange: None,
|
|
summarize_after_fork: false,
|
|
summarization_prompt: None,
|
|
initial_prompt: None,
|
|
destination: ForkedConversationDestination::NewTab,
|
|
});
|
|
}
|
|
AgentDetailsButtonEvent::ViewDetails { .. } => {
|
|
// ViewDetails not shown in the details panel because we're already viewing it,
|
|
// only in management view cards
|
|
}
|
|
AgentDetailsButtonEvent::CopyLink { link } => {
|
|
match &self.data.mode {
|
|
PanelMode::Conversation {
|
|
ai_conversation_id: Some(conversation_id),
|
|
..
|
|
} => {
|
|
send_telemetry_from_ctx!(
|
|
AgentManagementTelemetryEvent::ConversationLinkCopied {
|
|
conversation_id: conversation_id.to_string(),
|
|
copied_from: OpenedFrom::DetailsPanel,
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
PanelMode::Task {
|
|
task_id: Some(task_id),
|
|
..
|
|
} => {
|
|
send_telemetry_from_ctx!(
|
|
AgentManagementTelemetryEvent::SessionLinkCopied {
|
|
task_id: task_id.to_string(),
|
|
copied_from: OpenedFrom::DetailsPanel,
|
|
},
|
|
ctx
|
|
);
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(link.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_creator_section(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
|
let creator = self.data.creator.as_ref()?;
|
|
let created_at = self.data.created_at?;
|
|
let theme = appearance.theme();
|
|
|
|
let ui_font_size = appearance.ui_font_size();
|
|
let small_font_size = ui_font_size - 2.;
|
|
|
|
let avatar_content = creator
|
|
.photo_url
|
|
.as_ref()
|
|
.map(|url| AvatarContent::Image {
|
|
url: url.clone(),
|
|
display_name: creator.display_name.clone(),
|
|
})
|
|
.unwrap_or_else(|| AvatarContent::DisplayName(creator.display_name.clone()));
|
|
let avatar = Avatar::new(
|
|
avatar_content,
|
|
warpui::ui_components::components::UiComponentStyles {
|
|
width: Some(20.),
|
|
height: Some(20.),
|
|
border_radius: Some(warpui::elements::CornerRadius::with_all(
|
|
warpui::elements::Radius::Percentage(50.),
|
|
)),
|
|
background: Some(blended_colors::accent(theme).into()),
|
|
font_color: Some(ColorU::black()),
|
|
font_family_id: Some(appearance.ui_font_family()),
|
|
font_weight: Some(warpui::fonts::Weight::Bold),
|
|
font_size: Some(small_font_size),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.build()
|
|
.finish();
|
|
|
|
let created_text = Text::new(
|
|
format!(
|
|
"Created by {} • {}",
|
|
creator.display_name,
|
|
format_approx_duration_from_now(created_at)
|
|
),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.with_selectable(true)
|
|
.finish();
|
|
|
|
Some(
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(
|
|
Container::new(avatar)
|
|
.with_margin_right(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(Expanded::new(1., created_text).finish())
|
|
.finish(),
|
|
)
|
|
}
|
|
|
|
fn render_error_field(
|
|
&self,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Option<Box<dyn Element>> {
|
|
let error_message = match &self.data.mode {
|
|
PanelMode::Task { error_message, .. } => error_message.as_ref()?,
|
|
_ => return None,
|
|
};
|
|
let theme = appearance.theme();
|
|
let ui_font_size = appearance.ui_font_size();
|
|
|
|
let label_text = Text::new(
|
|
"Error".to_string(),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.finish();
|
|
|
|
let value_field = render_copyable_text_field(
|
|
CopyableTextFieldConfig::new(error_message.clone())
|
|
.with_font_size(ui_font_size)
|
|
.with_text_color(theme.ansi_fg_red())
|
|
.with_wrap_text(true)
|
|
.with_icon_size(16.)
|
|
.with_mouse_state(self.mouse_state_for_copy_button(CopyButtonKind::Error))
|
|
.with_last_copied_at(self.copy_feedback_times.get(&CopyButtonKind::Error)),
|
|
|ctx| {
|
|
ctx.dispatch_typed_action(ConversationDetailsPanelAction::CopyError);
|
|
},
|
|
app,
|
|
);
|
|
|
|
Some(
|
|
Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Container::new(label_text)
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(value_field)
|
|
.finish(),
|
|
)
|
|
}
|
|
|
|
fn render_status_section(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
|
let theme = appearance.theme();
|
|
let ui_font_size = appearance.ui_font_size();
|
|
|
|
// Section header
|
|
let header = Text::new(
|
|
"Status".to_string(),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.with_style(Properties::default().weight(Weight::Semibold))
|
|
.finish();
|
|
|
|
let (icon, color, display_text): (Icon, _, String) = match &self.data.mode {
|
|
PanelMode::Task { display_status, .. } => {
|
|
let status = display_status.as_ref()?;
|
|
let (icon, color) = status.status_icon_and_color(theme);
|
|
(icon, color, status.to_string())
|
|
}
|
|
PanelMode::Conversation { status, .. } => {
|
|
let status = status.as_ref()?;
|
|
let (icon, color) = status.status_icon_and_color(theme);
|
|
(icon, color, status.to_string())
|
|
}
|
|
};
|
|
|
|
let status_icon = ConstrainedBox::new(icon.to_warpui_icon(color.into()).finish())
|
|
.with_width(STATUS_ICON_SIZE)
|
|
.with_height(STATUS_ICON_SIZE)
|
|
.finish();
|
|
|
|
let status_text = Text::new(display_text, appearance.ui_font_family(), ui_font_size)
|
|
.with_color(color)
|
|
.with_selectable(true)
|
|
.finish();
|
|
|
|
let status_badge = Container::new(
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(Container::new(status_icon).with_margin_right(4.).finish())
|
|
.with_child(status_text)
|
|
.finish(),
|
|
)
|
|
.with_uniform_padding(4.)
|
|
.with_background(coloru_with_opacity(color, 10))
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
|
|
.finish();
|
|
|
|
Some(
|
|
Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Container::new(header)
|
|
.with_margin_bottom(SECTION_HEADER_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(status_badge)
|
|
.finish(),
|
|
)
|
|
}
|
|
|
|
fn render_harness_section(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
|
if !FeatureFlag::AgentHarness.is_enabled() {
|
|
return None;
|
|
}
|
|
let harness = self.data.harness?;
|
|
let theme = appearance.theme();
|
|
let ui_font_size = appearance.ui_font_size();
|
|
|
|
let label_text = Text::new(
|
|
"Harness".to_string(),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.finish();
|
|
|
|
let icon_tint = harness_display::brand_color(harness)
|
|
.map(Into::into)
|
|
.unwrap_or_else(|| theme.foreground());
|
|
|
|
let icon = ConstrainedBox::new(
|
|
harness_display::icon_for(harness)
|
|
.to_warpui_icon(icon_tint)
|
|
.finish(),
|
|
)
|
|
.with_width(16.)
|
|
.with_height(16.)
|
|
.finish();
|
|
|
|
let name = Text::new(
|
|
harness_display::display_name(harness).to_string(),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(theme.foreground().into())
|
|
.with_selectable(true)
|
|
.finish();
|
|
|
|
let value_row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(Container::new(icon).with_margin_right(4.).finish())
|
|
.with_child(name)
|
|
.finish();
|
|
|
|
Some(
|
|
Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Container::new(label_text)
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(value_row)
|
|
.finish(),
|
|
)
|
|
}
|
|
|
|
/// Renders the primary skill that this conversation ran.
|
|
fn render_skill_section(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
|
let skill_spec = self.data.skill_spec.as_ref()?;
|
|
let skill_name = skill_spec.skill_name();
|
|
let theme = appearance.theme();
|
|
let ui_font_size = appearance.ui_font_size();
|
|
let sub_color = blended_colors::text_sub(theme, theme.surface_1());
|
|
|
|
let icon = ConstrainedBox::new(Icon::Warp.to_warpui_icon(theme.foreground()).finish())
|
|
.with_width(20.)
|
|
.with_height(20.)
|
|
.finish();
|
|
|
|
let skill_name_text = Text::new(
|
|
format!("/{skill_name}"),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(sub_color)
|
|
.with_selectable(true)
|
|
.finish();
|
|
|
|
let oz_root_url = ChannelState::oz_root_url();
|
|
let encoded_skill_name = urlencoding::encode(&skill_name);
|
|
let skill_url = format!("{oz_root_url}/agents/{encoded_skill_name}");
|
|
|
|
let oz_link = appearance
|
|
.ui_builder()
|
|
.link(
|
|
"Open in Oz".to_string(),
|
|
Some(skill_url),
|
|
None,
|
|
self.mouse_states.skill_link.clone(),
|
|
)
|
|
.build()
|
|
.finish();
|
|
|
|
let separator = || {
|
|
Container::new(
|
|
Text::new("•".to_string(), appearance.ui_font_family(), ui_font_size)
|
|
.with_color(sub_color)
|
|
.finish(),
|
|
)
|
|
.with_margin_left(4.)
|
|
.with_margin_right(4.)
|
|
.finish()
|
|
};
|
|
|
|
let mut row = Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(Container::new(icon).with_margin_right(4.).finish())
|
|
.with_child(Shrinkable::new(1., skill_name_text).finish())
|
|
.with_child(separator())
|
|
.with_child(Shrinkable::new(1., oz_link).finish());
|
|
|
|
// Add GitHub source link if we have enough info to construct it.
|
|
if let (Some(org), Some(repo)) = (&skill_spec.org, &skill_spec.repo) {
|
|
if skill_spec.is_full_path() {
|
|
let github_url = format!(
|
|
"https://github.com/{}/{}/blob/-/{}",
|
|
org, repo, skill_spec.skill_identifier
|
|
);
|
|
let source_link = appearance
|
|
.ui_builder()
|
|
.link(
|
|
"Open in GitHub".to_string(),
|
|
Some(github_url),
|
|
None,
|
|
self.mouse_states.skill_source_link.clone(),
|
|
)
|
|
.build()
|
|
.finish();
|
|
row.add_child(separator());
|
|
row.add_child(Shrinkable::new(1., source_link).finish());
|
|
}
|
|
}
|
|
|
|
Some(row.finish())
|
|
}
|
|
|
|
fn render_source_section(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
|
let source_prompt = self.data.source_prompt.as_ref()?;
|
|
let trimmed = source_prompt.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
Some(self.render_simple_field("Initial query", trimmed, appearance))
|
|
}
|
|
|
|
fn render_artifacts_section(&self, appearance: &Appearance) -> Option<Box<dyn Element>> {
|
|
if self.data.artifacts.is_empty() {
|
|
return None;
|
|
}
|
|
let theme = appearance.theme();
|
|
let ui_font_size = appearance.ui_font_size();
|
|
|
|
let label_text = Text::new(
|
|
"Artifacts".to_string(),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.finish();
|
|
|
|
Some(
|
|
Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Container::new(label_text)
|
|
.with_margin_bottom(SECTION_HEADER_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(ChildView::new(&self.artifact_buttons_row).finish())
|
|
.finish(),
|
|
)
|
|
}
|
|
|
|
fn format_setup_commands_for_copy(commands: &[String]) -> String {
|
|
let wrapped: Vec<String> = commands.iter().map(|cmd| format!("({cmd})")).collect();
|
|
wrapped.join(" && \n")
|
|
}
|
|
|
|
fn render_setup_commands_section(
|
|
&self,
|
|
setup_commands: &[String],
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Option<Box<dyn Element>> {
|
|
if setup_commands.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let theme = appearance.theme();
|
|
let ui_font_size = appearance.ui_font_size();
|
|
|
|
let header_text = Text::new(
|
|
"Environment setup commands".to_string(),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.finish();
|
|
|
|
let commands_text = setup_commands
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, cmd)| format!("{}. {cmd}", i + 1))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
|
|
let field = render_copyable_text_field(
|
|
CopyableTextFieldConfig::new(commands_text)
|
|
.with_font_size(ui_font_size)
|
|
.with_text_color(theme.foreground().into())
|
|
.with_icon_size(16.)
|
|
.with_wrap_text(true)
|
|
.with_mouse_state(self.mouse_state_for_copy_button(CopyButtonKind::SetupCommands))
|
|
.with_last_copied_at(self.copy_feedback_times.get(&CopyButtonKind::SetupCommands)),
|
|
{
|
|
let copy_text = Self::format_setup_commands_for_copy(setup_commands);
|
|
move |ctx| {
|
|
ctx.dispatch_typed_action(ConversationDetailsPanelAction::CopySetupCommands(
|
|
copy_text.clone(),
|
|
));
|
|
}
|
|
},
|
|
app,
|
|
);
|
|
|
|
Some(
|
|
Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Container::new(header_text)
|
|
.with_margin_bottom(SECTION_HEADER_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(
|
|
Container::new(field)
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
}
|
|
|
|
fn render_environment_section(
|
|
&self,
|
|
environment_id: &str,
|
|
env_model: &AmbientAgentEnvironment,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let environment_name = &env_model.name;
|
|
let docker_image = env_model.base_image.to_string();
|
|
|
|
let theme = appearance.theme();
|
|
let ui_font_size = appearance.ui_font_size();
|
|
|
|
// Section header
|
|
let header = Text::new(
|
|
"Environment details".to_string(),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.finish();
|
|
|
|
let mut section = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch);
|
|
section.add_child(
|
|
Container::new(header)
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
);
|
|
|
|
// Helper to render a copyable field with "Label: Value" format
|
|
let render_copyable_field =
|
|
|label: &str,
|
|
value: &str,
|
|
copy_button_kind: CopyButtonKind,
|
|
action: ConversationDetailsPanelAction| {
|
|
render_copyable_text_field(
|
|
CopyableTextFieldConfig::new(format!("{label}: {value}"))
|
|
.with_font_size(ui_font_size)
|
|
.with_text_color(theme.foreground().into())
|
|
.with_icon_size(16.)
|
|
.with_mouse_state(self.mouse_state_for_copy_button(copy_button_kind))
|
|
.with_last_copied_at(self.copy_feedback_times.get(©_button_kind)),
|
|
move |ctx| {
|
|
ctx.dispatch_typed_action(action.clone());
|
|
},
|
|
app,
|
|
)
|
|
};
|
|
|
|
let name_text = Text::new(
|
|
format!("Name: {environment_name}"),
|
|
appearance.ui_font_family(),
|
|
ui_font_size,
|
|
)
|
|
.with_color(theme.foreground().into())
|
|
.with_selectable(true)
|
|
.finish();
|
|
section.add_child(
|
|
Container::new(name_text)
|
|
.with_vertical_padding(4.)
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
);
|
|
|
|
section.add_child(
|
|
Container::new(render_copyable_field(
|
|
"ID",
|
|
environment_id,
|
|
CopyButtonKind::EnvironmentId,
|
|
ConversationDetailsPanelAction::CopyEnvironmentId,
|
|
))
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
);
|
|
|
|
section.add_child(
|
|
Container::new(render_copyable_field(
|
|
"Image",
|
|
&docker_image,
|
|
CopyButtonKind::DockerImage,
|
|
ConversationDetailsPanelAction::CopyDockerImage,
|
|
))
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
);
|
|
|
|
Container::new(section.finish())
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish()
|
|
}
|
|
|
|
// Render a simple field with a button to copy the field's contents.
|
|
fn render_field_with_copy(
|
|
&self,
|
|
label: &str,
|
|
value: &str,
|
|
action: ConversationDetailsPanelAction,
|
|
copy_button_kind: CopyButtonKind,
|
|
appearance: &Appearance,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let ui_font_size = appearance.ui_font_size();
|
|
|
|
let label_text = Text::new(label.to_string(), appearance.ui_font_family(), ui_font_size)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.finish();
|
|
|
|
let value_field = render_copyable_text_field(
|
|
CopyableTextFieldConfig::new(value.to_string())
|
|
.with_font_size(ui_font_size)
|
|
.with_text_color(theme.foreground().into())
|
|
.with_icon_size(16.)
|
|
.with_mouse_state(self.mouse_state_for_copy_button(copy_button_kind))
|
|
.with_last_copied_at(self.copy_feedback_times.get(©_button_kind)),
|
|
move |ctx| {
|
|
ctx.dispatch_typed_action(action.clone());
|
|
},
|
|
app,
|
|
);
|
|
|
|
Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Container::new(label_text)
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(value_field)
|
|
.finish()
|
|
}
|
|
|
|
fn render_simple_field(
|
|
&self,
|
|
label: &str,
|
|
value: &str,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
let ui_font_size = appearance.ui_font_size();
|
|
|
|
let label_text = Text::new(label.to_string(), appearance.ui_font_family(), ui_font_size)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.finish();
|
|
|
|
let value_text = Text::new(value.to_string(), appearance.ui_font_family(), ui_font_size)
|
|
.with_color(theme.foreground().into())
|
|
.with_selectable(true)
|
|
.finish();
|
|
|
|
Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Start)
|
|
.with_child(
|
|
Container::new(label_text)
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
)
|
|
.with_child(value_text)
|
|
.finish()
|
|
}
|
|
|
|
/// Renders the credits section with a breakdown of inference and compute costs.
|
|
fn render_credits_with_split(
|
|
&self,
|
|
inference: f32,
|
|
compute: f32,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let label_text = Text::new(
|
|
"Credits used".to_string(),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.finish();
|
|
|
|
let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Start);
|
|
column.add_child(
|
|
Container::new(label_text)
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
);
|
|
|
|
let inference_row = self.render_cost_sub_row(
|
|
"Inference",
|
|
inference,
|
|
"Credits spent on AI model requests",
|
|
self.mouse_states.inference_info_tooltip.clone(),
|
|
appearance,
|
|
);
|
|
column.add_child(
|
|
Container::new(inference_row)
|
|
.with_margin_bottom(LABEL_VALUE_GAP)
|
|
.finish(),
|
|
);
|
|
|
|
let compute_row = self.render_cost_sub_row(
|
|
"Compute",
|
|
compute,
|
|
"Credits spent on sandbox compute time",
|
|
self.mouse_states.compute_info_tooltip.clone(),
|
|
appearance,
|
|
);
|
|
column.add_child(compute_row);
|
|
|
|
column.finish()
|
|
}
|
|
|
|
fn render_cost_sub_row(
|
|
&self,
|
|
label: &str,
|
|
value: f32,
|
|
tooltip: &str,
|
|
tooltip_mouse_state: MouseStateHandle,
|
|
appearance: &Appearance,
|
|
) -> Box<dyn Element> {
|
|
let theme = appearance.theme();
|
|
|
|
let label_text = Text::new(
|
|
format!("{label}: "),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(blended_colors::text_sub(theme, theme.surface_1()))
|
|
.finish();
|
|
|
|
let value_text = Text::new(
|
|
format!("{value:.1}"),
|
|
appearance.ui_font_family(),
|
|
appearance.ui_font_size(),
|
|
)
|
|
.with_color(theme.foreground().into())
|
|
.with_selectable(true)
|
|
.finish();
|
|
|
|
let info_icon = appearance
|
|
.ui_builder()
|
|
.info_button_with_tooltip(
|
|
appearance.ui_font_size() * 0.85,
|
|
tooltip,
|
|
tooltip_mouse_state,
|
|
)
|
|
.finish();
|
|
|
|
Flex::row()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.with_child(label_text)
|
|
.with_child(value_text)
|
|
.with_child(Container::new(info_icon).with_margin_left(4.).finish())
|
|
.finish()
|
|
}
|
|
|
|
/// Returns the mouse state handle for the given copy button kind.
|
|
fn mouse_state_for_copy_button(&self, kind: CopyButtonKind) -> MouseStateHandle {
|
|
match kind {
|
|
CopyButtonKind::Directory => self.mouse_states.copy_directory.clone(),
|
|
CopyButtonKind::ConversationId => self.mouse_states.copy_conversation_id.clone(),
|
|
CopyButtonKind::RunId => self.mouse_states.copy_run_id.clone(),
|
|
CopyButtonKind::EnvironmentId => self.mouse_states.copy_environment_id.clone(),
|
|
CopyButtonKind::DockerImage => self.mouse_states.copy_docker_image.clone(),
|
|
CopyButtonKind::Error => self.mouse_states.copy_error.clone(),
|
|
CopyButtonKind::SetupCommands => self.mouse_states.copy_setup_commands.clone(),
|
|
}
|
|
}
|
|
|
|
/// Records a copy action and schedules re-render to clear checkmark.
|
|
fn record_copy(&mut self, kind: CopyButtonKind, ctx: &mut ViewContext<Self>) {
|
|
self.copy_feedback_times.insert(kind, Instant::now());
|
|
let duration = COPY_FEEDBACK_DURATION;
|
|
ctx.spawn(
|
|
async move {
|
|
warpui::r#async::Timer::after(duration).await;
|
|
},
|
|
|me, _, ctx| {
|
|
ctx.notify();
|
|
me.copy_feedback_times
|
|
.retain(|_, time| time.elapsed() < COPY_FEEDBACK_DURATION);
|
|
},
|
|
);
|
|
ctx.notify();
|
|
}
|
|
}
|
|
|
|
impl View for ConversationDetailsPanel {
|
|
fn ui_name() -> &'static str {
|
|
"ConversationDetailsPanel"
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
let appearance = Appearance::handle(app).as_ref(app);
|
|
let theme = appearance.theme();
|
|
|
|
let mut content = Flex::column()
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
|
|
.with_main_axis_size(MainAxisSize::Min);
|
|
|
|
// Header row with optional action buttons and close button
|
|
let close_button = icon_button(
|
|
appearance,
|
|
Icon::X,
|
|
false,
|
|
self.mouse_states.close_button.clone(),
|
|
)
|
|
.build()
|
|
.on_click(|ctx, _, _| {
|
|
ctx.dispatch_typed_action(ConversationDetailsPanelAction::Close);
|
|
})
|
|
.with_cursor(Cursor::PointingHand)
|
|
.finish();
|
|
|
|
let mut header_row = Flex::row()
|
|
.with_main_axis_size(MainAxisSize::Max)
|
|
.with_main_axis_alignment(MainAxisAlignment::End)
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center);
|
|
// Add continue locally button (left-aligned) and action icon buttons (right-aligned).
|
|
let has_action_buttons = !self.action_buttons.as_ref(app).is_empty();
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
let has_continue_locally = self.continue_locally_conversation_id(app).is_some();
|
|
#[cfg(target_family = "wasm")]
|
|
let has_continue_locally = false;
|
|
let has_oz_url = Self::oz_run_url(&self.data).is_some();
|
|
|
|
if has_continue_locally || has_oz_url {
|
|
let mut buttons_wrap = Wrap::row().with_spacing(8.).with_run_spacing(8.);
|
|
|
|
#[cfg(not(target_family = "wasm"))]
|
|
if has_continue_locally {
|
|
buttons_wrap.add_child(ChildView::new(&self.continue_locally_button).finish());
|
|
}
|
|
if has_oz_url {
|
|
buttons_wrap.add_child(ChildView::new(&self.open_in_oz_button).finish());
|
|
}
|
|
|
|
header_row.add_child(
|
|
Expanded::new(
|
|
1.,
|
|
Container::new(buttons_wrap.finish())
|
|
.with_margin_right(8.)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
if has_action_buttons {
|
|
header_row.add_child(ChildView::new(&self.action_buttons).finish());
|
|
// Vertical divider between action buttons and close button
|
|
header_row.add_child(
|
|
Container::new(
|
|
ConstrainedBox::new(
|
|
Container::new(Empty::new().finish())
|
|
.with_border(Border::left(1.).with_border_fill(theme.outline()))
|
|
.finish(),
|
|
)
|
|
.with_height(16.)
|
|
.finish(),
|
|
)
|
|
.with_margin_left(8.)
|
|
.with_margin_right(4.)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
header_row.add_child(close_button);
|
|
content.add_child(
|
|
Container::new(header_row.finish())
|
|
.with_margin_bottom(HEADER_SPACING)
|
|
.finish(),
|
|
);
|
|
|
|
// Title
|
|
let ui_font_size = appearance.ui_font_size();
|
|
let title_font_size = ui_font_size + 2.;
|
|
let skill_section = self.render_skill_section(appearance);
|
|
let title_margin = if skill_section.is_some() {
|
|
LABEL_VALUE_GAP
|
|
} else {
|
|
HEADER_SPACING
|
|
};
|
|
let title = Text::new(
|
|
self.data.title.clone(),
|
|
appearance.ui_font_family(),
|
|
title_font_size,
|
|
)
|
|
.with_color(theme.foreground().into())
|
|
.with_style(Properties::default().weight(Weight::Semibold))
|
|
.finish();
|
|
content.add_child(
|
|
Container::new(title)
|
|
.with_margin_bottom(title_margin)
|
|
.finish(),
|
|
);
|
|
|
|
// Skill section
|
|
if let Some(skill_section) = skill_section {
|
|
content.add_child(
|
|
Container::new(skill_section)
|
|
.with_margin_bottom(HEADER_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
// Creator section
|
|
if let Some(creator_section) = self.render_creator_section(appearance) {
|
|
content.add_child(
|
|
Container::new(creator_section)
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
// Divider
|
|
content.add_child(
|
|
Container::new(
|
|
Container::new(Empty::new().finish())
|
|
.with_border(Border::top(1.).with_border_fill(blended_colors::neutral_2(theme)))
|
|
.finish(),
|
|
)
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
|
|
// Status section
|
|
if let Some(status_section) = self.render_status_section(appearance) {
|
|
content.add_child(
|
|
Container::new(status_section)
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
if let Some(harness_section) = self.render_harness_section(appearance) {
|
|
content.add_child(
|
|
Container::new(harness_section)
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
if let Some(artifacts_section) = self.render_artifacts_section(appearance) {
|
|
content.add_child(
|
|
Container::new(artifacts_section)
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
// Mode-specific fields
|
|
match &self.data.mode {
|
|
PanelMode::Conversation {
|
|
directory,
|
|
server_conversation_id: conversation_id,
|
|
ai_conversation_id: _,
|
|
status: _,
|
|
} => {
|
|
if let Some(directory) = directory {
|
|
content.add_child(
|
|
Container::new(self.render_field_with_copy(
|
|
"Directory",
|
|
directory,
|
|
ConversationDetailsPanelAction::CopyDirectory,
|
|
CopyButtonKind::Directory,
|
|
appearance,
|
|
app,
|
|
))
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
if let Some(id) = conversation_id {
|
|
content.add_child(
|
|
Container::new(self.render_field_with_copy(
|
|
"Conversation ID",
|
|
id,
|
|
ConversationDetailsPanelAction::CopyConversationId,
|
|
CopyButtonKind::ConversationId,
|
|
appearance,
|
|
app,
|
|
))
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
}
|
|
PanelMode::Task {
|
|
directory, task_id, ..
|
|
} => {
|
|
if let Some(directory) = directory {
|
|
content.add_child(
|
|
Container::new(self.render_field_with_copy(
|
|
"Directory",
|
|
directory,
|
|
ConversationDetailsPanelAction::CopyDirectory,
|
|
CopyButtonKind::Directory,
|
|
appearance,
|
|
app,
|
|
))
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
if let Some(task_id) = task_id {
|
|
content.add_child(
|
|
Container::new(self.render_field_with_copy(
|
|
"Run ID",
|
|
&task_id.to_string(),
|
|
ConversationDetailsPanelAction::CopyRunId,
|
|
CopyButtonKind::RunId,
|
|
appearance,
|
|
app,
|
|
))
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
match &self.data.credits {
|
|
Some(CreditsInfo::AmbientConversation { inference, compute }) => {
|
|
content.add_child(
|
|
Container::new(
|
|
self.render_credits_with_split(*inference, *compute, appearance),
|
|
)
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
Some(CreditsInfo::LocalConversation(credits)) => {
|
|
let formatted = format!("{credits:.1}");
|
|
content.add_child(
|
|
Container::new(self.render_simple_field(
|
|
"Credits used",
|
|
&formatted,
|
|
appearance,
|
|
))
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
None => {}
|
|
}
|
|
|
|
if let Some(duration) = self.data.run_time {
|
|
let formatted = human_readable_precise_duration(duration);
|
|
content.add_child(
|
|
Container::new(self.render_simple_field("Run time", &formatted, appearance))
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
if let Some(created_at) = self.data.created_at {
|
|
let formatted = created_at.format("%I:%M %p on %-m/%-d/%Y").to_string();
|
|
content.add_child(
|
|
Container::new(self.render_simple_field("Created on", &formatted, appearance))
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
// Task-only fields
|
|
if let PanelMode::Task { environment_id, .. } = &self.data.mode {
|
|
if let Some((eid, env)) = environment_id.as_deref().and_then(|eid| {
|
|
let server_id = ServerId::try_from(eid).ok()?;
|
|
let sync_id = SyncId::ServerId(server_id);
|
|
let env = CloudAmbientAgentEnvironment::get_by_id(&sync_id, app).cloned()?;
|
|
Some((eid, env))
|
|
}) {
|
|
let env_model = &env.model().string_model;
|
|
content.add_child(self.render_environment_section(eid, env_model, appearance, app));
|
|
|
|
if let Some(setup_commands_section) =
|
|
self.render_setup_commands_section(&env_model.setup_commands, appearance, app)
|
|
{
|
|
content.add_child(setup_commands_section);
|
|
}
|
|
}
|
|
|
|
if let Some(error_field) = self.render_error_field(appearance, app) {
|
|
content.add_child(
|
|
Container::new(error_field)
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(source_section) = self.render_source_section(appearance) {
|
|
content.add_child(
|
|
Container::new(source_section)
|
|
.with_margin_bottom(FIELD_SPACING)
|
|
.finish(),
|
|
);
|
|
}
|
|
|
|
let scrollable_content = NewScrollable::vertical(
|
|
SingleAxisConfig::Clipped {
|
|
handle: self.scroll_state.clone(),
|
|
child: Container::new(content.finish())
|
|
.with_uniform_padding(12.)
|
|
.finish(),
|
|
},
|
|
theme.nonactive_ui_detail().into(),
|
|
theme.active_ui_detail().into(),
|
|
warpui::elements::Fill::None,
|
|
)
|
|
.finish();
|
|
|
|
let selected_text = self.selected_text.clone();
|
|
let scrollable_content = SelectableArea::new(
|
|
self.selection_handle.clone(),
|
|
move |selection_args, _, _| {
|
|
*selected_text.write() = selection_args.selection.filter(|s| !s.is_empty());
|
|
},
|
|
scrollable_content,
|
|
)
|
|
.on_selection_updated(|ctx, _| {
|
|
ctx.dispatch_typed_action(ConversationDetailsPanelAction::Focus);
|
|
})
|
|
.finish();
|
|
|
|
let panel_content = Flex::column()
|
|
.with_child(
|
|
Expanded::new(
|
|
1.,
|
|
Container::new(scrollable_content)
|
|
.with_border(
|
|
Border::left(1.).with_border_fill(blended_colors::neutral_2(theme)),
|
|
)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.finish();
|
|
|
|
// On mobile, add background and skip Resizable
|
|
#[cfg(target_family = "wasm")]
|
|
if warpui::platform::wasm::is_mobile_device() {
|
|
return Container::new(panel_content)
|
|
.with_background(theme.surface_1())
|
|
.finish();
|
|
}
|
|
|
|
Resizable::new(self.resizable_state_handle.clone(), panel_content)
|
|
.with_dragbar_side(DragBarSide::Left)
|
|
.with_bounds_callback(Box::new(|_| (200.0, 800.0)))
|
|
.on_resize(|ctx, _| ctx.notify())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Entity for ConversationDetailsPanel {
|
|
type Event = ConversationDetailsPanelEvent;
|
|
}
|
|
|
|
impl TypedActionView for ConversationDetailsPanel {
|
|
type Action = ConversationDetailsPanelAction;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
match action {
|
|
ConversationDetailsPanelAction::Close => {
|
|
ctx.emit(ConversationDetailsPanelEvent::Close);
|
|
}
|
|
ConversationDetailsPanelAction::CopyDirectory => match &self.data.mode {
|
|
PanelMode::Conversation {
|
|
directory: Some(directory),
|
|
..
|
|
}
|
|
| PanelMode::Task {
|
|
directory: Some(directory),
|
|
..
|
|
} => {
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(directory.clone()));
|
|
self.record_copy(CopyButtonKind::Directory, ctx);
|
|
}
|
|
_ => {}
|
|
},
|
|
ConversationDetailsPanelAction::CopyConversationId => {
|
|
if let PanelMode::Conversation {
|
|
server_conversation_id: Some(id),
|
|
..
|
|
} = &self.data.mode
|
|
{
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(id.clone()));
|
|
self.record_copy(CopyButtonKind::ConversationId, ctx);
|
|
}
|
|
}
|
|
ConversationDetailsPanelAction::CopyRunId => {
|
|
if let PanelMode::Task {
|
|
task_id: Some(task_id),
|
|
..
|
|
} = &self.data.mode
|
|
{
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(task_id.to_string()));
|
|
self.record_copy(CopyButtonKind::RunId, ctx);
|
|
}
|
|
}
|
|
ConversationDetailsPanelAction::CopyEnvironmentId => {
|
|
if let PanelMode::Task {
|
|
environment_id: Some(env_id),
|
|
..
|
|
} = &self.data.mode
|
|
{
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(env_id.clone()));
|
|
self.record_copy(CopyButtonKind::EnvironmentId, ctx);
|
|
}
|
|
}
|
|
ConversationDetailsPanelAction::CopyDockerImage => {
|
|
if let PanelMode::Task {
|
|
environment_id: Some(env_id),
|
|
..
|
|
} = &self.data.mode
|
|
{
|
|
// Fetch docker image from environment
|
|
if let Ok(server_id) = ServerId::try_from(env_id.as_str()) {
|
|
let sync_id = SyncId::ServerId(server_id);
|
|
if let Some(env) = CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx) {
|
|
let docker_image = env.model().string_model.base_image.to_string();
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(docker_image));
|
|
self.record_copy(CopyButtonKind::DockerImage, ctx);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ConversationDetailsPanelAction::CopyError => {
|
|
if let PanelMode::Task {
|
|
error_message: Some(error),
|
|
..
|
|
} = &self.data.mode
|
|
{
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(error.clone()));
|
|
self.record_copy(CopyButtonKind::Error, ctx);
|
|
}
|
|
}
|
|
ConversationDetailsPanelAction::CopySetupCommands(text) => {
|
|
if !text.is_empty() {
|
|
ctx.clipboard()
|
|
.write(ClipboardContent::plain_text(text.clone()));
|
|
self.record_copy(CopyButtonKind::SetupCommands, ctx);
|
|
}
|
|
}
|
|
ConversationDetailsPanelAction::Focus => {
|
|
ctx.focus_self();
|
|
}
|
|
ConversationDetailsPanelAction::CopySelectedText => {
|
|
if let Some(text) = self.selected_text.read().clone().filter(|t| !t.is_empty()) {
|
|
ctx.clipboard().write(ClipboardContent::plain_text(text));
|
|
}
|
|
}
|
|
#[cfg(not(target_family = "wasm"))]
|
|
ConversationDetailsPanelAction::ContinueLocally => {
|
|
if let Some(conversation_id) = self.continue_locally_conversation_id(ctx) {
|
|
send_telemetry_from_ctx!(
|
|
AgentManagementTelemetryEvent::DetailsPanelContinueLocally,
|
|
ctx
|
|
);
|
|
ctx.dispatch_typed_action(&WorkspaceAction::ContinueConversationLocally {
|
|
conversation_id,
|
|
});
|
|
}
|
|
}
|
|
ConversationDetailsPanelAction::OpenInOz => {
|
|
if let Some(url) = Self::oz_run_url(&self.data) {
|
|
ctx.open_url(&url);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[cfg(test)]
|
|
#[path = "conversation_details_panel_tests.rs"]
|
|
mod tests;
|