Skip to content
Draft
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
10 changes: 7 additions & 3 deletions app/src/ai/active_agent_views_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ impl ActiveAgentViewsModel {
task_id: AmbientAgentTaskId,
ctx: &mut ModelContext<Self>,
) {
self.ambient_sessions
.retain(|view_id, id| *view_id == terminal_view_id || *id != task_id);
let existing = self.ambient_sessions.insert(terminal_view_id, task_id);
if existing != Some(task_id) {
self.last_opened_times
Expand All @@ -365,9 +367,11 @@ impl ActiveAgentViewsModel {
ctx: &mut ModelContext<Self>,
) {
if let Some(task_id) = self.ambient_sessions.remove(&terminal_view_id) {
self.last_opened_times
.remove(&ConversationOrTaskId::TaskId(task_id));
ctx.emit(ActiveAgentViewsEvent::AmbientSessionClosed { task_id });
if !self.ambient_sessions.values().any(|id| *id == task_id) {
self.last_opened_times
.remove(&ConversationOrTaskId::TaskId(task_id));
ctx.emit(ActiveAgentViewsEvent::AmbientSessionClosed { task_id });
}
}
}

Expand Down
66 changes: 66 additions & 0 deletions app/src/ai/active_agent_views_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,72 @@ fn focus_change_without_task_id_has_no_conversation() {
});
}

#[test]
fn ambient_session_registration_replaces_stale_terminal_for_same_task() {
App::test((), |mut app| async move {
let model = setup_model(&mut app);
let terminal_a = EntityId::new();
let terminal_b = EntityId::new();
let task = new_task_id();

model.update(&mut app, |model, ctx| {
model.register_ambient_session(terminal_a, task, ctx);
model.register_ambient_session(terminal_b, task, ctx);
});

model.read(&app, |model, _| {
assert_eq!(
model.get_terminal_view_id_for_ambient_task(task),
Some(terminal_b)
);
assert_eq!(model.ambient_sessions.len(), 1);
});
});
}

#[test]
fn ambient_session_unregister_keeps_task_open_until_last_terminal_is_removed() {
App::test((), |mut app| async move {
let model = setup_model(&mut app);
let terminal_a = EntityId::new();
let terminal_b = EntityId::new();
let task = new_task_id();

model.update(&mut app, |model, _| {
model.ambient_sessions.insert(terminal_a, task);
model.ambient_sessions.insert(terminal_b, task);
model
.last_opened_times
.insert(ConversationOrTaskId::TaskId(task), Utc::now());
});

model.update(&mut app, |model, ctx| {
model.unregister_ambient_session(terminal_a, ctx);
});

model.read(&app, |model, _| {
assert_eq!(
model.get_terminal_view_id_for_ambient_task(task),
Some(terminal_b)
);
assert!(model
.last_opened_times
.contains_key(&ConversationOrTaskId::TaskId(task)));
});

model.update(&mut app, |model, ctx| {
model.unregister_ambient_session(terminal_b, ctx);
});

model.read(&app, |model, _| {
assert_eq!(model.get_terminal_view_id_for_ambient_task(task), None);
assert!(!model
.last_opened_times
.contains_key(&ConversationOrTaskId::TaskId(task)));
});
});
}

#[test]
fn remove_focused_state_for_window_cleans_up() {
App::test((), |mut app| async move {
Expand Down
64 changes: 64 additions & 0 deletions app/src/ai/agent_conversations_model_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1380,6 +1380,70 @@ fn test_server_token_assignment_updates_copy_link_resolution() {
});
}

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

let now = Utc::now();
let session_id = make_uuid(8205);
let mut task = create_test_task(&make_uuid(8206), "user-a", now);
task.state = AmbientAgentTaskState::InProgress;
task.session_id = Some(session_id.clone());
task.session_link = Some("https://example.com/session".to_string());
task.is_sandbox_running = true;
let task_id = task.task_id;
let terminal_view_id = EntityId::new();

app.add_singleton_model(|_| {
let mut model = create_test_model();
model.tasks.insert(task_id, task);
model
});
ActiveAgentViewsModel::handle(&app).update(&mut app, |model, ctx| {
model.register_ambient_session(terminal_view_id, task_id, ctx);
});

app.update(|ctx| {
let action = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun(
task_id,
)),
None,
ctx,
);

assert!(matches!(
action,
Some(WorkspaceAction::FocusTerminalViewInWorkspace { terminal_view_id: id })
if id == terminal_view_id
));
});

ActiveAgentViewsModel::handle(&app).update(&mut app, |model, ctx| {
model.unregister_ambient_session(terminal_view_id, ctx);
});

app.update(|ctx| {
let action = AgentConversationsModel::resolve_open_action(
AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun(
task_id,
)),
None,
ctx,
);

assert!(matches!(
action,
Some(WorkspaceAction::OpenAmbientAgentSession {
session_id: resolved_session_id,
task_id: resolved_task_id,
}) if resolved_session_id.to_string() == session_id && resolved_task_id == task_id
));
});
});
}

#[test]
fn test_resolve_copy_link_uses_attached_synced_conversation_for_task_without_token() {
App::test((), |mut app| async move {
Expand Down
169 changes: 169 additions & 0 deletions app/src/pane_group/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3466,6 +3466,174 @@ impl PaneGroup {
});
}

fn fetch_and_hydrate_live_ambient_transcript(
target_view: ViewHandle<TerminalView>,
server_conversation_token: ServerConversationToken,
ambient_agent_task_id: AmbientAgentTaskId,
ctx: &mut ViewContext<Self>,
) {
let history_model_handle = BlocklistAIHistoryModel::handle(ctx);

let future = history_model_handle
.as_ref(ctx)
.load_conversation_by_server_token(&server_conversation_token, ctx);
ctx.spawn(future, move |group, conversation, ctx| {
if !Self::terminal_view_needs_ambient_transcript_hydration(
target_view.id(),
ambient_agent_task_id,
ctx,
) {
return;
}
let Some(conversation) = conversation else {
log::warn!(
"Failed to hydrate restored ambient agent pane for task {ambient_agent_task_id}"
);
return;
};
let terminal_view_id = target_view.id();
Self::hydrate_live_ambient_cloud_mode_view(
target_view,
conversation,
ambient_agent_task_id,
ctx,
);
group.create_missing_child_agent_panes_for_terminal_view(terminal_view_id, ctx);
ctx.notify();
});
}

fn hydrate_live_ambient_cloud_mode_view(
terminal_view: ViewHandle<TerminalView>,
cloud_conversation: CloudConversationData,
task_id: AmbientAgentTaskId,
ctx: &mut ViewContext<Self>,
) {
let mut conversation_id = None;
terminal_view.update(ctx, |view, ctx| {
match cloud_conversation {
CloudConversationData::Oz(conversation) => {
let id = conversation.id();
view.restore_conversation_after_view_creation(
RestoredAIConversation::new(*conversation),
true,
ctx,
);
view.enter_agent_view(None, Some(id), AgentViewEntryOrigin::CloudAgent, ctx);
conversation_id = Some(id);
}
CloudConversationData::CLIAgent(cli_conversation) => {
if !FeatureFlag::AgentHarness.is_enabled() {
log::warn!(
"AgentHarness flag is disabled; ignoring CLI agent conversation"
);
return;
}
let harness = match cli_conversation.metadata.harness {
AIAgentHarness::ClaudeCode => Some(Harness::Claude),
AIAgentHarness::Gemini => Some(Harness::Gemini),
AIAgentHarness::Codex => Some(Harness::Codex),
AIAgentHarness::Oz => None,
AIAgentHarness::Unknown => Some(Harness::Unknown),
};
view.restore_conversation_and_directory_context(
CloudConversationData::CLIAgent(cli_conversation),
true,
|_, _| {},
ctx,
);
if let Some(harness) = harness {
if let Some(ambient_agent_view_model) =
view.ambient_agent_view_model().cloned()
{
ambient_agent_view_model.update(ctx, |model, ctx| {
model.set_harness(harness, ctx);
});
}
}
view.enter_agent_view_for_new_conversation(
None,
AgentViewEntryOrigin::ThirdPartyCloudAgent,
ctx,
);
if let Some(vehicle_conversation_id) = view.active_conversation_id(ctx) {
view.model
.lock()
.block_list_mut()
.attach_non_startup_blocks_to_conversation(vehicle_conversation_id);
}
}
}

if let Some(ambient_agent_view_model) = view.ambient_agent_view_model().cloned() {
ambient_agent_view_model.update(ctx, |model, ctx| {
model.set_conversation_id(conversation_id);
model.enter_viewing_existing_session(task_id, ctx);
});
}
});

ActiveAgentViewsModel::handle(ctx).update(ctx, |active_views, ctx| {
active_views.register_ambient_session(terminal_view.id(), task_id, ctx);
});
}

fn terminal_view_needs_ambient_transcript_hydration(
terminal_view_id: EntityId,
task_id: AmbientAgentTaskId,
ctx: &AppContext,
) -> bool {
!BlocklistAIHistoryModel::as_ref(ctx)
.all_live_conversations_for_terminal_view(terminal_view_id)
.any(|conversation| conversation.task_id() == Some(task_id) && !conversation.is_empty())
}

fn hydrate_restored_ambient_agent_pane_if_needed(
&self,
pane_id: PaneId,
ctx: &mut ViewContext<Self>,
) {
let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) else {
return;
};
let Some(task_id) = terminal_view
.as_ref(ctx)
.ambient_agent_task_id_for_details_panel(ctx)
else {
return;
};
if !Self::terminal_view_needs_ambient_transcript_hydration(terminal_view.id(), task_id, ctx)
{
return;
}

let Some(server_conversation_token) = AgentConversationsModel::as_ref(ctx)
.get_task_data(&task_id)
.and_then(|task| task.conversation_id().map(ToString::to_string))
.map(ServerConversationToken::new)
else {
return;
};

Self::fetch_and_hydrate_live_ambient_transcript(
terminal_view,
server_conversation_token,
task_id,
ctx,
);
}

fn create_missing_child_agent_panes_for_terminal_view(
&mut self,
terminal_view_id: EntityId,
ctx: &mut ViewContext<Self>,
) {
let Some(pane_id) = self.find_pane_id_for_terminal_view(terminal_view_id, ctx) else {
return;
};
self.create_missing_child_agent_panes(pane_id, ctx);
}

/// Replaces a pane with a new cloud conversation.
fn replace_pane_with_new_cloud_conversation(
&mut self,
Expand Down Expand Up @@ -5399,6 +5567,7 @@ impl PaneGroup {
self.cleanup_closed_pane(pane_id, ctx);
return false;
}
self.hydrate_restored_ambient_agent_pane_if_needed(pane_id, ctx);

self.focus_pane_and_record_in_history(pane_id, ctx);

Expand Down
Loading
Loading