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
12 changes: 12 additions & 0 deletions app/src/ai/active_agent_views_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
use chrono::{DateTime, Utc};

use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent_conversations_model::AgentConversationEntryId;
use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent};
use crate::ai::blocklist::orchestration_event_streamer::{
Expand Down Expand Up @@ -47,6 +48,17 @@ pub enum ConversationOrTaskId {
TaskId(AmbientAgentTaskId),
}

impl From<AgentConversationEntryId> for ConversationOrTaskId {
fn from(id: AgentConversationEntryId) -> Self {
match id {
AgentConversationEntryId::Conversation(conversation_id) => {
ConversationOrTaskId::ConversationId(conversation_id)
}
AgentConversationEntryId::AmbientRun(task_id) => ConversationOrTaskId::TaskId(task_id),
}
}
}

impl ConversationOrTaskId {
pub fn conversation_id(&self) -> Option<AIConversationId> {
match self {
Expand Down
219 changes: 211 additions & 8 deletions app/src/ai/agent_conversations_model.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#[allow(dead_code)]
pub mod entry;

pub use entry::AgentConversationEntry;
pub use entry::{
AgentConversationEntry, AgentConversationEntryId, AgentConversationNavigationSubject,
AgentConversationProvenance,
};

use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
use crate::ai::agent::api::ServerConversationToken;
Expand Down Expand Up @@ -346,6 +349,32 @@ impl AgentRunDisplayStatus {
}
}

pub fn to_conversation_status(&self) -> ConversationStatus {
match self {
AgentRunDisplayStatus::TaskQueued
| AgentRunDisplayStatus::TaskPending
| AgentRunDisplayStatus::TaskClaimed
| AgentRunDisplayStatus::TaskInProgress
| AgentRunDisplayStatus::ConversationInProgress => ConversationStatus::InProgress,
AgentRunDisplayStatus::TaskSucceeded | AgentRunDisplayStatus::ConversationSucceeded => {
ConversationStatus::Success
}
AgentRunDisplayStatus::TaskFailed
| AgentRunDisplayStatus::TaskError
| AgentRunDisplayStatus::TaskUnknown
| AgentRunDisplayStatus::ConversationError => ConversationStatus::Error,
AgentRunDisplayStatus::TaskBlocked { blocked_action }
| AgentRunDisplayStatus::ConversationBlocked { blocked_action } => {
ConversationStatus::Blocked {
blocked_action: blocked_action.clone(),
}
}
AgentRunDisplayStatus::TaskCancelled | AgentRunDisplayStatus::ConversationCancelled => {
ConversationStatus::Cancelled
}
}
}

pub fn is_cancellable(&self) -> bool {
self.is_working()
}
Expand Down Expand Up @@ -557,13 +586,9 @@ impl ConversationOrTask<'_> {
/// Returns the session ID for tasks, if we have one.
pub fn session_id(&self) -> Option<SessionId> {
match self {
ConversationOrTask::Task(task) => task.session_id.as_deref().and_then(|s| {
let session_id = s.parse::<SessionId>();
if let Err(ref e) = session_id {
log::warn!("Failed to parse shared session ID: {e}");
}
session_id.ok()
}),
ConversationOrTask::Task(task) => {
task.session_id.as_deref().and_then(entry::parse_session_id)
}
ConversationOrTask::Conversation(_) => None,
}
}
Expand Down Expand Up @@ -1484,6 +1509,184 @@ impl AgentConversationsModel {
.collect()
}

pub fn get_entry_by_id(
&self,
id: &AgentConversationEntryId,
app: &AppContext,
) -> Option<AgentConversationEntry> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
match id {
AgentConversationEntryId::AmbientRun(task_id) => self
.tasks
.get(task_id)
.map(|task| entry::entry_for_task(task, history_model, app)),
AgentConversationEntryId::Conversation(conversation_id) => self
.conversations
.get(conversation_id)
.map(|metadata| entry::entry_for_conversation(metadata, history_model, app))
.or_else(|| {
history_model
.get_conversation_metadata(conversation_id)
.map(|metadata| {
let nav_data =
ConversationNavigationData::from_historical_conversation_metadata(
metadata,
);
entry::entry_for_historical_metadata(
metadata,
nav_data,
history_model,
app,
)
})
}),
}
}

pub fn resolve_open_action(
subject: AgentConversationNavigationSubject,
restore_layout: Option<RestoreConversationLayout>,
app: &AppContext,
) -> Option<WorkspaceAction> {
let model = Self::as_ref(app);
match subject {
AgentConversationNavigationSubject::Entry(id) => model
.get_entry_by_id(&id, app)
.and_then(|entry| model.resolve_entry_open_action(&entry, restore_layout, app)),
AgentConversationNavigationSubject::ServerToken(server_token) => model
.entry_for_server_token(&server_token, app)
.and_then(|entry| model.resolve_entry_open_action(&entry, restore_layout, app))
.or_else(|| {
Some(WorkspaceAction::OpenConversationTranscriptViewer {
ambient_agent_task_id: model.task_id_for_server_token(&server_token),
conversation_id: server_token,
})
}),
}
}

fn resolve_entry_open_action(
&self,
entry: &AgentConversationEntry,
restore_layout: Option<RestoreConversationLayout>,
app: &AppContext,
) -> Option<WorkspaceAction> {
let active_views_model = ActiveAgentViewsModel::as_ref(app);

if let Some(task_id) = entry.identity.ambient_agent_task_id {
if let Some(terminal_view_id) =
active_views_model.get_terminal_view_id_for_ambient_task(task_id)
{
return Some(WorkspaceAction::FocusTerminalViewInWorkspace { terminal_view_id });
}
}

if let Some(conversation_id) = entry.identity.local_conversation_id {
if active_views_model.is_conversation_open(conversation_id, app) {
if let Some(nav_data) = self
.conversations
.get(&conversation_id)
.map(|metadata| &metadata.nav_data)
{
return Some(WorkspaceAction::RestoreOrNavigateToConversation {
conversation_id,
window_id: nav_data.window_id,
pane_view_locator: nav_data.pane_view_locator,
terminal_view_id: nav_data.terminal_view_id,
restore_layout,
});
}

if let Some(terminal_view_id) =
active_views_model.get_terminal_view_id_for_conversation(conversation_id, app)
{
return Some(WorkspaceAction::FocusTerminalViewInWorkspace {
terminal_view_id,
});
}
}
}

if let Some(task_id) = entry.identity.ambient_agent_task_id {
if let Some(session_id) = self
.tasks
.get(&task_id)
.and_then(AmbientAgentTask::active_execution_session_id)
.and_then(entry::parse_session_id)
{
return Some(WorkspaceAction::OpenAmbientAgentSession {
session_id,
task_id,
});
}
}

if let Some(conversation_id) = entry.identity.local_conversation_id {
let nav_data = self
.conversations
.get(&conversation_id)
.map(|metadata| &metadata.nav_data);
if entry.backing.has_loaded_conversation
|| entry.backing.has_local_persisted_data
|| nav_data.is_some()
{
return Some(WorkspaceAction::RestoreOrNavigateToConversation {
conversation_id,
window_id: nav_data.and_then(|nav_data| nav_data.window_id),
pane_view_locator: None,
terminal_view_id: nav_data.and_then(|nav_data| nav_data.terminal_view_id),
restore_layout,
});
}
}

entry
.identity
.server_conversation_token
.as_ref()
.map(|token| WorkspaceAction::OpenConversationTranscriptViewer {
conversation_id: token.clone(),
ambient_agent_task_id: entry.identity.ambient_agent_task_id,
})
}

fn entry_for_server_token(
&self,
server_token: &ServerConversationToken,
app: &AppContext,
) -> Option<AgentConversationEntry> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
if let Some(task) = self.tasks.values().find(|task| {
task.conversation_id()
.is_some_and(|conversation_id| conversation_id == server_token.as_str())
}) {
return Some(entry::entry_for_task(task, history_model, app));
}

let conversation_id = history_model.find_conversation_id_by_server_token(server_token)?;
if let Some(task) = self.tasks.values().find(|task| {
entry::conversation_id_shadowed_by_task(task, history_model) == Some(conversation_id)
}) {
return Some(entry::entry_for_task(task, history_model, app));
}

self.get_entry_by_id(
&AgentConversationEntryId::Conversation(conversation_id),
app,
)
}

fn task_id_for_server_token(
&self,
server_token: &ServerConversationToken,
) -> Option<AmbientAgentTaskId> {
self.tasks.values().find_map(|task| {
task.conversation_id()
.is_some_and(|conversation_id| conversation_id == server_token.as_str())
.then_some(task.task_id)
})
}

fn handle_history_event(
&mut self,
event: &BlocklistAIHistoryEvent,
Expand Down
48 changes: 45 additions & 3 deletions app/src/ai/agent_conversations_model/entry.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId};
use crate::ai::agent::api::ServerConversationToken;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::ambient_agents::{AgentSource, AmbientAgentTask, AmbientAgentTaskId};
Expand All @@ -20,12 +21,30 @@ use super::{
///
/// Task-backed rows use the ambient run ID even when they are attached to a local
/// conversation, so task-specific affordances do not disappear when local data is present.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AgentConversationEntryId {
AmbientRun(AmbientAgentTaskId),
Conversation(AIConversationId),
}

impl From<ConversationOrTaskId> for AgentConversationEntryId {
fn from(id: ConversationOrTaskId) -> Self {
match id {
ConversationOrTaskId::ConversationId(conversation_id) => {
AgentConversationEntryId::Conversation(conversation_id)
}
ConversationOrTaskId::TaskId(task_id) => AgentConversationEntryId::AmbientRun(task_id),
}
}
}

/// Navigation request input for resolving an entry or server-token handle at action time.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AgentConversationNavigationSubject {
Entry(AgentConversationEntryId),
ServerToken(ServerConversationToken),
}

/// Normalized row data for agent conversation list, management, and navigation surfaces.
///
/// The entry keeps local conversation identity, ambient run identity, cloud token identity,
Expand Down Expand Up @@ -241,6 +260,17 @@ pub(super) fn entry_for_task(
})
});
let status = item.display_status(app);
let has_active_session_id = task
.active_execution_session_id()
.and_then(parse_session_id)
.is_some();
let has_open_ambient_session = ActiveAgentViewsModel::as_ref(app)
.get_terminal_view_id_for_ambient_task(task.task_id)
.is_some();
let can_open = has_open_ambient_session
|| has_active_session_id
|| local_conversation_id.is_some()
|| server_conversation_token.is_some();

AgentConversationEntry {
id: AgentConversationEntryId::AmbientRun(task.task_id),
Expand Down Expand Up @@ -279,7 +309,7 @@ pub(super) fn entry_for_task(
has_ambient_run: true,
},
capabilities: AgentConversationCapabilities {
can_open: item.get_open_action(None, app).is_some(),
can_open,
can_copy_link: item.session_or_conversation_link(app).is_some(),
can_share: task.conversation_id().is_some()
|| local_conversation_id
Expand Down Expand Up @@ -389,7 +419,9 @@ fn entry_for_conversation_parts(
.is_some_and(AIConversationMetadata::is_ambient_agent_conversation),
},
capabilities: AgentConversationCapabilities {
can_open: item.get_open_action(None, app).is_some(),
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_share: history_model.can_conversation_be_shared(&conversation_id),
can_delete: has_local_persisted_data,
Expand All @@ -415,3 +447,13 @@ fn server_conversation_token_for_conversation(
})
.or_else(|| nav_data.and_then(|nav_data| nav_data.server_conversation_token.clone()))
}

pub(super) fn parse_session_id(session_id: &str) -> Option<SessionId> {
match session_id.parse::<SessionId>() {
Ok(session_id) => Some(session_id),
Err(e) => {
log::warn!("Failed to parse shared session ID: {e}");
None
}
}
}
Loading