Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion app/src/ai/agent_conversations_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ enum TaskFetchState {
const MAX_PERSONAL_TASKS: usize = 200;
const MAX_TEAM_TASKS: usize = 300;

#[derive(PartialEq)]
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum SessionStatus {
Available,
Expired,
Expand Down Expand Up @@ -469,6 +469,7 @@ pub enum ConversationOrTask<'a> {
Conversation(&'a ConversationMetadata),
}

#[allow(dead_code)]
impl ConversationOrTask<'_> {
pub fn title(&self, app: &AppContext) -> String {
match self {
Expand Down Expand Up @@ -1577,6 +1578,22 @@ impl AgentConversationsModel {
}
}

pub fn resolve_copy_link(
subject: AgentConversationNavigationSubject,
app: &AppContext,
) -> Option<String> {
let model = Self::as_ref(app);
match subject {
AgentConversationNavigationSubject::Entry(id) => model
.get_entry_by_id(&id, app)
.and_then(|entry| model.resolve_entry_copy_link(&entry)),
AgentConversationNavigationSubject::ServerToken(server_token) => model
.entry_for_server_token(&server_token, app)
.and_then(|entry| model.resolve_entry_copy_link(&entry))
.or_else(|| Some(server_token.conversation_link())),
}
}

fn resolve_entry_open_action(
&self,
entry: &AgentConversationEntry,
Expand Down Expand Up @@ -1662,6 +1679,28 @@ impl AgentConversationsModel {
})
}

fn resolve_entry_copy_link(&self, entry: &AgentConversationEntry) -> Option<String> {
if let Some(task_id) = entry.identity.ambient_agent_task_id {
if let Some(session_link) = self.tasks.get(&task_id).and_then(|task| {
task.has_active_execution()
.then(|| {
task.active_run_execution()
.session_link
.map(ToString::to_string)
})
.flatten()
}) {
return Some(session_link);
}
}

entry
.identity
.server_conversation_token
.as_ref()
.map(ServerConversationToken::conversation_link)
}

fn entry_for_server_token(
&self,
server_token: &ServerConversationToken,
Expand Down Expand Up @@ -1786,6 +1825,7 @@ impl AgentConversationsModel {

/// Returns an iterator with all tasks and conversations with filters applied, sorted with the
/// most recently updated items first.
#[allow(dead_code)]
pub fn get_tasks_and_conversations(
&self,
filters: &AgentManagementFilters,
Expand Down Expand Up @@ -1856,6 +1896,7 @@ impl AgentConversationsModel {
}

/// Get a task by its task ID
#[allow(dead_code)]
pub fn get_task(&self, task_id: &AmbientAgentTaskId) -> Option<ConversationOrTask<'_>> {
self.tasks.get(task_id).map(ConversationOrTask::Task)
}
Expand Down
30 changes: 26 additions & 4 deletions app/src/ai/agent_conversations_model/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use warpui::{AppContext, SingletonEntity};
use super::{
artifacts_match_filter, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter,
ConversationMetadata, ConversationOrTask, CreatedOnFilter, CreatorFilter, EnvironmentFilter,
HarnessFilter, OwnerFilter, SourceFilter, StatusFilter,
HarnessFilter, OwnerFilter, SessionStatus, SourceFilter, StatusFilter,
};

/// Stable projection identity used by list and navigation surfaces.
Expand All @@ -27,6 +27,15 @@ pub enum AgentConversationEntryId {
Conversation(AIConversationId),
}

impl AgentConversationEntryId {
pub fn as_key(&self) -> String {
match self {
AgentConversationEntryId::AmbientRun(id) => format!("task_{id}"),
AgentConversationEntryId::Conversation(id) => format!("conv_{id}"),
}
}
}

impl From<ConversationOrTaskId> for AgentConversationEntryId {
fn from(id: ConversationOrTaskId) -> Self {
match id {
Expand All @@ -42,6 +51,7 @@ impl From<ConversationOrTaskId> for AgentConversationEntryId {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AgentConversationNavigationSubject {
Entry(AgentConversationEntryId),
#[allow(dead_code)]
ServerToken(ServerConversationToken),
}

Expand Down Expand Up @@ -80,6 +90,7 @@ pub struct AgentConversationDisplayData {
pub creator: AgentConversationCreator,
pub request_usage: Option<f32>,
pub run_time: Option<String>,
pub session_status: Option<SessionStatus>,
pub source: Option<AgentSource>,
pub working_directory: Option<String>,
pub environment_id: Option<String>,
Expand Down Expand Up @@ -271,6 +282,9 @@ pub(super) fn entry_for_task(
|| has_active_session_id
|| local_conversation_id.is_some()
|| server_conversation_token.is_some();
let can_copy_link = task.has_active_execution()
&& task.active_run_execution().session_link.is_some()
|| server_conversation_token.is_some();

AgentConversationEntry {
id: AgentConversationEntryId::AmbientRun(task.task_id),
Expand All @@ -293,8 +307,10 @@ pub(super) fn entry_for_task(
},
request_usage: item.request_usage(app),
run_time: item.run_time(),
session_status: item.get_session_status(),
source: item.source().cloned(),
working_directory: None,
working_directory: conversation_metadata
.and_then(|metadata| metadata.initial_working_directory.clone()),
environment_id: item.environment_id().map(ToString::to_string),
harness: item.harness(app),
artifacts: item.artifacts(app),
Expand All @@ -310,7 +326,7 @@ pub(super) fn entry_for_task(
},
capabilities: AgentConversationCapabilities {
can_open,
can_copy_link: item.session_or_conversation_link(app).is_some(),
can_copy_link,
can_share: task.conversation_id().is_some()
|| local_conversation_id
.is_some_and(|id| history_model.can_conversation_be_shared(&id)),
Expand Down Expand Up @@ -398,6 +414,7 @@ fn entry_for_conversation_parts(
},
request_usage: item.request_usage(app),
run_time: item.run_time(),
session_status: item.get_session_status(),
source: item.source().cloned(),
working_directory: metadata
.nav_data
Expand All @@ -422,7 +439,12 @@ fn entry_for_conversation_parts(
can_open: has_local_persisted_data
|| has_cloud_data
|| item.get_open_action(None, app).is_some(),
can_copy_link: item.session_or_conversation_link(app).is_some(),
can_copy_link: server_conversation_token_for_conversation(
conversation_id,
Some(&metadata.nav_data),
history_model,
)
.is_some(),
can_share: history_model.can_conversation_be_shared(&conversation_id),
can_delete: has_local_persisted_data,
can_fork_locally: has_local_persisted_data,
Expand Down
175 changes: 175 additions & 0 deletions app/src/ai/agent_conversations_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,181 @@ fn test_resolve_open_action_handles_server_token_subject_without_entry() {
});
}

#[test]
fn test_resolve_copy_link_prefers_active_session_link() {
App::test((), |mut app| async move {
add_entry_projection_test_models(&mut app);

let now = Utc::now();
let session_link = "https://example.com/session/active";
let mut task = create_test_task(&make_uuid(8300), "user-a", now);
task.state = AmbientAgentTaskState::InProgress;
task.session_id = Some(make_uuid(8301));
task.session_link = Some(session_link.to_string());
task.conversation_id = Some("session-backed-token".to_string());
task.is_sandbox_running = true;
let task_id = task.task_id;

app.add_singleton_model(|_| {
let mut model = create_test_model();
model.tasks.insert(task_id, task);
model
});

app.update(|ctx| {
let link = AgentConversationsModel::resolve_copy_link(
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun(
task_id,
)),
ctx,
);

assert_eq!(link.as_deref(), Some(session_link));
});
});
}

#[test]
fn test_resolve_copy_link_uses_cloud_conversation_link_for_inactive_task() {
App::test((), |mut app| async move {
add_entry_projection_test_models(&mut app);

let token = "inactive-task-token";
let mut task = create_test_task(&make_uuid(8302), "user-a", Utc::now());
task.conversation_id = Some(token.to_string());
let task_id = task.task_id;

app.add_singleton_model(|_| {
let mut model = create_test_model();
model.tasks.insert(task_id, task);
model
});

app.update(|ctx| {
let link = AgentConversationsModel::resolve_copy_link(
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun(
task_id,
)),
ctx,
);

assert_eq!(
link,
Some(ServerConversationToken::new(token.to_string()).conversation_link())
);

let entry = AgentConversationsModel::as_ref(ctx)
.get_entry_by_id(&AgentConversationEntryId::AmbientRun(task_id), ctx)
.expect("task entry should exist");
assert!(entry.capabilities.can_copy_link);
});
});
}

#[test]
fn test_resolve_copy_link_returns_none_for_local_only_unsynced_conversation() {
App::test((), |mut app| async move {
add_entry_projection_test_models(&mut app);

let conversation_id = AIConversationId::new();
app.add_singleton_model(|_| {
let mut model = create_test_model();
model.conversations.insert(
conversation_id,
create_test_conversation_metadata(conversation_id, "Local only"),
);
model
});

app.update(|ctx| {
let link = AgentConversationsModel::resolve_copy_link(
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::Conversation(
conversation_id,
)),
ctx,
);

assert_eq!(link, None);

let entry = AgentConversationsModel::as_ref(ctx)
.get_entry_by_id(
&AgentConversationEntryId::Conversation(conversation_id),
ctx,
)
.expect("conversation entry should exist");
assert!(!entry.capabilities.can_copy_link);
});
});
}

#[test]
fn test_resolve_copy_link_uses_attached_synced_conversation_for_task_without_token() {
App::test((), |mut app| async move {
let _orchestration_v2_guard = FeatureFlag::OrchestrationV2.override_enabled(true);
add_entry_projection_test_models(&mut app);

let conversation_id = AIConversationId::new();
let token = "attached-conversation-token";
let task_id = make_uuid(8303);
let conversation = create_restored_conversation(
conversation_id,
"root-task",
AgentConversationData {
server_conversation_token: Some(token.to_string()),
conversation_usage_metadata: None,
reverted_action_ids: None,
forked_from_server_conversation_token: None,
artifacts_json: None,
parent_agent_id: None,
agent_name: None,
parent_conversation_id: None,
is_remote_child: false,
run_id: Some(task_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
},
);

BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| {
model.restore_conversations(EntityId::new(), vec![conversation], ctx);
});

let mut task = create_test_task(&task_id, "user-a", Utc::now());
task.conversation_id = None;
let task_id = task.task_id;

app.add_singleton_model(|_| {
let mut model = create_test_model();
model.tasks.insert(task_id, task);
model.conversations.insert(
conversation_id,
create_test_conversation_metadata(conversation_id, "Conversation"),
);
model
});

app.update(|ctx| {
let link = AgentConversationsModel::resolve_copy_link(
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun(
task_id,
)),
ctx,
);

assert_eq!(
link,
Some(ServerConversationToken::new(token.to_string()).conversation_link())
);

let entry = AgentConversationsModel::as_ref(ctx)
.get_entry_by_id(&AgentConversationEntryId::AmbientRun(task_id), ctx)
.expect("task entry should exist");
assert!(entry.capabilities.can_copy_link);
assert_eq!(entry.identity.local_conversation_id, Some(conversation_id));
});
});
}

#[test]
fn test_eviction_protects_personal_from_team_overflow() {
// Add 50 old personal tasks + 600 new team tasks
Expand Down
11 changes: 4 additions & 7 deletions app/src/ai/agent_management/details_action_buttons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ use warpui::{AppContext, Element, Entity, TypedActionView, View, ViewContext, Vi
use crate::view_components::copyable_text_field::COPY_FEEDBACK_DURATION;

use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::AgentRunDisplayStatus;
use crate::ai::agent_management::view::ManagementCardItemId;
use crate::ai::agent_conversations_model::{AgentConversationEntryId, AgentRunDisplayStatus};
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ui_components::icons::Icon;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
Expand All @@ -25,7 +24,7 @@ pub struct ActionButtonsConfig {
pub fork_conversation_id: Option<AIConversationId>,
/// Shows an info button for viewing more details.
/// Only used in management view hover toolbelt.
pub view_details_item_id: Option<ManagementCardItemId>,
pub view_details_item_id: Option<AgentConversationEntryId>,
/// Conversation link URL (either to the transcript or live session) for copy link button.
pub copy_link_url: Option<String>,
}
Expand Down Expand Up @@ -87,7 +86,7 @@ pub enum AgentDetailsButtonEvent {
Open,
CancelTask { task_id: AmbientAgentTaskId },
ForkConversation { conversation_id: AIConversationId },
ViewDetails { item_id: ManagementCardItemId },
ViewDetails { item_id: AgentConversationEntryId },
CopyLink { link: String },
}

Expand Down Expand Up @@ -260,9 +259,7 @@ impl TypedActionView for ConversationActionButtonsRow {
}
AgentDetailsAction::ViewDetails => {
if let Some(item_id) = &self.config.view_details_item_id {
ctx.emit(AgentDetailsButtonEvent::ViewDetails {
item_id: item_id.clone(),
});
ctx.emit(AgentDetailsButtonEvent::ViewDetails { item_id: *item_id });
}
}
AgentDetailsAction::CopyLink => {
Expand Down
Loading
Loading