use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::Arc;
use tokio::sync::mpsc;
use super::core::AppEvent;
use super::task;
use crate::app::session_state::SessionState;
use crate::domain::agent::AgentModel;
use crate::domain::session::{SessionId, Status};
use crate::infra::db::SessionFocusedReviewRow;
use crate::infra::git::GitClient;
use crate::ui::state::app_mode::{AppMode, ConfirmationViewMode, HelpContext};
#[derive(Debug)]
pub(crate) enum ReviewCacheEntry {
Loading {
diff_hash: u64,
},
Ready {
diff_hash: u64,
text: String,
},
Failed {
diff_hash: u64,
error: String,
},
Suppressed {
diff_hash: u64,
},
}
impl ReviewCacheEntry {
pub(crate) fn diff_hash(&self) -> u64 {
match self {
Self::Loading { diff_hash }
| Self::Ready { diff_hash, .. }
| Self::Failed { diff_hash, .. }
| Self::Suppressed { diff_hash } => *diff_hash,
}
}
pub(crate) fn from_result(diff_hash: u64, result: &Result<String, String>) -> Self {
match result {
Ok(review_text) => Self::Ready {
diff_hash,
text: review_text.clone(),
},
Err(error) => Self::Failed {
diff_hash,
error: error.clone(),
},
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ReviewUpdate {
pub(crate) diff_hash: u64,
pub(crate) result: Result<String, String>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct FocusedReviewPersistence {
pub(crate) diff_hash: Option<u64>,
pub(crate) session_id: SessionId,
pub(crate) text: Option<String>,
}
const REVIEW_LOADING_MESSAGE_PREFIX: &str = "Reviewing changes with";
struct ReviewModeTarget<'a> {
review_status_message: &'a mut Option<String>,
review_text: &'a mut Option<String>,
}
pub(crate) fn diff_content_hash(diff: &str) -> u64 {
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
diff.as_bytes().iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
})
}
pub(crate) fn is_review_loading_status_message(status_message: &str) -> bool {
status_message.starts_with(REVIEW_LOADING_MESSAGE_PREFIX)
}
pub(crate) fn review_loading_message(review_model: AgentModel) -> String {
format!("{REVIEW_LOADING_MESSAGE_PREFIX} {}", review_model.as_str())
}
pub(crate) fn review_view_state(
review_cache: &HashMap<SessionId, ReviewCacheEntry>,
session_id: &str,
review_model: AgentModel,
) -> (Option<String>, Option<String>) {
let Some(cache_entry) = review_cache.get(session_id) else {
return (None, None);
};
match cache_entry {
ReviewCacheEntry::Loading { .. } => (Some(review_loading_message(review_model)), None),
ReviewCacheEntry::Ready { text, .. } => (None, Some(text.clone())),
ReviewCacheEntry::Failed { error, .. } => (
Some(format!("Review assist unavailable: {}", error.trim())),
None,
),
ReviewCacheEntry::Suppressed { .. } => (None, None),
}
}
pub(crate) fn review_cache_from_rows(
focused_review_rows: Vec<SessionFocusedReviewRow>,
) -> HashMap<SessionId, ReviewCacheEntry> {
focused_review_rows
.into_iter()
.filter_map(|row| {
let diff_hash = row.diff_hash.parse::<u64>().ok()?;
Some((
SessionId::from(row.session_id),
ReviewCacheEntry::Ready {
diff_hash,
text: row.text,
},
))
})
.collect()
}
pub(crate) fn start_review_assist(
app_event_tx: mpsc::UnboundedSender<AppEvent>,
review_model: AgentModel,
session_id: &str,
session_folder: &Path,
diff_hash: u64,
review_diff: &str,
session_summary: Option<&str>,
) {
task::TaskService::spawn_review_assist_task(task::ReviewAssistTaskInput {
app_event_tx,
diff_hash,
review_diff: review_diff.to_string(),
session_folder: session_folder.to_path_buf(),
session_id: SessionId::from(session_id),
review_model,
session_summary: session_summary.map(str::to_string),
});
}
pub(crate) fn mark_session_agent_review(session_state: &mut SessionState, session_id: &str) {
update_transient_review_status(
session_state,
session_id,
Status::Review,
Status::AgentReview,
);
}
pub(crate) fn apply_review_updates(
review_cache: &mut HashMap<SessionId, ReviewCacheEntry>,
mode: &mut AppMode,
session_state: &mut SessionState,
review_updates: HashMap<SessionId, ReviewUpdate>,
) -> Vec<FocusedReviewPersistence> {
let mut persistence_updates = Vec::new();
for (session_id, review_update) in review_updates {
if let Some(persistence_update) = apply_review_update(
review_cache,
mode,
session_state,
&session_id,
review_update,
) {
persistence_updates.push(persistence_update);
}
}
persistence_updates
}
pub(crate) async fn auto_start_reviews(
review_cache: &mut HashMap<SessionId, ReviewCacheEntry>,
session_ids: &HashSet<SessionId>,
session_state: &mut SessionState,
mode: &mut AppMode,
git_client: Arc<dyn GitClient>,
app_event_tx: mpsc::UnboundedSender<AppEvent>,
review_model: AgentModel,
) {
for session_id in session_ids {
let Some((current_status, session_folder, base_branch, session_summary)) = session_state
.sessions
.iter()
.find(|session| session.id == *session_id)
.map(|session| {
(
session.status,
session.folder.clone(),
session.base_branch.clone(),
session.summary.clone(),
)
})
else {
continue;
};
if current_status == Status::InProgress {
review_cache.remove(session_id);
continue;
}
if current_status != Status::Review {
continue;
}
let diff = git_client
.diff(session_folder.clone(), base_branch)
.await
.unwrap_or_default();
if diff.trim().is_empty() || diff.starts_with("Failed to run git diff:") {
continue;
}
let new_hash = diff_content_hash(&diff);
if review_cache
.get(session_id)
.is_some_and(|entry| entry.diff_hash() == new_hash)
{
continue;
}
review_cache.insert(
session_id.clone(),
ReviewCacheEntry::Loading {
diff_hash: new_hash,
},
);
mark_session_agent_review(session_state, session_id);
if let Some(mode_target) = review_mode_target(mode, session_id) {
apply_review_loading(
mode_target.review_status_message,
mode_target.review_text,
review_model,
);
}
start_review_assist(
app_event_tx.clone(),
review_model,
session_id,
&session_folder,
new_hash,
&diff,
session_summary.as_deref(),
);
}
}
fn apply_review_loading(
review_status_message: &mut Option<String>,
review_text: &mut Option<String>,
review_model: AgentModel,
) {
*review_status_message = Some(review_loading_message(review_model));
*review_text = None;
}
fn apply_review_update(
review_cache: &mut HashMap<SessionId, ReviewCacheEntry>,
mode: &mut AppMode,
session_state: &mut SessionState,
session_id: &str,
review_update: ReviewUpdate,
) -> Option<FocusedReviewPersistence> {
let ReviewUpdate { diff_hash, result } = review_update;
let cache_entry = review_cache.get(session_id)?;
if !matches!(cache_entry, ReviewCacheEntry::Loading { .. })
|| cache_entry.diff_hash() != diff_hash
{
return None;
}
let persistence_update = FocusedReviewPersistence {
diff_hash: result.as_ref().ok().map(|_| diff_hash),
session_id: SessionId::from(session_id),
text: result.as_ref().ok().cloned(),
};
review_cache.insert(
SessionId::from(session_id),
ReviewCacheEntry::from_result(diff_hash, &result),
);
restore_session_review_status(session_state, session_id);
if let Some(mode_target) = review_mode_target(mode, session_id) {
apply_review_result(
mode_target.review_status_message,
mode_target.review_text,
result,
);
}
Some(persistence_update)
}
fn restore_session_review_status(session_state: &mut SessionState, session_id: &str) {
update_transient_review_status(
session_state,
session_id,
Status::AgentReview,
Status::Review,
);
}
fn update_transient_review_status(
session_state: &mut SessionState,
session_id: &str,
current_status: Status,
next_status: Status,
) {
if let Some(session) = session_state
.sessions
.iter_mut()
.find(|session| session.id == session_id)
&& session.status == current_status
{
session.status = next_status;
}
if let Some(handles) = session_state.handles.get(session_id)
&& let Ok(mut handle_status) = handles.status.lock()
&& *handle_status == current_status
{
*handle_status = next_status;
}
}
fn review_mode_target<'a>(mode: &'a mut AppMode, session_id: &str) -> Option<ReviewModeTarget<'a>> {
match mode {
AppMode::View {
review_status_message,
review_text,
session_id: view_session_id,
..
}
| AppMode::Prompt {
review_status_message,
review_text,
session_id: view_session_id,
..
}
| AppMode::Question {
review_status_message,
review_text,
session_id: view_session_id,
..
} if view_session_id == session_id => Some(ReviewModeTarget {
review_status_message,
review_text,
}),
AppMode::Help {
context:
HelpContext::View {
review_status_message,
review_text,
session_id: view_session_id,
..
},
..
} if view_session_id == session_id => Some(ReviewModeTarget {
review_status_message,
review_text,
}),
AppMode::OpenCommandSelector { restore_view, .. }
| AppMode::PublishBranchInput { restore_view, .. }
| AppMode::ViewInfoPopup { restore_view, .. } => {
confirmation_review_mode_target(restore_view, session_id)
}
AppMode::List
| AppMode::SessionCreation { .. }
| AppMode::Confirmation { .. }
| AppMode::SyncBlockedPopup { .. }
| AppMode::Prompt { .. }
| AppMode::Question { .. }
| AppMode::Diff { .. }
| AppMode::Help { .. }
| AppMode::View { .. } => None,
}
}
fn confirmation_review_mode_target<'a>(
restore_view: &'a mut ConfirmationViewMode,
session_id: &str,
) -> Option<ReviewModeTarget<'a>> {
if restore_view.session_id != session_id {
return None;
}
Some(ReviewModeTarget {
review_status_message: &mut restore_view.review_status_message,
review_text: &mut restore_view.review_text,
})
}
fn apply_review_result(
review_status_message: &mut Option<String>,
review_text: &mut Option<String>,
result: Result<String, String>,
) {
match result {
Ok(text) => {
*review_status_message = None;
*review_text = Some(text);
}
Err(error) => {
*review_status_message = Some(format!("Review assist unavailable: {}", error.trim()));
*review_text = None;
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use ratatui::widgets::TableState;
use super::*;
use crate::app::session::RealClock;
use crate::app::session_state::SessionState;
use crate::domain::input::InputState;
use crate::infra::agent::protocol::QuestionItem;
use crate::ui::state::app_mode::QuestionFocus;
use crate::ui::state::prompt::{PromptAttachmentState, PromptHistoryState, PromptSlashState};
fn empty_session_state() -> SessionState {
SessionState::new(
HashMap::new(),
Vec::new(),
TableState::default(),
Arc::new(RealClock),
0,
0,
)
}
fn loading_review_cache(
session_id: &SessionId,
diff_hash: u64,
) -> HashMap<SessionId, ReviewCacheEntry> {
HashMap::from([(session_id.clone(), ReviewCacheEntry::Loading { diff_hash })])
}
fn successful_review_update(
session_id: &SessionId,
diff_hash: u64,
review_text: &str,
) -> HashMap<SessionId, ReviewUpdate> {
HashMap::from([(
session_id.clone(),
ReviewUpdate {
diff_hash,
result: Ok(review_text.to_string()),
},
)])
}
#[test]
fn review_loading_message_uses_requested_model_name() {
let review_model = AgentModel::Gpt54;
let message = review_loading_message(review_model);
assert_eq!(message, "Reviewing changes with gpt-5.4");
}
#[test]
fn is_review_loading_status_message_matches_model_aware_copy() {
let status_message = review_loading_message(AgentModel::ClaudeOpus47);
let is_loading = is_review_loading_status_message(&status_message);
assert!(is_loading);
}
#[test]
fn review_view_state_uses_loading_message_for_cached_review_generation() {
let mut review_cache = HashMap::new();
review_cache.insert(
"session-id".into(),
ReviewCacheEntry::Loading { diff_hash: 7 },
);
let (review_status_message, review_text) =
review_view_state(&review_cache, "session-id", AgentModel::ClaudeSonnet46);
assert_eq!(
review_status_message.as_deref(),
Some("Reviewing changes with claude-sonnet-4-6")
);
assert_eq!(review_text, None);
}
#[test]
fn review_view_state_hides_suppressed_auto_review() {
let mut review_cache = HashMap::new();
review_cache.insert(
"session-id".into(),
ReviewCacheEntry::Suppressed { diff_hash: 7 },
);
let (review_status_message, review_text) =
review_view_state(&review_cache, "session-id", AgentModel::ClaudeSonnet46);
assert_eq!(review_status_message, None);
assert_eq!(review_text, None);
}
#[test]
fn review_cache_from_rows_restores_persisted_ready_review() {
let focused_review_rows = vec![SessionFocusedReviewRow {
diff_hash: "42".to_string(),
session_id: "session-id".to_string(),
text: "## Review\nPersisted finding.".to_string(),
}];
let review_cache = review_cache_from_rows(focused_review_rows);
assert!(matches!(
review_cache.get("session-id"),
Some(ReviewCacheEntry::Ready { diff_hash: 42, text })
if text == "## Review\nPersisted finding."
));
}
#[test]
fn apply_review_updates_returns_success_for_persistence() {
let session_id = SessionId::from("session-persist-review");
let diff_hash = 19;
let review_text = "## Review\nPersist this finding.";
let mut review_cache = loading_review_cache(&session_id, diff_hash);
let mut mode = AppMode::View {
review_status_message: Some(review_loading_message(AgentModel::Gpt54)),
review_text: None,
scroll_offset: None,
session_id: session_id.clone(),
};
let mut session_state = empty_session_state();
let review_updates = successful_review_update(&session_id, diff_hash, review_text);
let persistence_updates = apply_review_updates(
&mut review_cache,
&mut mode,
&mut session_state,
review_updates,
);
assert_eq!(
persistence_updates,
vec![FocusedReviewPersistence {
diff_hash: Some(diff_hash),
session_id,
text: Some(review_text.to_string()),
}]
);
}
#[test]
fn apply_review_updates_returns_clear_for_failed_regeneration() {
let session_id = SessionId::from("session-failed-review");
let diff_hash = 29;
let mut review_cache = loading_review_cache(&session_id, diff_hash);
let mut mode = AppMode::View {
review_status_message: Some(review_loading_message(AgentModel::Gpt54)),
review_text: None,
scroll_offset: None,
session_id: session_id.clone(),
};
let mut session_state = empty_session_state();
let review_updates = HashMap::from([(
session_id.clone(),
ReviewUpdate {
diff_hash,
result: Err("provider failed".to_string()),
},
)]);
let persistence_updates = apply_review_updates(
&mut review_cache,
&mut mode,
&mut session_state,
review_updates,
);
assert_eq!(
persistence_updates,
vec![FocusedReviewPersistence {
diff_hash: None,
session_id,
text: None,
}]
);
}
#[test]
fn apply_review_updates_writes_success_to_prompt_mode() {
let session_id = SessionId::from("session-prompt-review");
let diff_hash = 11;
let review_text = "## Review\nPrompt-mode finding.";
let mut review_cache = loading_review_cache(&session_id, diff_hash);
let mut mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::default(),
input: InputState::default(),
review_status_message: Some(review_loading_message(AgentModel::Gpt54)),
review_text: None,
scroll_offset: None,
session_id: session_id.clone(),
slash_state: PromptSlashState::default(),
};
let mut session_state = empty_session_state();
let review_updates = successful_review_update(&session_id, diff_hash, review_text);
apply_review_updates(
&mut review_cache,
&mut mode,
&mut session_state,
review_updates,
);
assert!(matches!(
review_cache.get(session_id.as_str()),
Some(ReviewCacheEntry::Ready { text, .. }) if text == review_text
));
assert!(matches!(
mode,
AppMode::Prompt {
review_status_message: None,
review_text: Some(ref rendered_review_text),
..
} if rendered_review_text == review_text
));
}
#[test]
fn apply_review_updates_writes_success_to_question_mode() {
let session_id = SessionId::from("session-question-review");
let diff_hash = 17;
let review_text = "## Review\nQuestion-mode finding.";
let mut review_cache = loading_review_cache(&session_id, diff_hash);
let mut mode = AppMode::Question {
at_mention_state: None,
current_index: 0,
focus: QuestionFocus::Answer,
input: InputState::default(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Need more detail?".to_string(),
}],
responses: Vec::new(),
review_status_message: Some(review_loading_message(AgentModel::Gpt54)),
review_text: None,
scroll_offset: None,
selected_option_index: None,
session_id: session_id.clone(),
};
let mut session_state = empty_session_state();
let review_updates = successful_review_update(&session_id, diff_hash, review_text);
apply_review_updates(
&mut review_cache,
&mut mode,
&mut session_state,
review_updates,
);
assert!(matches!(
review_cache.get(session_id.as_str()),
Some(ReviewCacheEntry::Ready { text, .. }) if text == review_text
));
assert!(matches!(
mode,
AppMode::Question {
review_status_message: None,
review_text: Some(ref rendered_review_text),
..
} if rendered_review_text == review_text
));
}
#[test]
fn apply_review_updates_ignores_suppressed_auto_review_entry() {
let session_id = SessionId::from("session-suppressed-review");
let diff_hash = 23;
let mut review_cache = HashMap::from([(
session_id.clone(),
ReviewCacheEntry::Suppressed { diff_hash },
)]);
let mut mode = AppMode::View {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.clone(),
};
let mut session_state = empty_session_state();
let review_updates =
successful_review_update(&session_id, diff_hash, "## Review\nShould not be rendered.");
apply_review_updates(
&mut review_cache,
&mut mode,
&mut session_state,
review_updates,
);
assert!(matches!(
review_cache.get(session_id.as_str()),
Some(ReviewCacheEntry::Suppressed { diff_hash: cached_hash })
if *cached_hash == diff_hash
));
assert!(matches!(
mode,
AppMode::View {
review_status_message: None,
review_text: None,
..
}
));
}
}