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
232 changes: 222 additions & 10 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 @@ -250,20 +253,29 @@ enum LinkPreference {

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AgentRunDisplayStatus {
/// Raw task-service lifecycle states. `from_task` only returns `TaskInProgress` while the
/// task still has an active execution, or when there is no shadowed local conversation to
/// provide a more granular status.
TaskQueued,
TaskPending,
TaskClaimed,
TaskInProgress,
TaskSucceeded,
TaskFailed,
TaskError,
TaskBlocked { blocked_action: String },
TaskBlocked {
blocked_action: String,
},
TaskCancelled,
TaskUnknown,
/// Conversation-derived lifecycle states, used for interactive conversations and for
/// in-progress ambient tasks after they can be resolved to their shadowed local conversation.
ConversationInProgress,
ConversationSucceeded,
ConversationError,
ConversationBlocked { blocked_action: String },
ConversationBlocked {
blocked_action: String,
},
ConversationCancelled,
}

Expand Down Expand Up @@ -346,6 +358,32 @@ impl AgentRunDisplayStatus {
}
}

pub fn to_conversation_status(&self) -> ConversationStatus {
match self {
AgentRunDisplayStatus::TaskQueued
| AgentRunDisplayStatus::TaskPending
| AgentRunDisplayStatus::TaskClaimed
| AgentRunDisplayStatus::TaskInProgress
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a difference between task in progress and conversation in progress? I know the source is different, but wondering if at the abstraction layer they should be the same thing (and then same question for the other overlapping statuses)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added some comments to distinguihs

| 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 +595,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 @@ -1487,6 +1521,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,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might be worth a comment on the difference between resolve_open_action and resolve_entry_open_action, and/or I guess the general difference between an entry and a subject

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a sec to grok the distinction in both cases

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
Loading