use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::future::poll_fn;
use std::path::PathBuf;
use std::sync::Arc;
use std::task::Poll;
use app::branch_publish::{
BranchPublishActionUpdate, BranchPublishTaskResult, BranchPublishTaskSuccess,
branch_publish_loading_label as branch_publish_loading_label_text,
branch_publish_success_title as branch_publish_success_title_text,
detected_forge_kind_from_git_push_error, git_push_authentication_message,
is_git_push_authentication_error,
review_request_created_notice as review_request_created_notice_text,
};
use app::reducer::AppEventReducer;
use app::review::{
FocusedReviewPersistence, ReviewUpdate, apply_review_updates, auto_start_reviews,
};
use super::state::{
App, RequestedReviewCommentFetchKey, SyncPopupContext, SyncReviewRequestTaskResult,
UpdateStatus,
};
use crate::app::session::{
SessionTaskService, StatusTransition, SyncMainOutcome, SyncSessionStartError, TurnAppliedState,
};
use crate::app::session_state::SessionGitStatus;
use crate::app::{self, SessionRuntimeCommand, sync_message};
use crate::domain::agent::AgentCliInfo;
use crate::domain::file_entry::{FileEntry, at_mention_lookup_root};
use crate::domain::input::InputState;
use crate::domain::question::default_option_index;
use crate::domain::session::{
PublishBranchAction, PublishedBranchSyncStatus, SessionDiffStats, SessionHandles, SessionId,
Status,
};
use crate::domain::transcript_notice::TranscriptNotice;
use crate::domain::transient_message::TransientMessageBody;
use crate::infra::db::DbError;
use crate::presentation::app_mode::{
AppMode, ChatFocus, ConfirmationViewMode, DiffPreview, DiffPreviewUnavailableReason,
HelpContext,
};
#[cfg(test)]
use crate::presentation::app_mode::{ReviewCommentAction, ReviewCommentActionSelection};
use crate::presentation::prompt::PromptAtMentionState;
use crate::presentation::review_comment as review_comment_selection;
pub(crate) enum AppRuntimeEvent {
App(Box<AppEvent>),
Session(SessionRuntimeCommand),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AppEvent {
AssignedIssuesLoaded {
generation: u64,
project_id: i64,
result: Result<Vec<ag_forge::AssignedIssue>, String>,
},
IssueDetailLoaded {
display_id: String,
generation: u64,
project_id: i64,
result: Result<ag_forge::IssueDetail, String>,
},
AtMentionEntriesLoaded {
entries: Vec<FileEntry>,
session_id: SessionId,
},
DiffPreviewLoaded {
path: String,
request_id: u64,
result: Result<ag_git::WorktreeFileContent, String>,
session_id: SessionId,
},
GitStatusUpdated {
generation: u64,
session_statuses: HashMap<SessionId, SessionGitStatus>,
status: Option<(u32, u32)>,
},
VersionAvailabilityUpdated {
latest_available_version: Option<String>,
},
AgentCliVersionsUpdated { agent_clis: Vec<AgentCliInfo> },
UpdateStatusChanged { update_status: UpdateStatus },
SessionModelUpdated {
session_id: SessionId,
session_agent: crate::domain::agent::AgentSelection,
},
SessionPersonalityUpdated {
personality_id: Option<String>,
session_id: SessionId,
},
SessionReasoningLevelUpdated {
reasoning_level: crate::domain::agent::ReasoningLevel,
session_id: SessionId,
},
SessionSpeedModeUpdated {
session_id: SessionId,
speed_mode: crate::domain::agent::SpeedMode,
},
RefreshSessions,
RefreshProjects,
RefreshGitStatus,
RequestedReviewsLoaded {
generation: u64,
project_id: i64,
result: Result<Vec<ag_forge::RequestedReview>, String>,
},
RequestedReviewCommentSnapshotLoaded {
display_id: String,
generation: u64,
project_id: i64,
result: Result<ag_forge::ReviewCommentSnapshot, String>,
web_url: String,
},
SessionReviewCommentSnapshotLoaded {
result: Result<ag_forge::ReviewCommentSnapshot, String>,
session_id: SessionId,
},
SessionProgressUpdated {
progress_message: Option<String>,
session_id: SessionId,
},
SyncMainCompleted {
result: Result<SyncMainOutcome, SyncSessionStartError>,
},
SyncMainConflictResolutionStarted { conflicted_files: Vec<String> },
SessionDiffStatsUpdated {
diff_stats: SessionDiffStats,
session_id: SessionId,
},
SessionTitleGenerationFinished {
generation: u64,
session_id: SessionId,
},
BranchPublishActionCompleted {
result: Box<BranchPublishTaskResult>,
session_id: SessionId,
},
ReviewPrepared {
diff_hash: u64,
review_text: String,
session_id: SessionId,
},
ReviewPreparationFailed {
diff_hash: u64,
error: String,
session_id: SessionId,
},
SessionUpdated { session_id: SessionId, version: u64 },
AgentResponseReceived {
session_id: SessionId,
turn_applied_state: TurnAppliedState,
},
StackedParentTurnCompleted { session_id: SessionId },
StackedParentSyncCompleted { session_id: SessionId },
StackedParentMergeCompleted { child_session_ids: Vec<SessionId> },
SessionWorkflowNoticeUpdated {
notice: String,
session_id: SessionId,
},
SessionOrchestrationProgressUpdated {
progress: Option<String>,
session_id: SessionId,
},
PublishedBranchSyncUpdated {
persistent_notice: Option<String>,
session_id: SessionId,
sync_operation_id: String,
sync_status: PublishedBranchSyncStatus,
},
ReviewRequestStatusUpdated {
generation: u64,
result: Result<SyncReviewRequestTaskResult, String>,
session_id: SessionId,
},
}
#[derive(Default)]
pub(super) struct AppEventBatch {
pub(super) assigned_issues: Option<(u64, i64, Result<Vec<ag_forge::AssignedIssue>, String>)>,
pub(super) issue_details: Vec<IssueDetailUpdate>,
pub(super) applied_turns: HashMap<SessionId, TurnAppliedState>,
pub(super) agent_cli_updates: Option<Vec<AgentCliInfo>>,
pub(super) at_mention_entries_updates: HashMap<SessionId, Vec<FileEntry>>,
pub(super) branch_publish_action_updates: Vec<BranchPublishActionUpdate>,
pub(super) diff_preview_updates: Vec<DiffPreviewUpdate>,
pub(super) git_status_update: Option<GitStatusBatchUpdate>,
pub(super) latest_available_version_update: Option<LatestAvailableVersionUpdate>,
pub(super) published_branch_sync_updates: Vec<(SessionId, PublishedBranchSyncUpdate)>,
pub(super) review_updates: HashMap<SessionId, ReviewUpdate>,
pub(super) session_git_status_updates: HashMap<SessionId, SessionGitStatus>,
pub(super) session_ids: HashSet<SessionId>,
pub(super) session_update_versions: HashMap<SessionId, u64>,
pub(super) session_model_updates: HashMap<SessionId, crate::domain::agent::AgentSelection>,
pub(super) session_orchestration_progress_updates: HashMap<SessionId, Option<String>>,
pub(super) session_personality_updates: HashMap<SessionId, Option<String>>,
pub(super) session_reasoning_level_updates:
HashMap<SessionId, crate::domain::agent::ReasoningLevel>,
pub(super) session_speed_mode_updates: HashMap<SessionId, crate::domain::agent::SpeedMode>,
pub(super) session_progress_updates: HashMap<SessionId, Option<String>>,
pub(super) session_review_comment_snapshots:
HashMap<SessionId, Result<ag_forge::ReviewCommentSnapshot, String>>,
pub(super) session_diff_stats_updates: HashMap<SessionId, SessionDiffStats>,
pub(super) stacked_parent_merge_child_rebases: HashSet<SessionId>,
pub(super) stacked_parent_syncs_completed: HashSet<SessionId>,
pub(super) stacked_parent_turns_completed: HashSet<SessionId>,
pub(super) session_title_generation_finished: HashMap<SessionId, u64>,
pub(super) session_workflow_notice_updates: HashMap<SessionId, Vec<String>>,
pub(super) should_refresh_git_status: bool,
pub(super) should_reload_projects: bool,
pub(super) should_reload_sessions: bool,
pub(super) review_request_status_updates: Vec<ReviewRequestStatusUpdate>,
pub(super) requested_reviews:
Option<(u64, i64, Result<Vec<ag_forge::RequestedReview>, String>)>,
pub(super) requested_review_comment_snapshots: Vec<RequestedReviewCommentSnapshotUpdate>,
pub(super) sync_main_conflicted_files: Option<Vec<String>>,
pub(super) sync_main_result: Option<Result<SyncMainOutcome, SyncSessionStartError>>,
pub(super) update_status: Option<UpdateStatus>,
}
#[derive(Debug, Eq, PartialEq)]
enum AppEventEffect {
ReloadSessions,
ReloadProjects,
RefreshGitStatus,
ApplyReviewUpdates(HashMap<SessionId, ReviewUpdate>),
}
#[derive(Debug, Eq, PartialEq)]
struct AppEventReductionPlan {
after_snapshot_effects: Vec<AppEventEffect>,
before_snapshot_effects: Vec<AppEventEffect>,
changes_observable_state: bool,
}
pub(super) struct IssueDetailUpdate {
pub(super) display_id: String,
pub(super) generation: u64,
pub(super) project_id: i64,
pub(super) result: Result<ag_forge::IssueDetail, String>,
}
pub(super) struct DiffPreviewUpdate {
pub(super) path: String,
pub(super) request_id: u64,
pub(super) result: Result<ag_git::WorktreeFileContent, String>,
pub(super) session_id: SessionId,
}
pub(super) struct GitStatusBatchUpdate {
generation: u64,
status: Option<(u32, u32)>,
}
pub(super) struct LatestAvailableVersionUpdate {
latest_available_version: Option<String>,
}
pub(super) struct PublishedBranchSyncUpdate {
persistent_notice: Option<String>,
sync_operation_id: String,
sync_status: PublishedBranchSyncStatus,
}
pub(super) struct ReviewRequestStatusUpdate {
pub(super) generation: u64,
pub(super) result: Result<SyncReviewRequestTaskResult, String>,
pub(super) session_id: SessionId,
}
pub(super) struct RequestedReviewCommentSnapshotUpdate {
pub(super) display_id: String,
pub(super) generation: u64,
pub(super) project_id: i64,
pub(super) result: Result<ag_forge::ReviewCommentSnapshot, String>,
pub(super) web_url: String,
}
impl AppEventBatch {
fn drain_reduction_plan(&mut self) -> AppEventReductionPlan {
let mut before_snapshot_effects = Vec::new();
if self.should_reload_sessions {
before_snapshot_effects.push(AppEventEffect::ReloadSessions);
}
if self.should_reload_projects {
before_snapshot_effects.push(AppEventEffect::ReloadProjects);
}
if self.should_refresh_git_status {
before_snapshot_effects.push(AppEventEffect::RefreshGitStatus);
}
let changes_observable_state = self.should_reload_sessions
|| self.should_reload_projects
|| self.agent_cli_updates.is_some()
|| self.assigned_issues.is_some()
|| self.git_status_update.is_some()
|| !self.issue_details.is_empty()
|| self.latest_available_version_update.is_some()
|| self.update_status.is_some()
|| !self.applied_turns.is_empty()
|| !self.at_mention_entries_updates.is_empty()
|| !self.branch_publish_action_updates.is_empty()
|| !self.diff_preview_updates.is_empty()
|| !self.published_branch_sync_updates.is_empty()
|| !self.review_request_status_updates.is_empty()
|| self.requested_reviews.is_some()
|| !self.requested_review_comment_snapshots.is_empty()
|| !self.review_updates.is_empty()
|| !self.session_model_updates.is_empty()
|| !self.session_orchestration_progress_updates.is_empty()
|| !self.session_personality_updates.is_empty()
|| !self.session_progress_updates.is_empty()
|| !self.session_review_comment_snapshots.is_empty()
|| !self.session_reasoning_level_updates.is_empty()
|| !self.session_speed_mode_updates.is_empty()
|| !self.session_diff_stats_updates.is_empty()
|| !self.session_title_generation_finished.is_empty()
|| !self.session_workflow_notice_updates.is_empty()
|| !self.stacked_parent_merge_child_rebases.is_empty()
|| !self.stacked_parent_syncs_completed.is_empty()
|| !self.stacked_parent_turns_completed.is_empty()
|| self.sync_main_conflicted_files.is_some()
|| self.sync_main_result.is_some();
let after_snapshot_effects = (!self.review_updates.is_empty())
.then(|| AppEventEffect::ApplyReviewUpdates(std::mem::take(&mut self.review_updates)))
.into_iter()
.collect();
AppEventReductionPlan {
after_snapshot_effects,
before_snapshot_effects,
changes_observable_state,
}
}
pub(super) fn collect_event(&mut self, event: AppEvent) {
match event {
AppEvent::AssignedIssuesLoaded {
generation,
project_id,
result,
} => {
self.collect_assigned_issues_loaded(generation, project_id, result);
}
AppEvent::IssueDetailLoaded {
display_id,
generation,
project_id,
result,
} => self.issue_details.push(IssueDetailUpdate {
display_id,
generation,
project_id,
result,
}),
AppEvent::AtMentionEntriesLoaded {
entries,
session_id,
} => self.collect_at_mention_entries_loaded(session_id, entries),
AppEvent::GitStatusUpdated {
generation,
session_statuses,
status,
} => self.collect_git_status_updated(generation, session_statuses, status),
AppEvent::VersionAvailabilityUpdated {
latest_available_version,
} => self.collect_version_availability_updated(latest_available_version),
AppEvent::AgentCliVersionsUpdated { agent_clis } => {
self.collect_agent_cli_versions_updated(agent_clis);
}
AppEvent::UpdateStatusChanged { update_status } => {
self.collect_update_status_changed(update_status);
}
AppEvent::SessionModelUpdated {
session_id,
session_agent,
} => self.collect_session_model_updated(session_id, session_agent),
AppEvent::SessionPersonalityUpdated {
personality_id,
session_id,
} => self.collect_session_personality_updated(session_id, personality_id),
AppEvent::SessionReasoningLevelUpdated {
reasoning_level,
session_id,
} => self.collect_session_reasoning_level_updated(session_id, reasoning_level),
AppEvent::SessionSpeedModeUpdated {
session_id,
speed_mode,
} => self.collect_session_speed_mode_updated(session_id, speed_mode),
AppEvent::RefreshSessions => self.collect_refresh_sessions(),
AppEvent::RefreshProjects => self.collect_refresh_projects(),
AppEvent::RefreshGitStatus => self.collect_refresh_git_status(),
AppEvent::RequestedReviewsLoaded {
generation,
project_id,
result,
} => self.collect_requested_reviews_loaded(generation, project_id, result),
AppEvent::RequestedReviewCommentSnapshotLoaded {
display_id,
generation,
project_id,
result,
web_url,
} => self.collect_requested_review_comment_snapshot_loaded(
display_id, generation, project_id, result, web_url,
),
event => self.collect_runtime_event(event),
}
}
fn collect_runtime_event(&mut self, event: AppEvent) {
match event {
AppEvent::DiffPreviewLoaded {
path,
request_id,
result,
session_id,
} => self.diff_preview_updates.push(DiffPreviewUpdate {
path,
request_id,
result,
session_id,
}),
AppEvent::SessionProgressUpdated {
progress_message,
session_id,
} => self.collect_session_progress_updated(session_id, progress_message),
AppEvent::SessionReviewCommentSnapshotLoaded { result, session_id } => {
self.session_review_comment_snapshots
.insert(session_id, result);
}
AppEvent::SyncMainCompleted { result } => self.collect_sync_main_completed(result),
AppEvent::SyncMainConflictResolutionStarted { conflicted_files } => {
self.collect_sync_main_conflict_resolution_started(conflicted_files);
}
AppEvent::SessionDiffStatsUpdated {
diff_stats,
session_id,
} => {
self.session_diff_stats_updates
.insert(session_id, diff_stats);
}
AppEvent::SessionTitleGenerationFinished {
generation,
session_id,
} => {
self.session_title_generation_finished
.insert(session_id, generation);
}
AppEvent::BranchPublishActionCompleted { result, session_id } => {
self.collect_branch_publish_action_completed(*result, session_id);
}
AppEvent::ReviewPrepared {
diff_hash,
review_text,
session_id,
} => self.collect_review_prepared(diff_hash, review_text, session_id),
AppEvent::ReviewPreparationFailed {
diff_hash,
error,
session_id,
} => self.collect_review_preparation_failed(diff_hash, error, session_id),
AppEvent::SessionUpdated {
session_id,
version,
} => self.collect_session_updated(session_id, version),
AppEvent::AgentResponseReceived {
session_id,
turn_applied_state,
} => self.collect_agent_response_received(session_id, turn_applied_state),
AppEvent::StackedParentTurnCompleted { session_id } => {
self.collect_stacked_parent_turn_completed(session_id);
}
AppEvent::StackedParentSyncCompleted { session_id } => {
self.collect_stacked_parent_sync_completed(session_id);
}
AppEvent::StackedParentMergeCompleted { child_session_ids } => {
self.collect_stacked_parent_merge_completed(child_session_ids);
}
AppEvent::SessionWorkflowNoticeUpdated { notice, session_id } => {
self.collect_session_workflow_notice_updated(session_id, notice);
}
AppEvent::SessionOrchestrationProgressUpdated {
progress,
session_id,
} => {
self.session_orchestration_progress_updates
.insert(session_id, progress);
}
AppEvent::PublishedBranchSyncUpdated {
persistent_notice,
session_id,
sync_operation_id,
sync_status,
} => self.collect_published_branch_sync_updated(
session_id,
sync_operation_id,
sync_status,
persistent_notice,
),
AppEvent::ReviewRequestStatusUpdated {
generation,
result,
session_id,
} => self.collect_review_request_status_updated(generation, result, session_id),
_ => unreachable!("top-level app event should be collected before runtime events"),
}
}
fn collect_assigned_issues_loaded(
&mut self,
generation: u64,
project_id: i64,
result: Result<Vec<ag_forge::AssignedIssue>, String>,
) {
if self
.assigned_issues
.as_ref()
.is_none_or(|(current_generation, _, _)| generation >= *current_generation)
{
self.assigned_issues = Some((generation, project_id, result));
}
}
fn collect_requested_reviews_loaded(
&mut self,
generation: u64,
project_id: i64,
result: Result<Vec<ag_forge::RequestedReview>, String>,
) {
if self
.requested_reviews
.as_ref()
.is_none_or(|(batched_generation, _, _)| generation >= *batched_generation)
{
self.requested_reviews = Some((generation, project_id, result));
}
}
fn collect_requested_review_comment_snapshot_loaded(
&mut self,
display_id: String,
generation: u64,
project_id: i64,
result: Result<ag_forge::ReviewCommentSnapshot, String>,
web_url: String,
) {
self.requested_review_comment_snapshots
.push(RequestedReviewCommentSnapshotUpdate {
display_id,
generation,
project_id,
result,
web_url,
});
}
fn collect_session_model_updated(
&mut self,
session_id: SessionId,
session_agent: crate::domain::agent::AgentSelection,
) {
self.session_model_updates.insert(session_id, session_agent);
}
fn collect_session_personality_updated(
&mut self,
session_id: SessionId,
personality_id: Option<String>,
) {
self.session_personality_updates
.insert(session_id, personality_id);
}
fn collect_session_reasoning_level_updated(
&mut self,
session_id: SessionId,
reasoning_level: crate::domain::agent::ReasoningLevel,
) {
self.session_reasoning_level_updates
.insert(session_id, reasoning_level);
}
fn collect_session_speed_mode_updated(
&mut self,
session_id: SessionId,
speed_mode: crate::domain::agent::SpeedMode,
) {
self.session_speed_mode_updates
.insert(session_id, speed_mode);
}
fn collect_session_workflow_notice_updated(&mut self, session_id: SessionId, notice: String) {
self.session_ids.insert(session_id.clone());
self.session_workflow_notice_updates
.entry(session_id)
.or_default()
.push(notice);
}
fn collect_at_mention_entries_loaded(
&mut self,
session_id: SessionId,
entries: Vec<FileEntry>,
) {
self.at_mention_entries_updates.insert(session_id, entries);
}
fn collect_update_status_changed(&mut self, update_status: UpdateStatus) {
self.update_status = Some(update_status);
}
fn collect_agent_cli_versions_updated(&mut self, agent_clis: Vec<AgentCliInfo>) {
self.agent_cli_updates = Some(agent_clis);
}
fn collect_session_progress_updated(
&mut self,
session_id: SessionId,
progress_message: Option<String>,
) {
self.session_progress_updates
.insert(session_id, progress_message);
}
fn collect_refresh_sessions(&mut self) {
self.should_reload_sessions = true;
}
fn collect_refresh_projects(&mut self) {
self.should_reload_projects = true;
}
fn collect_refresh_git_status(&mut self) {
self.should_refresh_git_status = true;
}
fn collect_git_status_updated(
&mut self,
generation: u64,
session_statuses: HashMap<SessionId, SessionGitStatus>,
status: Option<(u32, u32)>,
) {
if self
.git_status_update
.as_ref()
.is_none_or(|batched_update| generation >= batched_update.generation)
{
self.git_status_update = Some(GitStatusBatchUpdate { generation, status });
self.session_git_status_updates = session_statuses;
}
}
fn collect_version_availability_updated(&mut self, latest_available_version: Option<String>) {
self.latest_available_version_update = Some(LatestAvailableVersionUpdate {
latest_available_version,
});
}
fn collect_sync_main_completed(
&mut self,
result: Result<SyncMainOutcome, SyncSessionStartError>,
) {
if result.is_ok() {
self.should_refresh_git_status = true;
}
self.sync_main_result = Some(result);
}
fn collect_sync_main_conflict_resolution_started(&mut self, conflicted_files: Vec<String>) {
self.sync_main_conflicted_files = Some(conflicted_files);
}
fn collect_branch_publish_action_completed(
&mut self,
result: BranchPublishTaskResult,
session_id: SessionId,
) {
if result.is_ok() {
self.should_refresh_git_status = true;
}
self.branch_publish_action_updates
.push(BranchPublishActionUpdate { result, session_id });
}
fn collect_review_prepared(
&mut self,
diff_hash: u64,
review_text: String,
session_id: SessionId,
) {
self.review_updates.insert(
session_id,
ReviewUpdate {
diff_hash,
result: Ok(review_text),
},
);
}
fn collect_review_preparation_failed(
&mut self,
diff_hash: u64,
error: String,
session_id: SessionId,
) {
self.review_updates.insert(
session_id,
ReviewUpdate {
diff_hash,
result: Err(error),
},
);
}
fn collect_published_branch_sync_updated(
&mut self,
session_id: SessionId,
sync_operation_id: String,
sync_status: PublishedBranchSyncStatus,
persistent_notice: Option<String>,
) {
if matches!(
sync_status,
PublishedBranchSyncStatus::Idle | PublishedBranchSyncStatus::Succeeded
) {
self.should_refresh_git_status = true;
}
self.session_ids.insert(session_id.clone());
self.published_branch_sync_updates.push((
session_id,
PublishedBranchSyncUpdate {
persistent_notice,
sync_operation_id,
sync_status,
},
));
}
fn collect_review_request_status_updated(
&mut self,
generation: u64,
result: Result<SyncReviewRequestTaskResult, String>,
session_id: SessionId,
) {
self.review_request_status_updates
.push(ReviewRequestStatusUpdate {
generation,
result,
session_id,
});
}
fn collect_session_updated(&mut self, session_id: SessionId, version: u64) {
self.session_ids.insert(session_id.clone());
self.session_update_versions.insert(session_id, version);
}
fn collect_agent_response_received(
&mut self,
session_id: SessionId,
turn_applied_state: TurnAppliedState,
) {
self.session_ids.insert(session_id.clone());
match self.applied_turns.entry(session_id) {
Entry::Occupied(mut occupied_entry) => {
occupied_entry.get_mut().merge_newer(turn_applied_state);
}
Entry::Vacant(vacant_entry) => {
vacant_entry.insert(turn_applied_state);
}
}
}
fn collect_stacked_parent_turn_completed(&mut self, session_id: SessionId) {
self.stacked_parent_turns_completed.insert(session_id);
}
fn collect_stacked_parent_sync_completed(&mut self, session_id: SessionId) {
self.stacked_parent_syncs_completed.insert(session_id);
}
fn collect_stacked_parent_merge_completed(&mut self, child_session_ids: Vec<SessionId>) {
self.stacked_parent_merge_child_rebases
.extend(child_session_ids);
}
}
impl App {
pub(crate) async fn apply_app_events(&mut self, first_event: AppEvent) {
let drained_events = AppEventReducer::drain(&mut self.event_rx, first_event);
let mut event_batch = AppEventBatch::default();
for event in drained_events {
event_batch.collect_event(event);
}
self.apply_app_event_batch(event_batch).await;
}
pub(crate) async fn process_pending_app_events(&mut self) {
let Ok(first_event) = self.event_rx.try_recv() else {
return;
};
self.apply_app_events(first_event).await;
}
#[cfg(test)]
pub(crate) async fn next_app_event(&mut self) -> Option<AppEvent> {
self.event_rx.recv().await
}
pub(crate) async fn next_runtime_event(&mut self) -> AppRuntimeEvent {
let event_rx = &mut self.event_rx;
let sessions = &mut self.sessions;
tokio::select! {
event = poll_fn(|context| match event_rx.poll_recv(context) {
Poll::Ready(Some(event)) => Poll::Ready(event),
Poll::Ready(None) | Poll::Pending => Poll::Pending,
}) => AppRuntimeEvent::App(Box::new(event)),
command = sessions.next_command() => AppRuntimeEvent::Session(command),
}
}
async fn apply_app_event_batch(&mut self, mut event_batch: AppEventBatch) {
let sync_generation_for_review_updates = self.sync_handle.current_generation();
let AppEventReductionPlan {
after_snapshot_effects,
before_snapshot_effects,
changes_observable_state,
} = event_batch.drain_reduction_plan();
let mut should_mark_dirty = changes_observable_state;
let previous_session_states = self.previous_session_states(&event_batch.session_ids);
should_mark_dirty |=
self.update_session_redraw_versions(&event_batch.session_update_versions);
self.apply_app_event_effects(before_snapshot_effects).await;
self.apply_batch_runtime_updates(&mut event_batch);
self.apply_batch_session_snapshot_updates(&mut event_batch);
self.apply_app_event_effects(after_snapshot_effects).await;
for branch_publish_action_update in
std::mem::take(&mut event_batch.branch_publish_action_updates)
{
self.apply_branch_publish_action_update(branch_publish_action_update)
.await;
}
self.apply_review_request_status_updates_and_synced_merges(
&mut event_batch,
sync_generation_for_review_updates,
)
.await;
self.apply_session_progress_updates(std::mem::take(
&mut event_batch.session_progress_updates,
));
self.apply_session_review_comment_snapshot_updates(std::mem::take(
&mut event_batch.session_review_comment_snapshots,
));
for (session_id, turn_applied_state) in event_batch.applied_turns {
self.apply_agent_response_received(&session_id, &turn_applied_state);
}
for (session_id, sync_update) in event_batch.published_branch_sync_updates {
self.apply_published_branch_sync_update(&session_id, sync_update)
.await;
}
if let Some(conflicted_files) = event_batch.sync_main_conflicted_files.as_deref() {
self.apply_sync_main_conflict_resolution_started(conflicted_files);
}
self.sync_touched_sessions(&event_batch.session_ids);
for (session_id, progress) in
std::mem::take(&mut event_batch.session_orchestration_progress_updates)
{
self.sessions
.update_orchestration_progress(&session_id, progress);
}
for (session_id, notices) in
std::mem::take(&mut event_batch.session_workflow_notice_updates)
{
for notice in notices {
self.sessions.append_workflow_notice(&session_id, notice);
}
}
self.start_stacked_child_rebases_after_parent_merge(std::mem::take(
&mut event_batch.stacked_parent_merge_child_rebases,
))
.await;
let mut turned_parent_session_ids =
std::mem::take(&mut event_batch.stacked_parent_turns_completed);
turned_parent_session_ids.extend(std::mem::take(
&mut event_batch.stacked_parent_syncs_completed,
));
self.start_stacked_child_rebases_after_parent_turns(turned_parent_session_ids)
.await;
auto_start_reviews(
&mut self.review_cache,
&event_batch.session_ids,
self.sessions.state_mut(),
self.services.git_client(),
self.services.event_sender(),
self.settings.default_review_reasoning_level,
self.settings.default_review_selection,
)
.await;
app::review::hydrate_review_transients(
&self.review_cache,
self.sessions.state_mut(),
self.settings.default_review_selection.model(),
);
if let Some(sync_main_result) = event_batch.sync_main_result {
let sync_popup_context = self.sync_popup_context();
self.mode = Self::sync_main_popup_mode(sync_main_result, &sync_popup_context);
}
self.handle_merge_queue_progress(&event_batch.session_ids, &previous_session_states)
.await;
self.retain_valid_session_progress_messages();
self.sessions.retain_active_prompt_outputs();
if should_mark_dirty {
self.mark_dirty();
}
}
async fn apply_review_request_status_updates_and_synced_merges(
&mut self,
event_batch: &mut AppEventBatch,
sync_generation: u64,
) {
let review_request_status_updates =
std::mem::take(&mut event_batch.review_request_status_updates);
let applied_review_request_status_update = review_request_status_updates
.iter()
.any(|update| update.generation == sync_generation);
for review_request_status_update in review_request_status_updates {
if review_request_status_update.generation != sync_generation {
continue;
}
self.apply_review_request_status_update(review_request_status_update)
.await;
}
if applied_review_request_status_update {
self.publish_sync_context();
}
if let Some(Ok(sync_main_outcome)) = event_batch.sync_main_result.as_mut() {
let default_branch = sync_main_outcome.default_branch.clone();
sync_main_outcome.deferred_merged_session_ids = self
.finalize_merged_sessions_after_main_sync(&default_branch)
.await;
}
}
fn apply_session_review_comment_snapshot_updates(
&mut self,
updates: HashMap<SessionId, Result<ag_forge::ReviewCommentSnapshot, String>>,
) {
for (loaded_session_id, result) in updates {
let AppMode::ReviewComments {
comment_actions,
comment_error,
comment_snapshot,
is_loading_comments,
selected_comment_index,
session_id,
..
} = &mut self.mode
else {
continue;
};
if *session_id != loaded_session_id {
continue;
}
*is_loading_comments = false;
match result {
Ok(snapshot) => {
review_comment_selection::retain_actionable_selections(
comment_actions,
&snapshot,
);
*selected_comment_index = review_comment_selection::retarget_selected_index(
comment_snapshot.as_ref(),
*selected_comment_index,
&snapshot,
);
*comment_error = None;
*comment_snapshot = Some(snapshot);
}
Err(error) => {
*comment_error = Some(format!("Failed to load review comments: {error}"));
*comment_snapshot = None;
}
}
}
}
async fn start_stacked_child_rebases_after_parent_turns(
&mut self,
parent_session_ids: HashSet<SessionId>,
) {
for parent_session_id in parent_session_ids {
let failures = self
.sessions
.rebase_stacked_children_after_parent_turn(
&self.services,
parent_session_id.as_str(),
)
.await;
self.sessions
.append_stacked_rebase_failure_notices(failures, "Stacked child auto-sync failed");
}
}
async fn start_stacked_child_rebases_after_parent_merge(
&mut self,
child_session_ids: HashSet<SessionId>,
) {
if child_session_ids.is_empty() {
return;
}
let mut child_session_ids = child_session_ids.into_iter().collect::<Vec<_>>();
child_session_ids.sort();
let failures = self
.sessions
.rebase_sessions_after_parent_merge(&self.services, child_session_ids)
.await;
self.sessions.append_stacked_rebase_failure_notices(
failures,
"Stacked child post-merge sync failed",
);
}
async fn apply_app_event_effects(&mut self, effects: Vec<AppEventEffect>) {
for effect in effects {
match effect {
AppEventEffect::ReloadSessions => self.refresh_sessions_now().await,
AppEventEffect::ReloadProjects => self.reload_projects().await,
AppEventEffect::RefreshGitStatus => self.restart_git_status_task(),
AppEventEffect::ApplyReviewUpdates(review_updates) => {
let focused_review_persistence = apply_review_updates(
&mut self.review_cache,
self.sessions.state_mut(),
review_updates,
);
self.persist_focused_review_updates(focused_review_persistence)
.await;
}
}
}
}
fn apply_batch_runtime_updates(&mut self, event_batch: &mut AppEventBatch) {
if let Some(agent_clis) = event_batch.agent_cli_updates.take() {
self.services.replace_available_agent_clis(agent_clis);
}
if let Some(git_status_update) = &event_batch.git_status_update
&& git_status_update.generation == self.sync_handle.current_generation()
{
self.projects.set_git_status(git_status_update.status);
self.sessions
.replace_session_git_statuses(event_batch.session_git_status_updates.clone());
}
if let Some((generation, project_id, result)) = event_batch.assigned_issues.take()
&& project_id == self.projects.active_project_id()
&& self
.assigned_issues
.matches_loading_request(project_id, generation)
{
match result {
Ok(items) => self.replace_assigned_issues(project_id, items),
Err(message) => {
self.assigned_issue_selected_index = None;
self.assigned_issues = app::AssignedIssueState::Failed {
message,
project_id,
};
}
}
}
for issue_detail in std::mem::take(&mut event_batch.issue_details) {
self.apply_issue_detail_update(issue_detail);
}
for diff_preview_update in std::mem::take(&mut event_batch.diff_preview_updates) {
self.apply_diff_preview_update(&diff_preview_update);
}
if let Some((generation, project_id, result)) = event_batch.requested_reviews.take()
&& project_id == self.projects.active_project_id()
&& self
.requested_reviews
.matches_loading_request(project_id, generation)
{
match result {
Ok(items) => {
self.replace_requested_reviews(project_id, items);
}
Err(message) => {
self.requested_review_selected_index = None;
self.requested_reviews = app::RequestedReviewState::Failed {
message,
project_id,
};
}
}
}
for requested_review_comment_snapshot in
std::mem::take(&mut event_batch.requested_review_comment_snapshots)
{
self.apply_requested_review_comment_snapshot_update(requested_review_comment_snapshot);
}
self.apply_status_bar_updates(
event_batch.latest_available_version_update.as_ref(),
event_batch.update_status.take(),
);
}
fn apply_issue_detail_update(&mut self, update: IssueDetailUpdate) {
if update.project_id != self.projects.active_project_id()
|| update.generation != self.assigned_issue_generation
{
return;
}
let AppMode::IssueDetail {
detail,
error,
issue,
..
} = &mut self.mode
else {
return;
};
if issue.display_id != update.display_id {
return;
}
match update.result {
Ok(issue_detail) => {
*detail = Some(issue_detail);
*error = None;
}
Err(message) => {
*detail = None;
*error = Some(format!("Failed to load issue details: {message}"));
}
}
}
fn apply_diff_preview_update(&mut self, update: &DiffPreviewUpdate) {
match &mut self.mode {
AppMode::Diff {
preview,
scroll_cache,
session_id,
..
} if *session_id == update.session_id => {
if Self::resolve_diff_preview(preview, update) {
*scroll_cache = None;
}
}
AppMode::Help {
context:
HelpContext::Diff {
preview,
session_id,
..
},
..
} if *session_id == update.session_id => {
Self::resolve_diff_preview(preview, update);
}
_ => {}
}
}
fn resolve_diff_preview(preview: &mut DiffPreview, update: &DiffPreviewUpdate) -> bool {
if !matches!(
preview,
DiffPreview::Loading { path, request_id }
if path == &update.path && *request_id == update.request_id
) {
return false;
}
let unavailable = |reason| DiffPreview::Unavailable {
path: update.path.clone(),
reason,
request_id: update.request_id,
};
*preview = match &update.result {
Ok(ag_git::WorktreeFileContent::Text(content)) => DiffPreview::Ready {
content: content.clone(),
path: update.path.clone(),
request_id: update.request_id,
},
Ok(ag_git::WorktreeFileContent::Missing) => {
unavailable(DiffPreviewUnavailableReason::Deleted)
}
Ok(ag_git::WorktreeFileContent::Binary) => {
unavailable(DiffPreviewUnavailableReason::Binary)
}
Ok(ag_git::WorktreeFileContent::TooLarge) => {
unavailable(DiffPreviewUnavailableReason::TooLarge)
}
Err(error) => unavailable(DiffPreviewUnavailableReason::LoadFailed(error.clone())),
};
true
}
fn apply_requested_review_comment_snapshot_update(
&mut self,
update: RequestedReviewCommentSnapshotUpdate,
) {
let RequestedReviewCommentSnapshotUpdate {
display_id,
generation,
project_id,
result,
web_url,
} = update;
let was_in_flight =
self.requested_review_comment_fetches
.remove(&RequestedReviewCommentFetchKey {
display_id: display_id.clone(),
generation,
project_id,
web_url: web_url.clone(),
});
if !was_in_flight {
return;
}
if project_id != self.projects.active_project_id() {
return;
}
match result {
Ok(comment_snapshot) => {
self.cache_requested_review_comment_snapshot(
&display_id,
&web_url,
&comment_snapshot,
);
self.apply_requested_review_detail_comment_success(
&display_id,
&web_url,
comment_snapshot,
);
}
Err(error) => {
self.apply_requested_review_detail_comment_error(
&display_id,
&web_url,
format!("Failed to load review comments: {error}"),
);
}
}
}
fn apply_requested_review_detail_comment_success(
&mut self,
display_id: &str,
web_url: &str,
comment_snapshot: ag_forge::ReviewCommentSnapshot,
) {
let AppMode::ReviewDetail {
comment_error,
is_loading_comments,
review,
..
} = &mut self.mode
else {
return;
};
if review.display_id != display_id || review.web_url != web_url {
return;
}
review.comment_snapshot = Some(comment_snapshot);
*comment_error = None;
*is_loading_comments = false;
}
fn apply_requested_review_detail_comment_error(
&mut self,
display_id: &str,
web_url: &str,
error: String,
) {
let AppMode::ReviewDetail {
comment_error,
is_loading_comments,
review,
..
} = &mut self.mode
else {
return;
};
if review.display_id != display_id || review.web_url != web_url {
return;
}
if review.comment_snapshot.is_some() {
return;
}
*comment_error = Some(error);
*is_loading_comments = false;
}
fn sync_touched_sessions(&mut self, session_ids: &HashSet<SessionId>) {
for session_id in session_ids {
self.sessions.sync_session_from_handle(session_id);
}
self.sessions.clear_terminal_session_workers(session_ids);
}
fn apply_status_bar_updates(
&mut self,
latest_available_version_update: Option<&LatestAvailableVersionUpdate>,
update_status: Option<UpdateStatus>,
) {
if let Some(latest_available_version_update) = latest_available_version_update {
self.latest_available_version
.clone_from(&latest_available_version_update.latest_available_version);
}
if let Some(update_status) = update_status {
self.update_status = Some(update_status);
}
}
fn previous_session_states(
&self,
session_ids: &HashSet<SessionId>,
) -> HashMap<SessionId, Status> {
session_ids
.iter()
.filter_map(|session_id| {
self.sessions
.sessions()
.iter()
.find(|session| session.id == *session_id)
.map(|session| (session_id.clone(), session.status))
})
.collect()
}
fn apply_sync_main_conflict_resolution_started(&mut self, conflicted_files: &[String]) {
if !matches!(
self.mode,
AppMode::SyncBlockedPopup {
is_loading: true,
..
}
) {
return;
}
let sync_popup_context = self.sync_popup_context();
self.mode =
Self::sync_main_conflict_resolution_popup_mode(conflicted_files, &sync_popup_context);
}
fn update_session_redraw_versions(
&mut self,
session_update_versions: &HashMap<SessionId, u64>,
) -> bool {
let mut did_change = false;
for (session_id, version) in session_update_versions {
let previous_version = self
.last_seen_session_update_versions
.insert(session_id.clone(), *version);
if previous_version != Some(*version) {
did_change = true;
}
}
did_change
}
fn apply_batch_session_snapshot_updates(&mut self, event_batch: &mut AppEventBatch) {
for (session_id, session_agent) in std::mem::take(&mut event_batch.session_model_updates) {
self.sessions
.apply_session_model_updated(&session_id, session_agent);
}
for (session_id, personality_id) in
std::mem::take(&mut event_batch.session_personality_updates)
{
self.sessions
.apply_session_personality_updated(&session_id, personality_id);
}
for (session_id, reasoning_level) in
std::mem::take(&mut event_batch.session_reasoning_level_updates)
{
self.sessions
.apply_session_reasoning_level_updated(&session_id, reasoning_level);
}
for (session_id, speed_mode) in std::mem::take(&mut event_batch.session_speed_mode_updates)
{
self.sessions
.apply_session_speed_mode_updated(&session_id, speed_mode);
}
for (session_id, diff_stats) in std::mem::take(&mut event_batch.session_diff_stats_updates)
{
self.sessions
.apply_session_diff_stats_updated(&session_id, diff_stats);
}
for (session_id, generation) in
std::mem::take(&mut event_batch.session_title_generation_finished)
{
self.sessions
.clear_title_generation_task_if_matches(&session_id, generation);
}
for (session_id, entries) in std::mem::take(&mut event_batch.at_mention_entries_updates) {
let lookup_root = self.at_mention_lookup_root(&session_id);
self.sessions
.set_at_mention_index_for_root(lookup_root, entries.clone());
self.apply_prompt_at_mention_entries(&session_id, entries);
}
}
fn apply_session_progress_updates(
&mut self,
session_progress_updates: HashMap<SessionId, Option<String>>,
) {
for (session_id, progress_message) in session_progress_updates {
if let Some(progress_message) = progress_message {
self.session_progress_messages
.insert(session_id, progress_message);
} else {
self.session_progress_messages.remove(&session_id);
}
}
}
fn apply_agent_response_received(
&mut self,
session_id: &str,
turn_applied_state: &TurnAppliedState,
) {
if !self
.sessions
.sessions()
.iter()
.any(|session| session.id == session_id)
{
return;
}
self.sessions
.apply_turn_applied_state(session_id, turn_applied_state);
self.question_progress.remove(session_id);
let questions = turn_applied_state.questions.clone();
if questions.is_empty() {
return;
}
if self.is_viewing_session(session_id) {
self.mode = AppMode::Question {
at_mention_state: None,
selected_option_index: default_option_index(&questions, 0),
session_id: session_id.into(),
questions,
responses: Vec::new(),
current_index: 0,
focus: ChatFocus::Input,
input: InputState::default(),
scroll_offset: None,
};
}
}
fn is_viewing_session(&self, session_id: &str) -> bool {
match &self.mode {
AppMode::View {
session_id: view_id,
..
}
| AppMode::Prompt {
session_id: view_id,
..
}
| AppMode::Diff {
session_id: view_id,
..
}
| AppMode::ReviewComments {
session_id: view_id,
..
}
| AppMode::Question {
session_id: view_id,
..
}
| AppMode::LaunchConfigurationSelector {
restore_view:
ConfirmationViewMode {
session_id: view_id,
..
},
..
}
| AppMode::PublishBranchInput {
restore_view:
ConfirmationViewMode {
session_id: view_id,
..
},
..
}
| AppMode::ViewInfoPopup {
restore_view:
ConfirmationViewMode {
session_id: view_id,
..
},
..
} => view_id == session_id,
AppMode::List
| AppMode::IssueDetail { .. }
| AppMode::ReviewDetail { .. }
| AppMode::SessionCreation { .. }
| AppMode::PreCommitHookWarning { .. }
| AppMode::ProjectSwitcher { .. }
| AppMode::Confirmation { .. }
| AppMode::SyncBlockedPopup { .. }
| AppMode::Help { .. } => false,
}
}
async fn apply_published_branch_sync_update(
&mut self,
session_id: &str,
sync_update: PublishedBranchSyncUpdate,
) {
let PublishedBranchSyncUpdate {
persistent_notice,
sync_operation_id,
sync_status,
} = sync_update;
match sync_status {
PublishedBranchSyncStatus::InProgress => {
self.sessions
.start_published_branch_sync(session_id, sync_operation_id);
}
PublishedBranchSyncStatus::Idle
| PublishedBranchSyncStatus::Succeeded
| PublishedBranchSyncStatus::Failed => {
let was_applied = self.sessions.finish_published_branch_sync(
session_id,
&sync_operation_id,
persistent_notice.as_deref(),
);
if was_applied && let Some(persistent_notice) = persistent_notice {
SessionTaskService::persist_workflow_notice(
self.services.db(),
session_id,
&persistent_notice,
)
.await;
}
}
}
}
pub(crate) fn at_mention_lookup_root(&self, session_id: &str) -> PathBuf {
let project_working_dir = self.working_dir().to_path_buf();
self.sessions.session_for_id(session_id).map_or_else(
|| project_working_dir.clone(),
|session| {
let project_working_dir = project_working_dir.clone();
let session_folder = session.folder.clone();
let has_session_folder = self.services.fs_client().is_dir(session_folder.clone());
if has_session_folder {
return session_folder;
}
let parent_session_folder =
session
.parent_session_id
.as_ref()
.and_then(|parent_session_id| {
self.sessions
.session_for_id(parent_session_id)
.map(|parent_session| parent_session.folder.clone())
});
let has_parent_session_folder =
parent_session_folder
.as_ref()
.is_some_and(|parent_session_folder| {
self.services
.fs_client()
.is_dir(parent_session_folder.clone())
});
at_mention_lookup_root(
project_working_dir,
parent_session_folder,
has_parent_session_folder,
)
},
)
}
fn apply_prompt_at_mention_entries(&mut self, session_id: &str, entries: Vec<FileEntry>) {
let (at_mention_state, has_query) = match &mut self.mode {
AppMode::Prompt {
at_mention_state,
input,
session_id: mode_session_id,
..
} if mode_session_id == session_id => {
(at_mention_state, input.at_mention_query().is_some())
}
AppMode::Question {
at_mention_state,
input,
session_id: mode_session_id,
..
} if mode_session_id == session_id => {
(at_mention_state, input.at_mention_query().is_some())
}
_ => return,
};
if !has_query {
return;
}
if let Some(state) = at_mention_state.as_mut() {
state.all_entries = entries;
state.selected_index = 0;
return;
}
*at_mention_state = Some(PromptAtMentionState::new(entries));
}
#[cfg(test)]
pub(super) fn apply_review_update(
&mut self,
session_id: &str,
review_update: app::review::ReviewUpdate,
) {
let mut review_updates = HashMap::new();
review_updates.insert(SessionId::from(session_id), review_update);
apply_review_updates(
&mut self.review_cache,
self.sessions.state_mut(),
review_updates,
);
}
async fn persist_focused_review_updates(
&self,
focused_review_persistence: Vec<FocusedReviewPersistence>,
) {
for persistence_update in focused_review_persistence {
let diff_hash = persistence_update
.diff_hash
.map(|diff_hash| diff_hash.to_string());
let _ = self
.services
.db()
.sessions()
.update_session_focused_review(
persistence_update.session_id.as_str(),
diff_hash,
persistence_update.text,
)
.await;
}
}
#[cfg(test)]
pub(super) async fn auto_start_reviews(&mut self, session_ids: &HashSet<SessionId>) {
auto_start_reviews(
&mut self.review_cache,
session_ids,
self.sessions.state_mut(),
self.services.git_client(),
self.services.event_sender(),
self.settings.default_review_reasoning_level,
self.settings.default_review_selection,
)
.await;
}
pub(super) async fn apply_branch_publish_action_update(
&mut self,
branch_publish_action_update: BranchPublishActionUpdate,
) {
let BranchPublishActionUpdate { result, session_id } = branch_publish_action_update;
match result {
Ok(BranchPublishTaskSuccess::Pushed {
branch_name,
review_request_creation,
upstream_reference,
}) => {
self.sessions
.apply_published_upstream_ref(&session_id, upstream_reference);
let result_message = TransientMessageBody::Markdown(format!(
"**{}**\n\n{}",
Self::branch_publish_success_title(PublishBranchAction::Push),
Self::branch_publish_success_message(
&branch_name,
review_request_creation.as_ref(),
)
));
self.sessions
.finish_branch_publish(&session_id, result_message);
}
Ok(BranchPublishTaskSuccess::PullRequestPublished {
review_request,
upstream_reference,
..
}) => {
self.sessions
.apply_published_upstream_ref(&session_id, upstream_reference);
self.sessions
.apply_review_request(&session_id, review_request.clone());
let persistent_notice = Self::review_request_created_notice(&review_request);
if self
.sessions
.finish_review_request_publish(&session_id, &persistent_notice)
{
SessionTaskService::persist_workflow_notice(
self.services.db(),
&session_id,
&persistent_notice,
)
.await;
}
}
Err(failure) => {
let result_message = TransientMessageBody::Markdown(format!(
"**{}**\n\n{}",
failure.title, failure.message
));
self.sessions
.finish_branch_publish(&session_id, result_message);
}
}
}
pub(super) async fn apply_review_request_status_update(
&mut self,
review_request_status_update: ReviewRequestStatusUpdate,
) {
let ReviewRequestStatusUpdate {
generation: _,
result,
session_id,
} = review_request_status_update;
let Ok(task_result) = result else {
return;
};
if let Some(summary) = task_result.summary {
let _ = self
.sessions
.store_review_request_summary(&self.services, &session_id, summary)
.await;
}
match task_result.outcome {
crate::app::session::SyncReviewRequestOutcome::Merged {
session_head_hash, ..
} => {
if let Some(warning) = self
.record_externally_merged_session(&session_id, session_head_hash)
.await
{
self.append_output_for_session(
&session_id,
&TranscriptNotice::ReviewRequestSyncWarning.format(warning),
)
.await;
}
}
crate::app::session::SyncReviewRequestOutcome::Closed { .. } => {
self.cancel_externally_closed_session(&session_id).await;
}
crate::app::session::SyncReviewRequestOutcome::Open { .. }
| crate::app::session::SyncReviewRequestOutcome::NoReviewRequest => {}
}
}
pub(super) async fn record_externally_merged_session(
&self,
session_id: &str,
session_head_hash: Option<String>,
) -> Option<String> {
let Ok(handles) = self.sessions.session_handles_or_err(session_id) else {
return None;
};
let mut warnings = Vec::new();
if let Some(session_head_hash) = session_head_hash
&& let Err(error) = self
.services
.db()
.sessions()
.update_session_merged_commit_hash(session_id, Some(session_head_hash))
.await
{
warnings.push(format!("Merged commit hash persistence failed: {error}"));
}
let status_transition =
StatusTransition::from_services(&self.services, handles, session_id);
if !status_transition.apply(Status::Merged).await {
warnings.push("Could not mark the merged session read-only".to_string());
}
(!warnings.is_empty()).then(|| warnings.join("\n"))
}
async fn finalize_merged_sessions_after_main_sync(
&mut self,
default_branch: &str,
) -> Vec<SessionId> {
let mut deferred_session_ids = Vec::new();
let merged_session_ids = self
.sessions
.sessions()
.iter()
.filter(|session| {
self.sessions
.session_handles_or_err(&session.id)
.ok()
.and_then(|handles| handles.status.lock().ok().map(|status| *status))
== Some(Status::Merged)
})
.map(|session| session.id.clone())
.collect::<Vec<_>>();
let mut session_ids = Vec::new();
for session_id in merged_session_ids {
match self
.merged_session_reached_synced_branch(&session_id, default_branch)
.await
{
Ok(true) => session_ids.push(session_id),
Ok(false) => {}
Err(error) => {
self.append_output_for_session(
&session_id,
&TranscriptNotice::ReviewRequestSyncWarning
.format(format!("Durable restack marker load failed: {error}")),
)
.await;
deferred_session_ids.push(session_id);
}
}
}
for session_id in session_ids {
let session_head_hash = match self
.services
.db()
.sessions()
.load_session_merged_commit_hash(&session_id)
.await
{
Ok(session_head_hash) => session_head_hash,
Err(error) => {
self.append_output_for_session(
&session_id,
&TranscriptNotice::ReviewRequestSyncWarning
.format(format!("Merged commit hash load failed: {error}")),
)
.await;
deferred_session_ids.push(session_id);
continue;
}
};
if let Some(warning) = self
.complete_externally_merged_session(&session_id, session_head_hash)
.await
{
self.append_output_for_session(
&session_id,
&TranscriptNotice::ReviewRequestSyncWarning.format(warning),
)
.await;
}
if self
.sessions
.session_handles_or_err(&session_id)
.ok()
.and_then(|handles| handles.status.lock().ok().map(|status| *status))
== Some(Status::Merged)
{
deferred_session_ids.push(session_id);
}
}
deferred_session_ids
}
async fn merged_session_reached_synced_branch(
&self,
session_id: &SessionId,
default_branch: &str,
) -> Result<bool, DbError> {
let Ok(session) = self.sessions.session_or_err(session_id) else {
return Ok(false);
};
let Some(review_request) = session.review_request.as_ref() else {
return Ok(false);
};
if review_request.summary.target_branch == default_branch {
return Ok(true);
}
if let Some(parent_session_id) = session.parent_session_id.as_ref() {
let Some(parent_session) = self
.sessions
.sessions()
.iter()
.find(|candidate| candidate.id == *parent_session_id)
else {
return Ok(false);
};
let Some(parent_review_request) = parent_session.review_request.as_ref() else {
return Ok(false);
};
return Ok(review_request.summary.target_branch
== parent_review_request.summary.source_branch
&& parent_review_request.summary.target_branch == default_branch
&& self
.sessions
.session_handles_or_err(parent_session_id)
.ok()
.and_then(|handles| handles.status.lock().ok().map(|status| *status))
== Some(Status::Merged));
}
if session.base_branch != default_branch {
return Ok(false);
}
let stack_base_commit_hash = self
.services
.db()
.sessions()
.get_session_stack_base_commit_hash(session_id)
.await?;
Ok(stack_base_commit_hash.is_some())
}
pub(super) async fn complete_externally_merged_session(
&self,
session_id: &str,
session_head_hash: Option<String>,
) -> Option<String> {
let Ok(session) = self.sessions.session_or_err(session_id) else {
return None;
};
let Ok(handles) = self.sessions.session_handles_or_err(session_id) else {
return None;
};
let mut warnings = Vec::new();
let folder = session.folder.clone();
let base_branch = session.base_branch.clone();
let source_branch = crate::app::session::session_branch(session_id);
let app_event_tx = self.services.event_sender();
match crate::app::session::SessionManager::restack_child_sessions_after_parent_merge(
self.services.db(),
session_id,
&base_branch,
session_head_hash,
)
.await
{
Ok(child_session_ids) => {
crate::app::session::SessionManager::emit_stacked_parent_merge_completed(
&app_event_tx,
child_session_ids,
);
}
Err(error) => {
return Some(format!("Stacked child restack intent failed: {error}"));
}
}
let status_transition =
StatusTransition::from_services(&self.services, handles, session_id);
let status_applied = status_transition.apply(Status::Done).await;
if !status_applied {
warnings.push("Could not archive the merged session".to_string());
return Some(warnings.join("\n"));
}
self.spawn_externally_merged_session_cleanup(session_id, folder, source_branch, handles);
(!warnings.is_empty()).then(|| warnings.join("\n"))
}
fn spawn_externally_merged_session_cleanup(
&self,
session_id: &str,
folder: PathBuf,
source_branch: String,
handles: &SessionHandles,
) {
let app_event_tx = self.services.event_sender();
let db = self.services.db().clone();
let fs_client = self.services.fs_client();
let git_client = self.services.git_client();
let session_id = SessionId::from(session_id);
let session_update_versions = self.services.session_update_versions();
let transcript = Arc::clone(&handles.transcript);
let cleanup_task = tokio::spawn(async move {
if let Err(error) =
crate::app::session::SessionManager::cleanup_merged_session_worktree(
folder,
fs_client,
git_client,
source_branch,
None,
)
.await
{
let warning = TranscriptNotice::ReviewRequestSyncWarning
.format(format!("Worktree cleanup failed: {error}"));
SessionTaskService::append_workflow_notice(
&transcript,
&db,
&app_event_tx,
&session_update_versions,
session_id.as_str(),
&warning,
)
.await;
}
});
self.services.track_cleanup_task(cleanup_task);
}
async fn cancel_externally_closed_session(&self, session_id: &str) {
let Ok(handles) = self.sessions.session_handles_or_err(session_id) else {
return;
};
let status_transition =
StatusTransition::from_services(&self.services, handles, session_id);
let _ = status_transition.apply(Status::Canceled).await;
self.sessions
.cancel_stacked_child_sessions(&self.services, session_id)
.await;
}
pub(super) fn view_info_popup_mode(
title: String,
message: String,
is_loading: bool,
loading_label: String,
restore_view: ConfirmationViewMode,
) -> AppMode {
AppMode::ViewInfoPopup {
is_loading,
loading_label,
message,
restore_view,
title,
}
}
pub(super) fn branch_publish_loading_label(
publish_branch_action: PublishBranchAction,
) -> String {
branch_publish_loading_label_text(publish_branch_action)
}
pub(super) fn branch_publish_success_title(
publish_branch_action: PublishBranchAction,
) -> String {
branch_publish_success_title_text(publish_branch_action)
}
pub(super) fn branch_publish_success_message(
branch_name: &str,
review_request_creation: Option<&crate::app::branch_publish::ReviewRequestCreationInfo>,
) -> String {
crate::app::branch_publish::branch_push_success_message(
branch_name,
review_request_creation,
)
}
pub(super) fn review_request_created_notice(
review_request: &crate::domain::session::ReviewRequest,
) -> String {
review_request_created_notice_text(review_request)
}
pub(super) fn sync_main_popup_mode(
sync_main_result: Result<SyncMainOutcome, SyncSessionStartError>,
sync_popup_context: &SyncPopupContext,
) -> AppMode {
match sync_main_result {
Ok(sync_main_outcome) => AppMode::SyncBlockedPopup {
project_name: Some(sync_popup_context.project_name.clone()),
default_branch: Some(sync_popup_context.default_branch.clone()),
is_loading: false,
message: Self::sync_success_message(&sync_main_outcome),
title: "Sync complete".to_string(),
},
Err(sync_error @ SyncSessionStartError::MainHasUncommittedChanges { .. }) => {
AppMode::SyncBlockedPopup {
project_name: Some(sync_popup_context.project_name.clone()),
default_branch: Some(sync_popup_context.default_branch.clone()),
is_loading: false,
message: sync_error.detail_message(),
title: "Sync blocked".to_string(),
}
}
Err(sync_error @ SyncSessionStartError::Other(_)) => AppMode::SyncBlockedPopup {
project_name: Some(sync_popup_context.project_name.clone()),
default_branch: Some(sync_popup_context.default_branch.clone()),
is_loading: false,
message: Self::sync_failure_message(&sync_error),
title: "Sync failed".to_string(),
},
}
}
pub(super) fn sync_main_conflict_resolution_popup_mode(
conflicted_files: &[String],
sync_popup_context: &SyncPopupContext,
) -> AppMode {
AppMode::SyncBlockedPopup {
project_name: Some(sync_popup_context.project_name.clone()),
default_branch: Some(sync_popup_context.default_branch.clone()),
is_loading: true,
message: Self::sync_conflict_resolution_message(conflicted_files),
title: "Resolving conflicts".to_string(),
}
}
fn sync_conflict_resolution_message(conflicted_files: &[String]) -> String {
let file_list = conflicted_files
.iter()
.map(|file| format!("- {file}"))
.collect::<Vec<String>>()
.join("\n");
format!("Resolving conflicts during sync.\n\nConflicted files:\n{file_list}")
}
fn sync_success_message(sync_main_outcome: &SyncMainOutcome) -> String {
let pulled_summary = Self::sync_commit_summary("pulled", sync_main_outcome.pulled_commits);
let pulled_titles =
Self::sync_pulled_commit_titles_summary(&sync_main_outcome.pulled_commit_titles);
let pushed_titles =
Self::sync_pushed_commit_titles_summary(&sync_main_outcome.pushed_commit_titles);
let pushed_summary = Self::sync_commit_summary("pushed", sync_main_outcome.pushed_commits);
let conflict_summary =
Self::sync_conflict_summary(&sync_main_outcome.resolved_conflict_files);
let mut message = sync_message::format_sync_success_message(
&pulled_summary,
&pulled_titles,
&pushed_summary,
&pushed_titles,
&conflict_summary,
);
if !sync_main_outcome.deferred_merged_session_ids.is_empty() {
let session_ids = sync_main_outcome
.deferred_merged_session_ids
.iter()
.map(|session_id| format!("- `{session_id}`"))
.collect::<Vec<_>>()
.join("\n");
message.push_str(
"\n\n## Merged sessions still waiting\nThese sessions could not be archived or \
restacked. Review their workflow warning, then retry the sync:\n",
);
message.push_str(&session_ids);
}
message
}
fn sync_pulled_commit_titles_summary(pulled_commit_titles: &[String]) -> String {
if pulled_commit_titles.is_empty() {
return String::new();
}
pulled_commit_titles
.iter()
.map(|title| format!(" - {title}"))
.collect::<Vec<String>>()
.join("\n")
}
fn sync_pushed_commit_titles_summary(pushed_commit_titles: &[String]) -> String {
if pushed_commit_titles.is_empty() {
return String::new();
}
pushed_commit_titles
.iter()
.map(|title| format!(" - {title}"))
.collect::<Vec<String>>()
.join("\n")
}
fn sync_failure_message(sync_error: &SyncSessionStartError) -> String {
let detail_message = sync_error.detail_message();
if !is_git_push_authentication_error(&detail_message) {
return detail_message;
}
git_push_authentication_message(
detected_forge_kind_from_git_push_error(&detail_message),
"run sync again",
)
}
fn sync_commit_summary(direction: &str, commit_count: Option<u32>) -> String {
match commit_count {
Some(1) => format!("1 commit {direction}"),
Some(commit_count) => format!("{commit_count} commits {direction}"),
None => format!("commits {direction}: unknown"),
}
}
fn sync_conflict_summary(resolved_conflict_files: &[String]) -> String {
if resolved_conflict_files.is_empty() {
return "no conflicts fixed".to_string();
}
format!("conflicts fixed: {}", resolved_conflict_files.join(", "))
}
}
#[cfg(test)]
mod tests {
use ag_forge::{
ReviewComment, ReviewCommentAnchorSide, ReviewCommentSnapshot, ReviewCommentThread,
};
use super::*;
use crate::domain::session::{
ForgeKind, ReviewRequest, ReviewRequestState, ReviewRequestSummary,
};
#[tokio::test]
async fn merged_branch_eligibility_rejects_incomplete_session_context() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let review_request = ReviewRequest {
last_refreshed_at: 0,
summary: ReviewRequestSummary {
display_id: "#23".to_string(),
forge_kind: ForgeKind::GitHub,
source_branch: "wt/child".to_string(),
state: ReviewRequestState::Merged,
status_summary: None,
target_branch: "wt/parent".to_string(),
title: "Merged child".to_string(),
web_url: "https://example.test/pull/23".to_string(),
},
};
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id("session-without-review")
.build(),
);
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id("child-with-missing-parent")
.parent_session_id(Some("missing-parent".into()))
.review_request(Some(review_request.clone()))
.build(),
);
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id("parent-without-review")
.build(),
);
app.sessions.push_session(
crate::test_support::SessionFixtureBuilder::new()
.id("child-with-unlinked-parent")
.parent_session_id(Some("parent-without-review".into()))
.review_request(Some(review_request))
.build(),
);
let missing_session = app
.merged_session_reached_synced_branch(&"missing-session".into(), "main")
.await
.expect("missing session eligibility should not fail");
let session_without_review = app
.merged_session_reached_synced_branch(&"session-without-review".into(), "main")
.await
.expect("unlinked session eligibility should not fail");
let child_with_missing_parent = app
.merged_session_reached_synced_branch(&"child-with-missing-parent".into(), "main")
.await
.expect("missing parent eligibility should not fail");
let child_with_unlinked_parent = app
.merged_session_reached_synced_branch(&"child-with-unlinked-parent".into(), "main")
.await
.expect("unlinked parent eligibility should not fail");
assert!(!missing_session);
assert!(!session_without_review);
assert!(!child_with_missing_parent);
assert!(!child_with_unlinked_parent);
}
#[tokio::test]
async fn test_issue_detail_success_preserves_action_error() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let project_id = app.projects.active_project_id();
let generation = app.assigned_issue_generation;
app.mode = AppMode::IssueDetail {
action_error: Some("Failed to start issue session: unavailable".to_string()),
detail: None,
error: None,
issue: ag_forge::AssignedIssue {
display_id: "#124".to_string(),
repository: "agentty-xyz/agentty".to_string(),
title: "Keep issue details reachable".to_string(),
updated_at: None,
web_url: "https://github.com/agentty-xyz/agentty/issues/124".to_string(),
},
scroll_offset: 0,
};
app.apply_issue_detail_update(IssueDetailUpdate {
display_id: "#124".to_string(),
generation,
project_id,
result: Ok(ag_forge::IssueDetail {
assignees: Vec::new(),
author: "octocat".to_string(),
body: Some("Loaded after the action failed.".to_string()),
created_at: None,
display_id: "#124".to_string(),
labels: Vec::new(),
repository: "agentty-xyz/agentty".to_string(),
state: "OPEN".to_string(),
title: "Keep issue details reachable".to_string(),
updated_at: None,
web_url: "https://github.com/agentty-xyz/agentty/issues/124".to_string(),
}),
});
assert!(matches!(
app.mode,
AppMode::IssueDetail {
action_error: Some(ref action_error),
detail: Some(_),
error: None,
..
} if action_error == "Failed to start issue session: unavailable"
));
}
#[tokio::test]
async fn test_session_review_comment_result_updates_matching_open_page() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.mode = AppMode::ReviewComments {
comment_actions: Vec::new(),
comment_error: None,
comment_snapshot: None,
diff: String::new(),
is_loading_comments: true,
selected_comment_index: 0,
session_id: "session-id".into(),
scroll_offset: 0,
};
app.apply_app_events(AppEvent::SessionReviewCommentSnapshotLoaded {
result: Ok(ag_forge::ReviewCommentSnapshot::default()),
session_id: "session-id".into(),
})
.await;
assert!(matches!(
app.mode,
AppMode::ReviewComments {
comment_error: None,
comment_snapshot: Some(_),
is_loading_comments: false,
..
}
));
}
#[tokio::test]
async fn test_session_review_comment_refresh_retargets_selected_thread() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
let previous_snapshot = review_comment_snapshot([
review_comment_thread("selected", false),
review_comment_thread("other", false),
]);
let updated_snapshot = review_comment_snapshot([
review_comment_thread("selected", true),
review_comment_thread("other", false),
]);
app.mode = AppMode::ReviewComments {
comment_actions: vec![
ReviewCommentActionSelection {
action: ReviewCommentAction::Address,
thread_id: "selected".to_string(),
},
ReviewCommentActionSelection {
action: ReviewCommentAction::Deny,
thread_id: "other".to_string(),
},
],
comment_error: None,
comment_snapshot: Some(previous_snapshot),
diff: String::new(),
is_loading_comments: true,
selected_comment_index: 0,
session_id: "session-id".into(),
scroll_offset: 0,
};
app.apply_app_events(AppEvent::SessionReviewCommentSnapshotLoaded {
result: Ok(updated_snapshot),
session_id: "session-id".into(),
})
.await;
assert!(matches!(
app.mode,
AppMode::ReviewComments {
ref comment_actions,
comment_snapshot: Some(ref snapshot),
selected_comment_index: 1,
..
} if review_comment_selection::selected_thread_id(snapshot, 1) == Some("selected")
&& comment_actions == &[ReviewCommentActionSelection {
action: ReviewCommentAction::Deny,
thread_id: "other".to_string(),
}]
));
}
#[tokio::test]
async fn test_session_review_comment_result_ignores_stale_pages_and_surfaces_errors() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
app.apply_app_events(AppEvent::SessionReviewCommentSnapshotLoaded {
result: Ok(ag_forge::ReviewCommentSnapshot::default()),
session_id: "closed-session".into(),
})
.await;
app.mode = AppMode::ReviewComments {
comment_actions: Vec::new(),
comment_error: None,
comment_snapshot: None,
diff: String::new(),
is_loading_comments: true,
selected_comment_index: 0,
session_id: "open-session".into(),
scroll_offset: 0,
};
app.apply_app_events(AppEvent::SessionReviewCommentSnapshotLoaded {
result: Ok(ag_forge::ReviewCommentSnapshot::default()),
session_id: "stale-session".into(),
})
.await;
app.apply_app_events(AppEvent::SessionReviewCommentSnapshotLoaded {
result: Err("authentication failed".to_string()),
session_id: "open-session".into(),
})
.await;
assert!(app.is_viewing_session("open-session"));
assert!(!app.is_viewing_session("stale-session"));
assert!(matches!(
app.mode,
AppMode::ReviewComments {
comment_error: Some(ref error),
comment_snapshot: None,
is_loading_comments: false,
..
} if error == "Failed to load review comments: authentication failed"
));
}
fn review_comment_snapshot<const THREAD_COUNT: usize>(
threads: [ReviewCommentThread; THREAD_COUNT],
) -> ReviewCommentSnapshot {
ReviewCommentSnapshot {
pr_level_comments: Vec::new(),
threads: Vec::from(threads),
}
}
fn review_comment_thread(id: &str, is_resolved: bool) -> ReviewCommentThread {
ReviewCommentThread {
anchor_side: ReviewCommentAnchorSide::New,
comments: vec![ReviewComment {
author: "reviewer".to_string(),
body: "Review comment".to_string(),
}],
id: id.to_string(),
is_outdated: Some(false),
is_resolved,
line: Some(1),
path: "src/main.rs".to_string(),
start_line: None,
}
}
#[test]
fn test_refresh_sessions_batch_sets_only_session_reload_scope() {
let mut event_batch = AppEventBatch::default();
event_batch.collect_event(AppEvent::RefreshSessions);
assert!(event_batch.should_reload_sessions);
assert!(!event_batch.should_reload_projects);
}
#[test]
fn test_refresh_projects_batch_sets_only_project_reload_scope() {
let mut event_batch = AppEventBatch::default();
event_batch.collect_event(AppEvent::RefreshProjects);
assert!(event_batch.should_reload_projects);
assert!(!event_batch.should_reload_sessions);
}
#[test]
fn test_requested_review_batch_keeps_newer_generation_when_stale_event_arrives_later() {
let mut event_batch = AppEventBatch::default();
let newer_event = AppEvent::RequestedReviewsLoaded {
generation: 2,
project_id: 42,
result: Ok(Vec::new()),
};
let stale_event = AppEvent::RequestedReviewsLoaded {
generation: 1,
project_id: 42,
result: Err("stale failure".to_string()),
};
event_batch.collect_event(newer_event);
event_batch.collect_event(stale_event);
let (generation, project_id, result) = event_batch
.requested_reviews
.expect("newer requested-review event should be retained");
assert_eq!(generation, 2);
assert_eq!(project_id, 42);
assert_eq!(result.expect("newer result should be successful").len(), 0);
}
#[test]
fn test_issue_detail_batch_retains_same_generation_results_in_arrival_order() {
let mut event_batch = AppEventBatch::default();
let visible_issue_event = AppEvent::IssueDetailLoaded {
display_id: "#124".to_string(),
generation: 1,
project_id: 42,
result: Err("visible issue result".to_string()),
};
let previous_issue_event = AppEvent::IssueDetailLoaded {
display_id: "#123".to_string(),
generation: 1,
project_id: 42,
result: Err("previous issue result".to_string()),
};
event_batch.collect_event(visible_issue_event);
event_batch.collect_event(previous_issue_event);
assert_eq!(event_batch.issue_details.len(), 2);
assert_eq!(event_batch.issue_details[0].display_id, "#124");
assert_eq!(event_batch.issue_details[1].display_id, "#123");
}
#[test]
fn test_assigned_issues_batch_changes_observable_state() {
let mut event_batch = AppEventBatch::default();
event_batch.collect_event(AppEvent::AssignedIssuesLoaded {
generation: 1,
project_id: 42,
result: Ok(Vec::new()),
});
assert!(event_batch.drain_reduction_plan().changes_observable_state);
}
#[test]
fn test_issue_detail_batch_changes_observable_state() {
let mut event_batch = AppEventBatch::default();
event_batch.collect_event(AppEvent::IssueDetailLoaded {
display_id: "#124".to_string(),
generation: 1,
project_id: 42,
result: Err("issue detail failure".to_string()),
});
assert!(event_batch.drain_reduction_plan().changes_observable_state);
}
#[test]
fn reduction_plan_orders_external_effects_without_running_them() {
let mut event_batch = AppEventBatch {
should_refresh_git_status: true,
should_reload_projects: true,
should_reload_sessions: true,
..AppEventBatch::default()
};
let reduction_plan = event_batch.drain_reduction_plan();
assert_eq!(
reduction_plan,
AppEventReductionPlan {
after_snapshot_effects: Vec::new(),
before_snapshot_effects: vec![
AppEventEffect::ReloadSessions,
AppEventEffect::ReloadProjects,
AppEventEffect::RefreshGitStatus,
],
changes_observable_state: true,
}
);
}
#[test]
fn reduction_plan_keeps_an_empty_batch_pure_and_invisible() {
let mut event_batch = AppEventBatch::default();
let reduction_plan = event_batch.drain_reduction_plan();
assert_eq!(
reduction_plan,
AppEventReductionPlan {
after_snapshot_effects: Vec::new(),
before_snapshot_effects: Vec::new(),
changes_observable_state: false,
}
);
}
#[test]
fn reduction_plan_orders_review_persistence_after_snapshot_updates() {
let mut event_batch = AppEventBatch::default();
event_batch.collect_event(AppEvent::ReviewPrepared {
diff_hash: 42,
review_text: "review output".to_string(),
session_id: "session-1".into(),
});
let reduction_plan = event_batch.drain_reduction_plan();
assert_eq!(
reduction_plan.after_snapshot_effects,
vec![AppEventEffect::ApplyReviewUpdates(HashMap::from([(
"session-1".into(),
ReviewUpdate {
diff_hash: 42,
result: Ok("review output".to_string()),
},
)]))]
);
assert!(reduction_plan.before_snapshot_effects.is_empty());
assert!(reduction_plan.changes_observable_state);
}
#[tokio::test]
async fn test_diff_preview_events_map_all_worktree_results() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
let outcomes = [
Ok(ag_git::WorktreeFileContent::Text("# Preview".to_string())),
Ok(ag_git::WorktreeFileContent::Missing),
Ok(ag_git::WorktreeFileContent::Binary),
Ok(ag_git::WorktreeFileContent::TooLarge),
Err("read failed".to_string()),
];
let resolve_diff_state = |mode: &AppMode| match mode {
AppMode::Diff {
preview,
scroll_cache,
..
} => Some((preview.clone(), scroll_cache.is_none())),
_ => None,
};
let mut resolved_previews = Vec::new();
for (request_id, result) in (1_u64..).zip(outcomes) {
app.mode = AppMode::Diff {
diff: "diff --git a/README.md b/README.md\n+preview".to_string(),
file_explorer_selected_index: 0,
preview: DiffPreview::Loading {
path: "README.md".to_string(),
request_id,
},
restore: None,
scroll_cache: Some(crate::presentation::app_mode::DiffScrollCache {
content_area: crate::presentation::app_mode::ViewportRect {
height: 24,
width: 80,
x: 0,
y: 0,
},
file_explorer_selected_index: 0,
max_scroll_offset: 4,
}),
scroll_offset: 2,
session_id: "session-id".into(),
};
app.apply_app_events(AppEvent::DiffPreviewLoaded {
path: "README.md".to_string(),
request_id,
result,
session_id: "session-id".into(),
})
.await;
let (preview, scroll_cache_cleared) = resolve_diff_state(&app.mode)
.expect("diff preview result should preserve diff mode");
assert!(scroll_cache_cleared);
resolved_previews.push(preview);
}
assert!(resolve_diff_state(&AppMode::List).is_none());
assert_eq!(resolved_previews.len(), 5);
assert!(matches!(
&resolved_previews[0],
DiffPreview::Ready { content, .. } if content == "# Preview"
));
assert!(matches!(
&resolved_previews[1],
DiffPreview::Unavailable {
reason: DiffPreviewUnavailableReason::Deleted,
..
}
));
assert!(matches!(
&resolved_previews[2],
DiffPreview::Unavailable {
reason: DiffPreviewUnavailableReason::Binary,
..
}
));
assert!(matches!(
&resolved_previews[3],
DiffPreview::Unavailable {
reason: DiffPreviewUnavailableReason::TooLarge,
..
}
));
assert!(matches!(
&resolved_previews[4],
DiffPreview::Unavailable {
reason: DiffPreviewUnavailableReason::LoadFailed(error),
..
} if error == "read failed"
));
}
#[tokio::test]
async fn test_diff_preview_event_ignores_stale_mode_session_path_and_request() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
let loading = || DiffPreview::Loading {
path: "README.md".to_string(),
request_id: 4,
};
let event = |path: &str, request_id: u64, session_id: &str| AppEvent::DiffPreviewLoaded {
path: path.to_string(),
request_id,
result: Ok(ag_git::WorktreeFileContent::Text("stale".to_string())),
session_id: session_id.into(),
};
let diff_mode = |preview| AppMode::Diff {
diff: "diff".to_string(),
file_explorer_selected_index: 0,
preview,
restore: None,
scroll_cache: None,
scroll_offset: 0,
session_id: "session-id".into(),
};
app.mode = diff_mode(loading());
app.apply_app_events(event("OTHER.md", 4, "session-id"))
.await;
let stale_path_ignored = matches!(
app.mode,
AppMode::Diff {
preview: DiffPreview::Loading { .. },
..
}
);
app.mode = diff_mode(loading());
app.apply_app_events(event("README.md", 5, "session-id"))
.await;
let stale_request_ignored = matches!(
app.mode,
AppMode::Diff {
preview: DiffPreview::Loading { .. },
..
}
);
app.mode = diff_mode(loading());
app.apply_app_events(event("README.md", 4, "other-session"))
.await;
let stale_session_ignored = matches!(
app.mode,
AppMode::Diff {
preview: DiffPreview::Loading { .. },
..
}
);
app.mode = AppMode::List;
app.apply_app_events(event("README.md", 4, "session-id"))
.await;
assert!(stale_path_ignored);
assert!(stale_request_ignored);
assert!(stale_session_ignored);
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_diff_preview_event_resolves_while_help_is_open() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Help {
context: HelpContext::Diff {
diff: "diff --git a/README.md b/README.md\n+preview".to_string(),
file_explorer_selected_index: 0,
preview: DiffPreview::Loading {
path: "README.md".to_string(),
request_id: 8,
},
restore: None,
scroll_offset: 0,
session_id: "session-id".into(),
},
scroll_offset: 0,
};
app.apply_app_events(AppEvent::DiffPreviewLoaded {
path: "README.md".to_string(),
request_id: 8,
result: Ok(ag_git::WorktreeFileContent::Text("# Ready".to_string())),
session_id: "session-id".into(),
})
.await;
assert!(matches!(
app.mode,
AppMode::Help {
context: HelpContext::Diff {
preview: DiffPreview::Ready { ref content, .. },
..
},
..
} if content == "# Ready"
));
}
}