diff --git a/app/src/ai/active_agent_views_model.rs b/app/src/ai/active_agent_views_model.rs index 08a8883de..c7c4f1c5a 100644 --- a/app/src/ai/active_agent_views_model.rs +++ b/app/src/ai/active_agent_views_model.rs @@ -349,6 +349,8 @@ impl ActiveAgentViewsModel { task_id: AmbientAgentTaskId, ctx: &mut ModelContext, ) { + 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 @@ -365,9 +367,11 @@ impl ActiveAgentViewsModel { ctx: &mut ModelContext, ) { 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 }); + } } } diff --git a/app/src/ai/active_agent_views_model_tests.rs b/app/src/ai/active_agent_views_model_tests.rs index a704c6764..dd25331df 100644 --- a/app/src/ai/active_agent_views_model_tests.rs +++ b/app/src/ai/active_agent_views_model_tests.rs @@ -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 { diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index d21538be8..04ddc684f 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -1381,6 +1381,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 { diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 9c226d2ce..8a2cd2eaa 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -373,6 +373,11 @@ impl PaneContent for TerminalPane { } }); let active_session = terminal_view.as_ref(ctx).active_session().clone(); + let active_stack_view = pane_stack.as_ref(ctx).active_view().clone(); + let active_ambient_session_registration = active_stack_view + .as_ref(ctx) + .ambient_agent_task_id_for_details_panel(ctx) + .map(|task_id| (active_stack_view.id(), task_id)); ActiveAgentViewsModel::handle(ctx).update(ctx, |model, ctx| { model.register_agent_view_controller( &agent_view_controller, @@ -380,6 +385,9 @@ impl PaneContent for TerminalPane { terminal_view_id, ctx, ); + if let Some((terminal_view_id, task_id)) = active_ambient_session_registration { + model.register_ambient_session(terminal_view_id, task_id, ctx); + } }); } @@ -402,6 +410,10 @@ impl PaneContent for TerminalPane { // Unsubscribe from all views in the pane stack. let pane_stack = self.view.as_ref(ctx).pane_stack().clone(); let contents = pane_stack.as_ref(ctx).entries().to_vec(); + let terminal_view_ids = contents + .iter() + .map(|(_, view)| view.id()) + .collect::>(); for (manager, view) in contents { // Notify the view that it's being detached so it can react appropriately // (e.g. the shared-session viewer tears down its network only when the detach @@ -418,7 +430,10 @@ impl PaneContent for TerminalPane { // restored, so this is safe to run unconditionally. let terminal_view_id = self.terminal_view(ctx).id(); ActiveAgentViewsModel::handle(ctx).update(ctx, |model, ctx| { - model.unregister_agent_view_controller(terminal_view_id, ctx); + for terminal_view_id in terminal_view_ids { + model.unregister_agent_view_controller(terminal_view_id, ctx); + model.unregister_ambient_session(terminal_view_id, ctx); + } }); // Clean up any active CLI agent session so its notification is removed. diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 0579c5f34..840f72b31 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -4878,7 +4878,7 @@ impl Workspace { self.tabs.iter().enumerate().find_map(|(index, tab)| { let pane_group = tab.pane_group.as_ref(ctx); - let has_task = pane_group.terminal_pane_ids().into_iter().any(|pane_id| { + let has_task = pane_group.visible_pane_ids().into_iter().any(|pane_id| { pane_group .terminal_view_from_pane_id(pane_id, ctx) .is_some_and(|tv| {