use std::io;
use crossterm::event::{self, KeyCode, KeyEvent};
use ratatui::Terminal;
use ratatui::backend::Backend;
use tracing::warn;
use crate::app::session::{SessionTaskService, remote_branch_name_from_upstream_ref};
use crate::app::{
self, App, AppEvent, ReviewCacheEntry, diff_content_hash, is_review_loading_status_message,
review_loading_message,
};
use crate::domain::agent::AgentModel;
use crate::domain::input::InputState;
use crate::domain::session::{FollowUpTaskAction, PublishBranchAction, SessionId, Status};
use crate::domain::transcript_notice::TranscriptNotice;
use crate::runtime::EventResult;
use crate::runtime::mode::confirmation::DEFAULT_OPTION_INDEX;
use crate::runtime::mode::input_key::is_insertable_char_key;
use crate::ui::component::session_output::SessionOutputLineContext;
use crate::ui::page::session_chat::SessionChatPage;
use crate::ui::state::app_mode::{
AppMode, ConfirmationIntent, ConfirmationViewMode, DiffRightPanel, HelpContext,
};
use crate::ui::state::help_action::{self, ViewSessionState};
use crate::ui::state::prompt::{PromptAttachmentState, PromptHistoryState};
#[derive(Clone)]
struct ViewContext {
review_status_message: Option<String>,
review_text: Option<String>,
scroll_offset: Option<u16>,
session_id: SessionId,
session_index: usize,
}
#[derive(Clone, Copy)]
struct ViewMetrics {
total_lines: u16,
view_height: u16,
}
struct ViewPendingUpdate {
review_status_message: Option<String>,
review_text: Option<String>,
scroll_offset: Option<u16>,
}
impl ViewPendingUpdate {
fn from_context(view_context: &ViewContext) -> Self {
Self {
review_status_message: view_context.review_status_message.clone(),
review_text: view_context.review_text.clone(),
scroll_offset: view_context.scroll_offset,
}
}
}
struct ViewKeyContext<'a> {
context: &'a ViewContext,
metrics: ViewMetrics,
session_snapshot: &'a ViewSessionSnapshot,
}
struct ViewSessionSnapshot {
can_continue_terminal_session: bool,
can_open_worktree: bool,
can_start_staged_session: bool,
follow_up_task_action: Option<FollowUpTaskAction>,
publish_pull_request_action: Option<PublishBranchAction>,
session_state: ViewSessionState,
session_status: Status,
}
const REVIEW_NO_DIFF_MESSAGE: &str = "No diff changes found for review.";
pub(crate) async fn handle<B: Backend>(
app: &mut App,
terminal: &mut Terminal<B>,
key: KeyEvent,
) -> io::Result<EventResult>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let Some(view_context) = view_context(app) else {
return Ok(EventResult::Continue);
};
let view_metrics = view_metrics(app, terminal, &view_context)?;
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let Some(view_session_snapshot) = view_session_snapshot(app, &view_context) else {
return Ok(EventResult::Continue);
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: view_metrics,
session_snapshot: &view_session_snapshot,
};
if !handle_view_key(app, key, view_key_context, &mut pending_update).await {
return Ok(EventResult::Continue);
}
apply_view_scroll_and_output_mode(
app,
pending_update.review_status_message,
pending_update.review_text,
pending_update.scroll_offset,
);
Ok(EventResult::Continue)
}
async fn handle_view_key(
app: &mut App,
key: KeyEvent,
view_key_context: ViewKeyContext<'_>,
pending_update: &mut ViewPendingUpdate,
) -> bool {
let view_context = view_key_context.context;
let view_metrics = view_key_context.metrics;
let view_session_snapshot = view_key_context.session_snapshot;
if let Some(should_apply_pending_update) = handle_primary_view_key(
app,
key,
view_context,
view_session_snapshot,
pending_update,
)
.await
{
return should_apply_pending_update;
}
if handle_scroll_key(key, view_metrics, pending_update) {
return true;
}
if let Some(should_apply_pending_update) = handle_workflow_view_key(
app,
key,
view_context,
view_session_snapshot,
pending_update,
)
.await
{
return should_apply_pending_update;
}
true
}
async fn handle_primary_view_key(
app: &mut App,
key: KeyEvent,
view_context: &ViewContext,
view_session_snapshot: &ViewSessionSnapshot,
pending_update: &ViewPendingUpdate,
) -> Option<bool> {
match key.code {
KeyCode::Char('q') => {
app.mode = AppMode::List;
}
KeyCode::Char('o') if view_session_snapshot.can_open_worktree => {
open_worktree_for_view_session(app, view_context).await;
}
KeyCode::Char('l') if view_session_snapshot.follow_up_task_action.is_some() => {
if let Err(error) = app
.launch_or_open_selected_follow_up_task(&view_context.session_id)
.await
{
app.append_output_for_session(
&view_context.session_id,
&TranscriptNotice::FollowUpTaskError.format(error),
)
.await;
}
return Some(false);
}
KeyCode::Char('s') if view_session_snapshot.can_start_staged_session => {
if let Err(error) = app.start_staged_session(&view_context.session_id).await {
app.append_output_for_session(
&view_context.session_id,
&TranscriptNotice::StartError.format(error),
)
.await;
}
return Some(false);
}
KeyCode::Char('c')
if key.modifiers == event::KeyModifiers::NONE
&& view_session_snapshot.can_continue_terminal_session =>
{
open_continue_confirmation(app, view_context);
return Some(false);
}
KeyCode::Char('[') if app.has_multiple_follow_up_tasks(&view_context.session_id) => {
app.select_previous_follow_up_task(&view_context.session_id);
}
KeyCode::Char(']') if app.has_multiple_follow_up_tasks(&view_context.session_id) => {
app.select_next_follow_up_task(&view_context.session_id);
}
KeyCode::Enter if is_view_chat_allowed(view_session_snapshot.session_status) => {
switch_view_to_prompt(
app,
view_context,
PromptHistoryState::new(session_prompt_history_entries(
app.sessions.session_at(view_context.session_index)?,
)),
InputState::default(),
pending_update.scroll_offset,
);
}
KeyCode::Char('/')
if is_view_action_allowed(view_session_snapshot.session_status)
&& is_insertable_char_key(key) =>
{
switch_view_to_prompt(
app,
view_context,
PromptHistoryState::new(session_prompt_history_entries(
app.sessions.session_at(view_context.session_index)?,
)),
InputState::with_text("/".to_string()),
pending_update.scroll_offset,
);
}
_ => return None,
}
Some(true)
}
fn handle_scroll_key(
key: KeyEvent,
view_metrics: ViewMetrics,
pending_update: &mut ViewPendingUpdate,
) -> bool {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
pending_update.scroll_offset =
scroll_offset_down(pending_update.scroll_offset, view_metrics, 1);
}
KeyCode::Char('k') | KeyCode::Up => {
pending_update.scroll_offset = Some(scroll_offset_up(
pending_update.scroll_offset,
view_metrics,
1,
));
}
KeyCode::Char('g') => pending_update.scroll_offset = Some(0),
KeyCode::Char('G') => pending_update.scroll_offset = None,
KeyCode::Char('d') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
pending_update.scroll_offset =
scroll_offset_half_page_down(pending_update.scroll_offset, view_metrics);
}
KeyCode::Char('u') if key.modifiers.contains(event::KeyModifiers::CONTROL) => {
pending_update.scroll_offset = Some(scroll_offset_half_page_up(
pending_update.scroll_offset,
view_metrics,
));
}
_ => return false,
}
true
}
async fn handle_workflow_view_key(
app: &mut App,
key: KeyEvent,
view_context: &ViewContext,
view_session_snapshot: &ViewSessionSnapshot,
pending_update: &mut ViewPendingUpdate,
) -> Option<bool> {
match key.code {
KeyCode::Char('d')
if !key.modifiers.contains(event::KeyModifiers::CONTROL)
&& is_view_diff_allowed(view_session_snapshot.session_status) =>
{
show_diff_for_view_session(app, view_context).await;
}
KeyCode::Char(character)
if character.eq_ignore_ascii_case(&'p')
&& !key.modifiers.contains(event::KeyModifiers::CONTROL)
&& view_session_snapshot.publish_pull_request_action.is_some() =>
{
let Some(publish_pull_request_action) =
view_session_snapshot.publish_pull_request_action
else {
return Some(true);
};
open_publish_branch_input(app, view_context, publish_pull_request_action);
return Some(false);
}
KeyCode::Char(character)
if character.eq_ignore_ascii_case(&'f')
&& !key.modifiers.contains(event::KeyModifiers::CONTROL)
&& is_view_review_allowed(view_session_snapshot.session_status) =>
{
open_or_regenerate_review(app, view_context, pending_update).await;
}
KeyCode::Char('m') if is_view_action_allowed(view_session_snapshot.session_status) => {
open_merge_confirmation(app, view_context);
}
KeyCode::Char('r') if is_view_rebase_allowed(view_session_snapshot.session_status) => {
rebase_view_session(app, &view_context.session_id).await;
}
KeyCode::Char('c')
if key.modifiers.contains(event::KeyModifiers::CONTROL)
&& view_session_snapshot.session_status == Status::InProgress =>
{
end_in_progress_turn(app, &view_context.session_id).await;
return Some(false);
}
KeyCode::Char('?') => {
open_view_help_overlay(app, view_context, view_session_snapshot);
return Some(false);
}
_ => return None,
}
Some(true)
}
fn open_merge_confirmation(app: &mut App, view_context: &ViewContext) {
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::MergeSession,
confirmation_message: "Add this session to merge queue?".to_string(),
confirmation_title: "Confirm Merge".to_string(),
restore_view: Some(confirmation_view_mode(view_context)),
session_id: Some(view_context.session_id.clone()),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
};
}
fn open_continue_confirmation(app: &mut App, view_context: &ViewContext) {
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::ContinueSession,
confirmation_message: "Create a new draft session with initial context from this session?"
.to_string(),
confirmation_title: "Confirm Continue".to_string(),
restore_view: Some(confirmation_view_mode(view_context)),
session_id: Some(view_context.session_id.clone()),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
};
}
async fn open_worktree_for_view_session(app: &mut App, view_context: &ViewContext) {
let open_commands = app.configured_open_commands();
if open_commands.len() > 1 {
app.mode = AppMode::OpenCommandSelector {
commands: open_commands,
restore_view: confirmation_view_mode(view_context),
selected_command_index: 0,
};
return;
}
let selected_open_command = open_commands.first().map(String::as_str);
app.open_session_worktree_in_tmux_with_command(selected_open_command)
.await;
}
fn confirmation_view_mode(view_context: &ViewContext) -> ConfirmationViewMode {
ConfirmationViewMode {
review_status_message: view_context.review_status_message.clone(),
review_text: view_context.review_text.clone(),
scroll_offset: view_context.scroll_offset,
session_id: view_context.session_id.clone(),
}
}
async fn open_or_regenerate_review(
app: &mut App,
view_context: &ViewContext,
pending_update: &mut ViewPendingUpdate,
) {
let is_loading = pending_update
.review_status_message
.as_deref()
.is_some_and(is_review_loading_status_message);
if is_loading {
return;
}
if pending_update.review_text.is_some() || pending_update.review_status_message.is_some() {
app.mode = AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::RegenerateReview,
confirmation_message: "Regenerate focused review?".to_string(),
confirmation_title: "Confirm Regenerate".to_string(),
restore_view: Some(confirmation_view_mode(view_context)),
session_id: Some(view_context.session_id.clone()),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
};
return;
}
open_review_output_mode(
app,
view_context,
&mut pending_update.review_status_message,
&mut pending_update.review_text,
)
.await;
pending_update.scroll_offset = None;
}
fn view_session_snapshot(app: &App, view_context: &ViewContext) -> Option<ViewSessionSnapshot> {
let session = app.sessions.session_at(view_context.session_index)?;
let session_status = session.status;
let can_open_worktree = is_view_worktree_open_allowed(session_status)
&& *app
.sessions
.session_worktree_availability()
.get(view_context.session_id.as_str())
.unwrap_or(&false);
Some(ViewSessionSnapshot {
can_continue_terminal_session: session.allows_terminal_continuation(),
can_open_worktree,
can_start_staged_session: session.is_draft_session()
&& session.status == Status::Draft
&& session.has_staged_drafts(),
follow_up_task_action: app.selected_follow_up_task_action(&view_context.session_id),
publish_pull_request_action: session.publish_pull_request_action(),
session_state: help_action::session_view_state(session),
session_status,
})
}
fn apply_view_scroll_and_output_mode(
app: &mut App,
review_status_message: Option<String>,
review_text: Option<String>,
scroll_offset: Option<u16>,
) {
if let AppMode::View {
review_status_message: view_review_status_message,
review_text: view_review_text,
scroll_offset: view_scroll_offset,
..
} = &mut app.mode
{
*view_review_status_message = review_status_message;
*view_review_text = review_text;
*view_scroll_offset = scroll_offset;
}
}
fn is_view_worktree_open_allowed(status: Status) -> bool {
!matches!(
status,
Status::Done
| Status::Canceled
| Status::InProgress
| Status::Rebasing
| Status::Merging
| Status::Queued
)
}
fn is_view_action_allowed(status: Status) -> bool {
!matches!(
status,
Status::Done
| Status::InProgress
| Status::Rebasing
| Status::Merging
| Status::Queued
| Status::Canceled
)
}
fn is_view_chat_allowed(status: Status) -> bool {
is_view_action_allowed(status) || matches!(status, Status::InProgress)
}
fn is_view_diff_allowed(status: Status) -> bool {
status.allows_review_actions()
}
fn is_view_review_allowed(status: Status) -> bool {
status.allows_review_actions()
}
fn is_view_rebase_allowed(status: Status) -> bool {
is_view_action_allowed(status) && status != Status::AgentReview
}
async fn end_in_progress_turn(app: &mut App, session_id: &str) {
if pop_last_queued_chat_message_if_any(app, session_id).await {
return;
}
cancel_in_progress_turn(app, session_id).await;
}
async fn pop_last_queued_chat_message_if_any(app: &mut App, session_id: &str) -> bool {
let popped_prompt = app
.sessions
.session_handles()
.get(session_id)
.and_then(|handles| handles.queued_messages.lock().ok()?.pop_back());
let Some(popped_prompt) = popped_prompt else {
return false;
};
app.sessions.sync_session_from_handle(session_id);
app.cleanup_prompt_attachment_files(&popped_prompt).await;
app.services.emit_app_event(AppEvent::SessionUpdated {
session_id: session_id.into(),
version: SessionTaskService::next_session_update_version(
&app.services.session_update_versions(),
session_id,
),
});
true
}
async fn cancel_in_progress_turn(app: &mut App, session_id: &str) {
let timestamp_seconds =
app::session::unix_timestamp_from_system_time(app.services.clock().now_system_time());
if let Err(error) = app
.services
.db()
.operations()
.request_cancel_for_session_operations(session_id)
.await
{
warn!(
session_id = session_id,
error = %error,
"failed to request cancellation for queued session operations"
);
}
if let Some(handles) = app.sessions.session_handles().get(session_id) {
match handles.cancel_token.lock() {
Ok(cancel_token) => cancel_token.cancel(),
Err(error) => {
warn!(
session_id = session_id,
error = %error,
"failed to lock session cancel token"
);
}
}
}
if let Err(error) = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(
session_id,
&Status::Review.to_string(),
timestamp_seconds,
)
.await
{
warn!(
session_id = session_id,
error = %error,
"failed to persist review status after interrupting session turn"
);
return;
}
if let Some(handles) = app.sessions.session_handles().get(session_id)
&& let Ok(mut handle_status) = handles.status.lock()
{
*handle_status = Status::Review;
}
if let Some(session) = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
{
session.status = Status::Review;
}
suppress_auto_review_for_stopped_turn(app, session_id).await;
app.services.emit_app_event(AppEvent::SessionUpdated {
session_id: session_id.into(),
version: SessionTaskService::next_session_update_version(
&app.services.session_update_versions(),
session_id,
),
});
app.services.emit_app_event(AppEvent::RefreshSessions);
}
async fn suppress_auto_review_for_stopped_turn(app: &mut App, session_id: &str) {
let Some(session) = app
.sessions
.sessions()
.iter()
.find(|session| session.id == session_id)
else {
return;
};
let session_folder = session.folder.clone();
let base_branch = session.base_branch.clone();
let diff = match app
.services
.git_client()
.diff(session_folder, base_branch)
.await
{
Ok(diff) => diff,
Err(error) => {
warn!(
session_id = session_id,
error = %error,
"failed to load stopped-turn diff for auto-review suppression"
);
return;
}
};
if diff.trim().is_empty() {
return;
}
app.review_cache.insert(
SessionId::from(session_id),
ReviewCacheEntry::Suppressed {
diff_hash: diff_content_hash(&diff),
},
);
}
fn switch_view_to_prompt(
app: &mut App,
view_context: &ViewContext,
history_state: PromptHistoryState,
input: InputState,
scroll_offset: Option<u16>,
) {
app.mode = AppMode::Prompt {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state,
review_status_message: view_context.review_status_message.clone(),
review_text: view_context.review_text.clone(),
slash_state: app.prompt_slash_state(),
session_id: view_context.session_id.clone(),
input,
scroll_offset,
};
}
fn open_view_help_overlay(
app: &mut App,
view_context: &ViewContext,
view_session_snapshot: &ViewSessionSnapshot,
) {
app.mode = AppMode::Help {
context: HelpContext::View {
can_open_worktree: view_session_snapshot.can_open_worktree,
review_status_message: view_context.review_status_message.clone(),
review_text: view_context.review_text.clone(),
publish_pull_request_action: view_session_snapshot.publish_pull_request_action,
session_id: view_context.session_id.clone(),
session_state: view_session_snapshot.session_state,
scroll_offset: view_context.scroll_offset,
},
scroll_offset: 0,
};
}
fn open_publish_branch_input(
app: &mut App,
view_context: &ViewContext,
publish_branch_action: PublishBranchAction,
) {
let Some(session) = app.sessions.session_at(view_context.session_index) else {
return;
};
let default_branch_name = crate::app::session::session_branch(&session.id);
let locked_upstream_ref = session.published_upstream_ref.clone();
let input = locked_upstream_ref
.as_deref()
.map(remote_branch_name_from_upstream_ref)
.map(InputState::with_text)
.unwrap_or_default();
app.mode = AppMode::PublishBranchInput {
default_branch_name,
input,
locked_upstream_ref,
publish_branch_action,
restore_view: confirmation_view_mode(view_context),
};
}
fn view_context(app: &mut App) -> Option<ViewContext> {
let (review_status_message, review_text, session_id, scroll_offset) = match &app.mode {
AppMode::View {
review_status_message,
review_text,
session_id,
scroll_offset,
} => (
review_status_message.clone(),
review_text.clone(),
session_id.clone(),
*scroll_offset,
),
_ => return None,
};
let Some(session_index) = app.session_index_for_id(&session_id) else {
app.mode = AppMode::List;
return None;
};
Some(ViewContext {
review_status_message,
review_text,
scroll_offset,
session_id,
session_index,
})
}
fn view_metrics<B: Backend>(
app: &App,
terminal: &Terminal<B>,
view_context: &ViewContext,
) -> io::Result<ViewMetrics>
where
B::Error: std::error::Error + Send + Sync + 'static,
{
let terminal_size = terminal.size().map_err(crate::runtime::backend_err)?;
let view_height = terminal_size.height.saturating_sub(5);
let output_width = terminal_size.width.saturating_sub(2);
let total_lines = view_total_lines(
app,
&view_context.session_id,
view_context.session_index,
view_context.review_status_message.as_deref(),
view_context.review_text.as_deref(),
output_width,
);
Ok(ViewMetrics {
total_lines,
view_height,
})
}
fn view_total_lines(
app: &App,
session_id: &str,
session_index: usize,
review_status_message: Option<&str>,
review_text: Option<&str>,
output_width: u16,
) -> u16 {
let active_progress = app.session_progress_message(session_id);
let active_prompt_output = app
.sessions
.active_prompt_outputs()
.get(session_id)
.map(std::string::String::as_str);
app.sessions.session_at(session_index).map_or(0, |session| {
SessionChatPage::rendered_output_line_count(
session,
output_width,
SessionOutputLineContext {
active_prompt_output,
active_progress,
review_model: app.settings.default_review_model,
review_status_message,
review_text,
session_update_version: app.session_update_version(session_id),
},
app.markdown_render_cache(),
app.session_output_layout_cache(),
)
})
}
fn prompt_history_entries(output: &str) -> Vec<String> {
let mut entries = Vec::new();
let mut output_lines = output.lines().peekable();
while let Some(line) = output_lines.next() {
let Some(first_prompt_line) = line.strip_prefix(" › ") else {
continue;
};
let mut prompt = first_prompt_line.to_string();
while let Some(next_line) = output_lines.peek().copied() {
if next_line.is_empty() {
break;
}
let prompt_line = next_line.strip_prefix(" ").unwrap_or(next_line);
prompt.push('\n');
prompt.push_str(prompt_line);
let _ = output_lines.next();
}
entries.push(prompt);
}
entries
}
fn session_prompt_history_entries(session: &crate::domain::session::Session) -> Vec<String> {
if session.status == Status::Draft && session.is_draft_session() {
return vec![session.prompt.clone()];
}
prompt_history_entries(&session.output)
}
fn scroll_offset_down(scroll_offset: Option<u16>, metrics: ViewMetrics, step: u16) -> Option<u16> {
let current_offset = scroll_offset?;
let next_offset = current_offset.saturating_add(step.max(1));
if next_offset >= metrics.total_lines.saturating_sub(metrics.view_height) {
return None;
}
Some(next_offset)
}
fn scroll_offset_up(scroll_offset: Option<u16>, metrics: ViewMetrics, step: u16) -> u16 {
let current_offset =
scroll_offset.unwrap_or_else(|| metrics.total_lines.saturating_sub(metrics.view_height));
current_offset.saturating_sub(step.max(1))
}
fn scroll_offset_half_page_down(scroll_offset: Option<u16>, metrics: ViewMetrics) -> Option<u16> {
scroll_offset_down(scroll_offset, metrics, half_page_scroll_step(metrics))
}
fn scroll_offset_half_page_up(scroll_offset: Option<u16>, metrics: ViewMetrics) -> u16 {
scroll_offset_up(scroll_offset, metrics, half_page_scroll_step(metrics))
}
fn half_page_scroll_step(metrics: ViewMetrics) -> u16 {
metrics.view_height / 2
}
async fn open_review_output_mode(
app: &mut App,
view_context: &ViewContext,
review_status_message: &mut Option<String>,
review_text: &mut Option<String>,
) {
let review_is_loading = review_status_message
.as_deref()
.is_some_and(is_review_loading_status_message);
if review_text.is_some() || review_is_loading {
return;
}
if let Some(cached) = app.review_cache.get(view_context.session_id.as_str()) {
match cached {
ReviewCacheEntry::Loading { .. } => {
let review_model = review_assist_model(app);
*review_status_message = Some(review_loading_message(review_model));
*review_text = None;
}
ReviewCacheEntry::Ready { text, .. } => {
*review_status_message = None;
*review_text = Some(text.clone());
}
ReviewCacheEntry::Failed { error, .. } => {
*review_status_message =
Some(format!("Review assist unavailable: {}", error.trim()));
*review_text = None;
}
ReviewCacheEntry::Suppressed { .. } => {}
}
if !matches!(cached, ReviewCacheEntry::Suppressed { .. }) {
return;
}
}
let Some(session) = app.sessions.session_at(view_context.session_index) else {
*review_status_message = None;
*review_text = Some(String::new());
return;
};
let session_folder = session.folder.clone();
let session_summary = session.summary.clone();
let diff = load_view_session_diff(app, view_context).await;
if diff.trim().is_empty() {
*review_status_message = None;
*review_text = Some(REVIEW_NO_DIFF_MESSAGE.to_string());
return;
}
if diff.starts_with("Failed to run git diff:") {
*review_status_message = None;
*review_text = Some(diff);
return;
}
let diff_hash = diff_content_hash(&diff);
let review_model = review_assist_model(app);
app.review_cache.insert(
view_context.session_id.clone(),
ReviewCacheEntry::Loading { diff_hash },
);
let _ = app
.services
.db()
.sessions()
.update_session_focused_review(&view_context.session_id, None, None)
.await;
*review_status_message = Some(review_loading_message(review_model));
*review_text = None;
app.start_review_assist(
&view_context.session_id,
&session_folder,
diff_hash,
&diff,
session_summary.as_deref(),
);
}
async fn show_diff_for_view_session(app: &mut App, view_context: &ViewContext) -> bool {
let diff = load_view_session_diff(app, view_context).await;
if diff.trim().is_empty() {
return false;
}
app.mode = AppMode::Diff {
diff,
file_explorer_selected_index: 0,
restore_question: None,
right_panel: DiffRightPanel::Diff,
scroll_cache: None,
session_id: view_context.session_id.clone(),
scroll_offset: 0,
};
true
}
fn review_assist_model(app: &App) -> AgentModel {
app.settings.default_review_model
}
async fn load_view_session_diff(app: &App, view_context: &ViewContext) -> String {
let Some(session) = app.sessions.session_at(view_context.session_index) else {
return String::new();
};
let session_folder = session.folder.clone();
let base_branch = session.base_branch.clone();
match app
.services
.git_client()
.diff(session_folder, base_branch)
.await
{
Ok(diff) => diff,
Err(error) => {
warn!(
session_id = %view_context.session_id,
error = %error,
"failed to load session diff for view mode"
);
format!("Failed to run git diff: {error}")
}
}
}
async fn rebase_view_session(app: &mut App, session_id: &str) {
if let Err(error) = app.rebase_session(session_id).await {
app.append_output_for_session(session_id, &TranscriptNotice::RebaseError.format(error))
.await;
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use crossterm::event::KeyModifiers;
use mockall::predicate::eq;
use tempfile::tempdir;
use super::*;
use crate::app::AppClients;
use crate::db::Database;
use crate::infra::app_server;
use crate::infra::tmux::{MockTmuxClient, TmuxClient};
fn mock_app_server() -> std::sync::Arc<dyn app_server::AppServerClient> {
std::sync::Arc::new(app_server::MockAppServerClient::new())
}
fn test_app_clients() -> AppClients {
AppClients::new().with_agent_availability_probe(Arc::new(
crate::infra::agent::StaticAgentAvailabilityProbe {
available_agent_kinds: crate::domain::agent::AgentKind::ALL.to_vec(),
},
))
}
async fn new_test_app_with_tmux_client(
tmux_client: Arc<dyn TmuxClient>,
) -> (App, tempfile::TempDir) {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
let clients = test_app_clients()
.with_app_server_client_override(mock_app_server())
.with_tmux_client(tmux_client);
let app = App::new_with_clients(base_path.clone(), base_path, None, database, clients)
.await
.expect("failed to build app");
(app, base_dir)
}
async fn new_test_app() -> (App, tempfile::TempDir) {
new_test_app_with_tmux_client(Arc::new(MockTmuxClient::new())).await
}
fn setup_test_git_repo(path: &Path) {
Command::new("git")
.args(["init"])
.current_dir(path)
.output()
.expect("git init failed");
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(path)
.output()
.expect("git config failed");
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(path)
.output()
.expect("git config failed");
std::fs::write(path.join("README.md"), "test").expect("write failed");
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.expect("git add failed");
Command::new("git")
.args(["commit", "-m", "Initial commit"])
.current_dir(path)
.output()
.expect("git commit failed");
Command::new("git")
.args(["branch", "-M", "main"])
.current_dir(path)
.output()
.expect("git branch failed");
}
async fn new_test_app_with_git_and_tmux_client(
tmux_client: Arc<dyn TmuxClient>,
) -> (App, tempfile::TempDir) {
let base_dir = tempdir().expect("failed to create temp dir");
let base_path = base_dir.path().to_path_buf();
setup_test_git_repo(base_dir.path());
let database = Database::open_in_memory()
.await
.expect("failed to open in-memory db");
let clients = test_app_clients()
.with_app_server_client_override(mock_app_server())
.with_tmux_client(tmux_client);
let app = App::new_with_clients(
base_path.clone(),
base_path,
Some("main".to_string()),
database,
clients,
)
.await
.expect("failed to build app");
(app, base_dir)
}
async fn new_test_app_with_session_and_tmux_client(
tmux_client: Arc<dyn TmuxClient>,
) -> (App, tempfile::TempDir, String) {
let (mut app, base_dir) = new_test_app_with_git_and_tmux_client(tmux_client).await;
let session_id = app
.create_session()
.await
.expect("failed to create session");
(app, base_dir, session_id)
}
async fn new_test_app_with_session() -> (App, tempfile::TempDir, String) {
new_test_app_with_session_and_tmux_client(Arc::new(MockTmuxClient::new())).await
}
fn session_fixture(status: Status, is_draft: bool) -> crate::domain::session::Session {
crate::domain::session::tests::SessionFixtureBuilder::new()
.status(status)
.draft(is_draft)
.folder(std::env::temp_dir())
.project_name("")
.build()
}
#[test]
fn test_is_view_worktree_open_allowed_returns_false_for_canceled() {
let status = Status::Canceled;
let can_open = is_view_worktree_open_allowed(status);
assert!(!can_open);
}
#[test]
fn test_is_view_worktree_open_allowed_returns_false_for_in_progress() {
let status = Status::InProgress;
let can_open = is_view_worktree_open_allowed(status);
assert!(!can_open);
}
#[test]
fn test_is_view_worktree_open_allowed_returns_false_for_rebasing() {
let status = Status::Rebasing;
let can_open = is_view_worktree_open_allowed(status);
assert!(!can_open);
}
#[test]
fn test_is_view_worktree_open_allowed_returns_false_for_merge_queue_statuses() {
let merge_queue_statuses = [Status::Queued, Status::Merging];
let can_open_for_statuses: Vec<bool> = merge_queue_statuses
.iter()
.map(|status| is_view_worktree_open_allowed(*status))
.collect();
assert!(can_open_for_statuses.iter().all(|can_open| !can_open));
}
#[test]
fn test_is_view_action_allowed_only_for_non_done_non_in_progress_status() {
let canceled_status = Status::Canceled;
let review_status = Status::Review;
let in_progress_status = Status::InProgress;
let done_status = Status::Done;
let canceled_allowed = is_view_action_allowed(canceled_status);
let review_allowed = is_view_action_allowed(review_status);
let in_progress_allowed = is_view_action_allowed(in_progress_status);
let done_allowed = is_view_action_allowed(done_status);
assert!(!canceled_allowed);
assert!(review_allowed);
assert!(!in_progress_allowed);
assert!(!done_allowed);
}
#[test]
fn test_is_view_diff_allowed_only_for_review_status() {
let review_status = Status::Review;
let new_status = Status::Draft;
let in_progress_status = Status::InProgress;
let review_allowed = is_view_diff_allowed(review_status);
let new_allowed = is_view_diff_allowed(new_status);
let in_progress_allowed = is_view_diff_allowed(in_progress_status);
assert!(review_allowed);
assert!(!new_allowed);
assert!(!in_progress_allowed);
}
#[test]
fn test_is_view_review_allowed_only_for_review_status() {
let review_status = Status::Review;
let agent_review_status = Status::AgentReview;
let done_status = Status::Done;
let in_progress_status = Status::InProgress;
let review_allowed = is_view_review_allowed(review_status);
let agent_review_allowed = is_view_review_allowed(agent_review_status);
let done_allowed = is_view_review_allowed(done_status);
let in_progress_allowed = is_view_review_allowed(in_progress_status);
assert!(review_allowed);
assert!(agent_review_allowed);
assert!(!done_allowed);
assert!(!in_progress_allowed);
}
#[test]
fn test_is_view_rebase_allowed_blocks_agent_review() {
let review_status = Status::Review;
let agent_review_status = Status::AgentReview;
let review_allowed = is_view_rebase_allowed(review_status);
let agent_review_allowed = is_view_rebase_allowed(agent_review_status);
assert!(review_allowed);
assert!(!agent_review_allowed);
}
#[tokio::test]
async fn test_view_context_returns_none_for_non_view_mode() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::List;
let context = view_context(&mut app);
assert!(context.is_none());
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_view_context_falls_back_to_list_when_session_is_missing() {
let (mut app, _base_dir) = new_test_app().await;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: "missing-session".into(),
scroll_offset: Some(2),
};
let context = view_context(&mut app);
assert!(context.is_none());
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_view_context_returns_existing_session_details() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.clone().into(),
scroll_offset: Some(4),
};
let context = view_context(&mut app);
assert!(context.is_some());
let context = context.expect("expected view context");
assert_eq!(context.session_id, session_id);
assert_eq!(context.scroll_offset, Some(4));
assert_eq!(context.session_index, 0);
}
#[tokio::test]
async fn test_view_session_snapshot_disables_actions_for_done_session() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::Done;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert!(snapshot.can_continue_terminal_session);
assert!(!snapshot.can_open_worktree);
assert_eq!(snapshot.session_state, ViewSessionState::Done);
assert_eq!(snapshot.session_status, Status::Done);
}
#[tokio::test]
async fn test_view_session_snapshot_enables_continue_for_canceled_session() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::Canceled;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert!(snapshot.can_continue_terminal_session);
assert!(!snapshot.can_open_worktree);
assert_eq!(snapshot.session_state, ViewSessionState::Canceled);
assert_eq!(snapshot.session_status, Status::Canceled);
}
#[tokio::test]
async fn test_view_session_snapshot_hides_worktree_open_for_unstarted_draft_session() {
let (mut app, _base_dir) =
new_test_app_with_git_and_tmux_client(Arc::new(MockTmuxClient::new())).await;
let session_id = app
.create_draft_session()
.await
.expect("failed to create draft session");
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert!(!snapshot.can_open_worktree);
assert_eq!(snapshot.session_state, ViewSessionState::NewSession);
}
#[tokio::test]
async fn test_view_session_snapshot_reads_cached_worktree_availability() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions
.set_session_worktree_available(&session_id, false);
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.into(),
scroll_offset: Some(1),
};
let context = view_context(&mut app).expect("expected view context");
let snapshot = view_session_snapshot(&app, &context).expect("expected view snapshot");
assert!(!snapshot.can_open_worktree);
}
#[tokio::test]
async fn test_view_session_snapshot_returns_none_for_stale_session_index() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.into(),
scroll_offset: Some(1),
};
let mut context = view_context(&mut app).expect("expected view context");
context.session_index = 99;
let snapshot = view_session_snapshot(&app, &context);
assert!(snapshot.is_none());
}
#[tokio::test]
async fn test_view_total_lines_counts_wrapped_output_lines() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].output = "word ".repeat(40);
let raw_line_count =
u16::try_from(app.sessions.sessions()[0].output.lines().count()).unwrap_or(u16::MAX);
let total_lines = view_total_lines(&app, &session_id, 0, None, None, 20);
assert!(total_lines > raw_line_count);
}
#[test]
fn test_prompt_history_entries_extracts_user_prompts() {
let output = " › first\n\nassistant\n\n › second\n\n";
let entries = prompt_history_entries(output);
assert_eq!(entries, vec!["first".to_string(), "second".to_string()]);
}
#[test]
fn test_prompt_history_entries_keeps_multiline_prompts() {
let output = " › first line\n second line\n\nassistant\n\n";
let entries = prompt_history_entries(output);
assert_eq!(entries, vec!["first line\nsecond line".to_string()]);
}
#[test]
fn test_prompt_history_entries_keeps_multiple_blank_lines_in_prompts() {
let output = " › first line\n \n \n after gap\n\nassistant\n\n";
let entries = prompt_history_entries(output);
assert_eq!(entries, vec!["first line\n\n\nafter gap".to_string()]);
}
#[test]
fn test_prompt_history_entries_ignores_non_prompt_lines() {
let output = "assistant line\n\n";
let entries = prompt_history_entries(output);
assert!(entries.is_empty());
}
#[tokio::test]
async fn test_scroll_offset_down_does_not_jump_to_bottom_for_wrapped_output() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].output = "word ".repeat(60);
let metrics = ViewMetrics {
total_lines: view_total_lines(&app, &session_id, 0, None, None, 20),
view_height: 5,
};
let next_offset = scroll_offset_down(Some(0), metrics, 1);
assert_eq!(next_offset, Some(1));
}
#[test]
fn test_scroll_offset_down_returns_none_at_end_of_content() {
let metrics = ViewMetrics {
total_lines: 20,
view_height: 10,
};
let next_offset = scroll_offset_down(Some(9), metrics, 1);
assert_eq!(next_offset, None);
}
#[test]
fn test_scroll_offset_up_uses_bottom_when_scroll_is_unset() {
let metrics = ViewMetrics {
total_lines: 30,
view_height: 10,
};
let next_offset = scroll_offset_up(None, metrics, 5);
assert_eq!(next_offset, 15);
}
#[tokio::test]
async fn test_apply_view_scroll_and_output_mode_updates_review_state() {
let (mut app, _base_dir, expected_session_id) = new_test_app_with_session().await;
let expected_status_message = review_loading_message(AgentModel::Gpt54);
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: expected_session_id.clone().into(),
scroll_offset: Some(3),
};
apply_view_scroll_and_output_mode(
&mut app,
Some(expected_status_message.clone()),
None,
Some(1),
);
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: Some(ref actual_status_message),
review_text: None,
ref session_id,
scroll_offset: Some(1),
} if session_id == &expected_session_id
&& actual_status_message == &expected_status_message
));
}
#[test]
fn test_is_review_loading_status_message_matches_model_aware_message() {
let status_message = review_loading_message(AgentModel::ClaudeOpus47);
let is_loading = is_review_loading_status_message(&status_message);
assert!(is_loading);
}
#[test]
fn test_is_review_loading_status_message_rejects_unrelated_message() {
let status_message = "Review complete.";
let is_loading = is_review_loading_status_message(status_message);
assert!(!is_loading);
}
#[tokio::test]
async fn test_review_assist_model_returns_default_review_model_setting() {
let (mut app, _base_dir, _session_id) = new_test_app_with_session().await;
app.settings.default_review_model = AgentModel::ClaudeOpus47;
let review_model = review_assist_model(&app);
assert_eq!(review_model, AgentModel::ClaudeOpus47);
}
#[tokio::test]
async fn test_view_total_lines_uses_default_review_model_for_loading_fallback() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.settings.default_review_model = AgentModel::ClaudeHaiku4520251001;
app.sessions.sessions_mut()[0].model = AgentModel::Gpt54;
app.sessions.sessions_mut()[0].status = Status::AgentReview;
let output_width = 14;
let session = &app.sessions.sessions()[0];
let expected = SessionChatPage::rendered_output_line_count(
session,
output_width,
SessionOutputLineContext {
active_prompt_output: None,
active_progress: None,
review_model: AgentModel::ClaudeHaiku4520251001,
review_status_message: None,
review_text: None,
session_update_version: app.session_update_version(&session_id),
},
app.markdown_render_cache(),
app.session_output_layout_cache(),
);
let total_lines = view_total_lines(&app, &session_id, 0, None, None, output_width);
assert_eq!(total_lines, expected);
}
#[tokio::test]
async fn test_open_review_output_mode_reuses_cached_review_text() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
let mut next_review_status_message = None;
let mut next_review_text = Some("Cached review".to_string());
open_review_output_mode(
&mut app,
&view_context,
&mut next_review_status_message,
&mut next_review_text,
)
.await;
assert_eq!(next_review_status_message, None);
assert_eq!(next_review_text.as_deref(), Some("Cached review"));
}
#[tokio::test]
async fn test_open_review_output_mode_starts_loading_when_diff_exists() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.settings.default_review_model = AgentModel::ClaudeOpus47;
app.sessions.sessions_mut()[0].status = Status::Review;
let session_folder = app.sessions.sessions()[0].folder.clone();
std::fs::write(session_folder.join("README.md"), "review test content\n")
.expect("failed to update readme");
let view_context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
let mut next_review_status_message = None;
let mut next_review_text = None;
open_review_output_mode(
&mut app,
&view_context,
&mut next_review_status_message,
&mut next_review_text,
)
.await;
assert_eq!(
next_review_status_message,
Some(review_loading_message(AgentModel::ClaudeOpus47))
);
assert_eq!(next_review_text, None);
assert_eq!(app.sessions.sessions()[0].status, Status::AgentReview);
assert!(matches!(
app.review_cache.get(&view_context.session_id),
Some(ReviewCacheEntry::Loading { .. })
));
}
#[tokio::test]
async fn test_open_review_output_mode_shows_no_diff_message_when_diff_empty() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
let mut next_review_status_message = None;
let mut next_review_text = None;
open_review_output_mode(
&mut app,
&view_context,
&mut next_review_status_message,
&mut next_review_text,
)
.await;
assert_eq!(next_review_status_message, None);
assert_eq!(next_review_text.as_deref(), Some(REVIEW_NO_DIFF_MESSAGE));
assert!(!app.review_cache.contains_key(&view_context.session_id));
}
#[tokio::test]
async fn test_open_review_output_mode_clears_stale_session_selection() {
let (app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.into(),
session_index: 99,
};
let mut app = app;
let mut next_review_status_message = None;
let mut next_review_text = None;
open_review_output_mode(
&mut app,
&view_context,
&mut next_review_status_message,
&mut next_review_text,
)
.await;
assert_eq!(next_review_status_message, None);
assert_eq!(next_review_text, Some(String::new()));
}
#[tokio::test]
async fn test_show_diff_for_view_session_switches_mode_to_diff() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let session_folder = app.sessions.sessions()[0].folder.clone();
std::fs::write(session_folder.join("README.md"), "updated content")
.expect("failed to write diff fixture");
let context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: Some(0),
session_id: session_id.clone().into(),
session_index: 0,
};
let opened = show_diff_for_view_session(&mut app, &context).await;
assert!(opened);
assert!(matches!(
app.mode,
AppMode::Diff {
ref session_id,
scroll_offset: 0,
..
} if session_id == &context.session_id
));
}
#[tokio::test]
async fn test_show_diff_for_view_session_keeps_view_mode_when_diff_is_empty() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.clone().into(),
scroll_offset: Some(0),
};
let context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: Some(0),
session_id: session_id.clone().into(),
session_index: 0,
};
let opened = show_diff_for_view_session(&mut app, &context).await;
assert!(!opened);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: Some(0),
..
} if session_id == &context.session_id
));
}
#[tokio::test]
async fn test_show_diff_for_view_session_uses_error_message_outside_git_repo() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let non_git_dir = tempdir().expect("failed to create non-git dir");
app.sessions.sessions_mut()[0].folder = non_git_dir.path().to_path_buf();
let context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: Some(0),
session_id: session_id.clone().into(),
session_index: 0,
};
let opened = show_diff_for_view_session(&mut app, &context).await;
assert!(opened);
assert!(matches!(
app.mode,
AppMode::Diff {
ref session_id,
ref diff,
scroll_offset: 0,
..
} if session_id == &context.session_id && diff.contains("Failed to run git diff:")
));
}
#[tokio::test]
async fn test_load_view_session_diff_returns_empty_string_for_stale_session_index() {
let (app, _base_dir, session_id) = new_test_app_with_session().await;
let context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: Some(0),
session_id: session_id.into(),
session_index: 99,
};
let diff = load_view_session_diff(&app, &context).await;
assert!(diff.is_empty());
}
#[tokio::test]
async fn test_append_output_for_session_appends_text() {
let (app, _base_dir, session_id) = new_test_app_with_session().await;
let mut app = app;
app.append_output_for_session(&session_id, "line one").await;
app.sessions.sync_from_handles();
let output = app.sessions.sessions()[0].output.clone();
assert_eq!(output, "line one");
}
#[tokio::test]
async fn test_open_merge_confirmation_sets_confirmation_mode_with_view_restore_state() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
review_status_message: Some(review_loading_message(AgentModel::Gpt54)),
review_text: Some("Critical finding".to_string()),
session_id: session_id.clone().into(),
scroll_offset: Some(5),
};
let context = view_context(&mut app).expect("expected view context");
open_merge_confirmation(&mut app, &context);
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::MergeSession,
ref confirmation_message,
ref confirmation_title,
restore_view: Some(ConfirmationViewMode {
review_status_message: Some(ref status_message),
review_text: Some(ref review_text),
scroll_offset: Some(5),
session_id: ref restored_session_id,
}),
session_id: Some(ref mode_session_id),
selected_confirmation_index: DEFAULT_OPTION_INDEX,
} if confirmation_title == "Confirm Merge"
&& confirmation_message == "Add this session to merge queue?"
&& restored_session_id == &session_id
&& mode_session_id == &session_id
&& status_message == &review_loading_message(AgentModel::Gpt54)
&& review_text == "Critical finding"
));
}
#[tokio::test]
async fn test_open_worktree_for_view_session_opens_command_selector_for_multiple_commands() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.settings.open_command = "cargo test\nnpm run dev".to_string();
app.mode = AppMode::View {
review_status_message: Some(review_loading_message(AgentModel::Gpt54)),
review_text: Some("Critical finding".to_string()),
session_id: session_id.clone().into(),
scroll_offset: Some(4),
};
let context = view_context(&mut app).expect("expected view context");
open_worktree_for_view_session(&mut app, &context).await;
assert!(matches!(
app.mode,
AppMode::OpenCommandSelector {
ref commands,
restore_view:
ConfirmationViewMode {
review_status_message: Some(ref status_message),
review_text: Some(ref review_text),
session_id: ref restored_session_id,
scroll_offset: Some(4),
},
selected_command_index: 0,
} if commands == &vec!["cargo test".to_string(), "npm run dev".to_string()]
&& restored_session_id == &session_id
&& status_message == &review_loading_message(AgentModel::Gpt54)
&& review_text == "Critical finding"
));
}
#[tokio::test]
async fn test_open_worktree_for_view_session_keeps_view_mode_for_single_command() {
let mut mock_tmux_client = MockTmuxClient::new();
mock_tmux_client
.expect_open_window_for_folder()
.times(1)
.returning(|_| Box::pin(async { Some("@42".to_string()) }));
mock_tmux_client
.expect_run_command_in_window()
.with(eq("@42".to_string()), eq("cargo test".to_string()))
.times(1)
.returning(|_, _| Box::pin(async {}));
let (mut app, _base_dir, session_id) =
new_test_app_with_session_and_tmux_client(Arc::new(mock_tmux_client)).await;
app.settings.open_command = "cargo test".to_string();
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let context = view_context(&mut app).expect("expected view context");
open_worktree_for_view_session(&mut app, &context).await;
assert!(matches!(
app.mode,
AppMode::View {
review_status_message: None,
review_text: None,
session_id: ref mode_session_id,
scroll_offset: Some(2),
} if mode_session_id == &session_id
));
}
#[tokio::test]
async fn test_rebase_view_session_appends_error_output_without_review_status() {
let (app, _base_dir, session_id) = new_test_app_with_session().await;
let mut app = app;
rebase_view_session(&mut app, &session_id).await;
app.sessions.sync_from_handles();
let output = app.sessions.sessions()[0].output.clone();
assert!(output.contains("[Rebase Error]"));
}
#[tokio::test]
async fn test_open_view_help_overlay_preserves_view_context() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
review_status_message: Some(review_loading_message(AgentModel::Gpt54)),
review_text: Some("Critical finding".to_string()),
scroll_offset: Some(3),
session_id: session_id.clone().into(),
session_index: 0,
};
let view_session_snapshot = ViewSessionSnapshot {
can_continue_terminal_session: false,
can_open_worktree: true,
can_start_staged_session: false,
follow_up_task_action: None,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_state: ViewSessionState::Review,
session_status: Status::Review,
};
open_view_help_overlay(&mut app, &view_context, &view_session_snapshot);
assert!(matches!(
app.mode,
AppMode::Help {
context: HelpContext::View {
can_open_worktree: true,
review_status_message: Some(ref status_message),
review_text: Some(ref review_text),
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_id: ref session_id_in_mode,
session_state: ViewSessionState::Review,
scroll_offset: Some(3),
..
},
scroll_offset: 0,
} if session_id_in_mode == &session_id
&& status_message == &review_loading_message(AgentModel::Gpt54)
&& review_text == "Critical finding"
));
}
#[tokio::test]
async fn test_open_publish_branch_input_preserves_view_context() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
review_status_message: Some(review_loading_message(AgentModel::Gpt54)),
review_text: Some("Critical finding".to_string()),
scroll_offset: Some(5),
session_id: session_id.clone().into(),
session_index: 0,
};
open_publish_branch_input(
&mut app,
&view_context,
PublishBranchAction::PublishPullRequest,
);
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
ref default_branch_name,
input: ref input_state,
locked_upstream_ref: None,
publish_branch_action: PublishBranchAction::PublishPullRequest,
restore_view:
ConfirmationViewMode {
review_status_message: Some(ref status_message),
review_text: Some(ref review_text),
session_id: ref restored_session_id,
scroll_offset: Some(5),
},
} if default_branch_name == &crate::app::session::session_branch(&session_id)
&& input_state.cursor == 0
&& input_state.text().is_empty()
&& restored_session_id == &session_id
&& status_message == &review_loading_message(AgentModel::Gpt54)
&& review_text == "Critical finding"
));
}
#[tokio::test]
async fn test_open_publish_branch_input_locks_existing_upstream_branch_name() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].published_upstream_ref =
Some("origin/review/custom".to_string());
let view_context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: Some(1),
session_id: session_id.into(),
session_index: 0,
};
open_publish_branch_input(
&mut app,
&view_context,
PublishBranchAction::PublishPullRequest,
);
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
input: ref input_state,
locked_upstream_ref: Some(ref upstream_ref),
..
} if upstream_ref == "origin/review/custom"
&& input_state.text() == "review/custom"
));
}
#[test]
fn test_is_view_worktree_open_allowed_disables_canceled_state() {
let status = Status::Canceled;
let result = is_view_worktree_open_allowed(status);
assert!(!result);
}
#[test]
fn test_is_view_worktree_open_allowed_disables_done_state() {
let status = Status::Done;
let result = is_view_worktree_open_allowed(status);
assert!(!result);
}
#[test]
fn test_is_view_worktree_open_allowed_disables_queued_state() {
let status = Status::Queued;
let result = is_view_worktree_open_allowed(status);
assert!(!result);
}
#[test]
fn test_is_view_worktree_open_allowed_enables_review_state() {
let status = Status::Review;
let result = is_view_worktree_open_allowed(status);
assert!(result);
}
#[test]
fn test_view_session_state_maps_merge_queue_statuses() {
let merge_queue_statuses = [Status::Queued, Status::Merging];
let mapped_states: Vec<ViewSessionState> = merge_queue_statuses
.iter()
.map(|status| help_action::session_view_state(&session_fixture(*status, false)))
.collect();
assert!(
mapped_states
.iter()
.all(|state| *state == ViewSessionState::MergeQueue)
);
}
#[test]
fn test_view_session_state_maps_rebasing_status() {
let status = Status::Rebasing;
let session = session_fixture(status, false);
let state = help_action::session_view_state(&session);
assert_eq!(state, ViewSessionState::Rebasing);
}
#[test]
fn test_view_session_state_maps_canceled_status() {
let status = Status::Canceled;
let session = session_fixture(status, false);
let state = help_action::session_view_state(&session);
assert_eq!(state, ViewSessionState::Canceled);
}
#[tokio::test]
async fn test_open_review_output_mode_uses_ready_cache_entry() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let cached_text = "## Review\nCached review from auto-generation.";
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Ready {
diff_hash: 123,
text: cached_text.to_string(),
},
);
let view_context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
let mut next_review_status_message = None;
let mut next_review_text = None;
open_review_output_mode(
&mut app,
&view_context,
&mut next_review_status_message,
&mut next_review_text,
)
.await;
assert_eq!(next_review_status_message, None);
assert_eq!(next_review_text.as_deref(), Some(cached_text));
}
#[tokio::test]
async fn test_open_review_output_mode_shows_loading_for_cache_loading_entry() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.settings.default_review_model = AgentModel::ClaudeOpus47;
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Loading { diff_hash: 456 },
);
let view_context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: None,
session_id: session_id.into(),
session_index: 0,
};
let mut next_review_status_message = None;
let mut next_review_text = None;
open_review_output_mode(
&mut app,
&view_context,
&mut next_review_status_message,
&mut next_review_text,
)
.await;
assert_eq!(
next_review_status_message,
Some(review_loading_message(AgentModel::ClaudeOpus47))
);
assert_eq!(next_review_text, None);
}
#[tokio::test]
async fn test_open_or_regenerate_review_opens_when_review_output_is_missing() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
review_status_message: None,
review_text: None,
scroll_offset: Some(5),
session_id: session_id.into(),
session_index: 0,
};
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
open_or_regenerate_review(&mut app, &view_context, &mut pending_update).await;
assert_eq!(pending_update.scroll_offset, None);
}
#[tokio::test]
async fn test_open_or_regenerate_shows_confirmation_when_review_output_exists() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Ready {
text: "Old review".to_string(),
diff_hash: 123,
},
);
let view_context = ViewContext {
review_status_message: None,
review_text: Some("Old review".to_string()),
scroll_offset: None,
session_id: session_id.clone().into(),
session_index: 0,
};
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
open_or_regenerate_review(&mut app, &view_context, &mut pending_update).await;
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::RegenerateReview,
..
}
));
assert!(app.review_cache.contains_key(session_id.as_str()));
}
#[tokio::test]
async fn test_open_or_regenerate_skips_when_loading_in_progress() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.review_cache.insert(
session_id.clone().into(),
ReviewCacheEntry::Loading { diff_hash: 42 },
);
let loading_message = review_loading_message(app.settings.default_review_model);
let view_context = ViewContext {
review_status_message: Some(loading_message.clone()),
review_text: None,
scroll_offset: None,
session_id: session_id.clone().into(),
session_index: 0,
};
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
open_or_regenerate_review(&mut app, &view_context, &mut pending_update).await;
assert!(matches!(
app.review_cache.get(session_id.as_str()),
Some(ReviewCacheEntry::Loading { diff_hash: 42 })
));
assert_eq!(pending_update.review_status_message, Some(loading_message));
}
#[tokio::test]
async fn test_handle_view_key_ignores_diff_for_non_review_status() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
can_continue_terminal_session: false,
can_open_worktree: false,
can_start_staged_session: false,
follow_up_task_action: None,
publish_pull_request_action: None,
session_state: ViewSessionState::Done,
session_status: Status::Done,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ViewMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
view_key_context,
&mut pending_update,
)
.await;
assert!(should_apply);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: Some(2),
..
} if session_id == &view_context.session_id
));
assert_eq!(pending_update.scroll_offset, Some(2));
}
#[tokio::test]
async fn test_handle_launch_follow_up_task_key_opens_linked_sibling_session() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let sibling_session_id = app
.create_session()
.await
.expect("failed to create sibling session");
let source_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == session_id)
.expect("expected source session in session list");
source_session.follow_up_tasks = vec![crate::domain::session::SessionFollowUpTask {
id: 1,
launched_session_id: Some(sibling_session_id.clone().into()),
position: 0,
text: "Open the sibling session.".to_string(),
}];
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.into(),
scroll_offset: Some(0),
};
let backend = ratatui::backend::TestBackend::new(120, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let result = handle(
&mut app,
&mut terminal,
KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
)
.await
.expect("launch/open key should be handled");
assert!(matches!(result, EventResult::Continue));
assert_eq!(
app.selected_session().map(|session| session.id.as_str()),
Some(sibling_session_id.as_str())
);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
..
} if session_id == &sibling_session_id
));
}
#[tokio::test]
async fn test_handle_continue_key_opens_confirmation_for_done_session() {
let (mut app, _base_dir, source_session_id) = new_test_app_with_session().await;
let source_session = app
.sessions
.sessions_mut()
.iter_mut()
.find(|session| session.id == source_session_id)
.expect("expected source session in session list");
source_session.status = Status::Done;
source_session.summary = Some("# Summary\n\nKeep going.".to_string());
source_session.title = Some("Done source".to_string());
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: source_session_id.clone().into(),
scroll_offset: Some(0),
};
let backend = ratatui::backend::TestBackend::new(120, 30);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let result = handle(
&mut app,
&mut terminal,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
)
.await
.expect("continue key should be handled");
assert!(matches!(result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Confirmation {
confirmation_intent: ConfirmationIntent::ContinueSession,
ref confirmation_title,
ref confirmation_message,
ref restore_view,
ref session_id,
..
} if confirmation_title == "Confirm Continue"
&& confirmation_message
== "Create a new draft session with initial context from this session?"
&& matches!(restore_view, Some(restore_view) if restore_view.session_id == source_session_id)
&& matches!(session_id, Some(session_id) if session_id.as_str() == source_session_id)
));
}
#[tokio::test]
async fn test_handle_view_key_slash_opens_prompt_with_prefilled_slash() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
review_status_message: Some("Review loading".to_string()),
review_text: Some("Focused review".to_string()),
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
can_continue_terminal_session: false,
can_open_worktree: true,
can_start_staged_session: false,
follow_up_task_action: None,
publish_pull_request_action: None,
session_state: ViewSessionState::Review,
session_status: Status::Review,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ViewMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
view_key_context,
&mut pending_update,
)
.await;
assert!(should_apply);
assert!(matches!(
app.mode,
AppMode::Prompt {
ref input,
ref review_status_message,
ref review_text,
ref session_id,
scroll_offset: Some(2),
..
} if input.text() == "/"
&& input.cursor == 1
&& review_status_message.as_deref() == Some("Review loading")
&& review_text.as_deref() == Some("Focused review")
&& session_id == &view_context.session_id
));
}
#[tokio::test]
async fn test_handle_view_key_p_opens_review_request_publish_input() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
can_continue_terminal_session: false,
can_open_worktree: true,
can_start_staged_session: false,
follow_up_task_action: None,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_state: ViewSessionState::Review,
session_status: Status::Review,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ViewMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
view_key_context,
&mut pending_update,
)
.await;
assert!(!should_apply);
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
publish_branch_action: PublishBranchAction::PublishPullRequest,
ref restore_view,
..
} if restore_view.session_id == session_id
));
}
#[tokio::test]
async fn test_handle_view_key_shift_p_opens_review_request_publish_input() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
can_continue_terminal_session: false,
can_open_worktree: true,
can_start_staged_session: false,
follow_up_task_action: None,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_state: ViewSessionState::Review,
session_status: Status::Review,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: ViewMetrics {
total_lines: 10,
view_height: 5,
},
session_snapshot: &view_session_snapshot,
};
let should_apply = handle_view_key(
&mut app,
KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT),
view_key_context,
&mut pending_update,
)
.await;
assert!(!should_apply);
assert!(matches!(
app.mode,
AppMode::PublishBranchInput {
publish_branch_action: PublishBranchAction::PublishPullRequest,
ref restore_view,
..
} if restore_view.session_id == session_id
));
}
#[tokio::test]
async fn test_handle_view_key_ignores_status_gated_actions() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.mode = AppMode::View {
review_status_message: None,
review_text: None,
session_id: session_id.clone().into(),
scroll_offset: Some(2),
};
let view_context = view_context(&mut app).expect("expected view context");
let view_metrics = ViewMetrics {
total_lines: 10,
view_height: 5,
};
for key in [
KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT),
] {
let mut pending_update = ViewPendingUpdate::from_context(&view_context);
let view_session_snapshot = ViewSessionSnapshot {
can_continue_terminal_session: false,
can_open_worktree: false,
can_start_staged_session: false,
follow_up_task_action: None,
publish_pull_request_action: None,
session_state: ViewSessionState::Done,
session_status: Status::Done,
};
let view_key_context = ViewKeyContext {
context: &view_context,
metrics: view_metrics,
session_snapshot: &view_session_snapshot,
};
let should_apply =
handle_view_key(&mut app, key, view_key_context, &mut pending_update).await;
assert!(should_apply);
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: Some(2),
..
} if session_id == &view_context.session_id
));
assert_eq!(pending_update.scroll_offset, Some(2));
}
}
#[tokio::test]
async fn test_q_always_transitions_to_list() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
let view_context = ViewContext {
review_status_message: None,
review_text: Some("review text".to_string()),
scroll_offset: Some(10),
session_id: session_id.into(),
session_index: 0,
};
let pending_update = ViewPendingUpdate::from_context(&view_context);
app.mode = AppMode::List;
assert!(matches!(app.mode, AppMode::List));
assert_eq!(pending_update.review_text.as_deref(), Some("review text"));
}
#[tokio::test]
async fn test_end_in_progress_turn_transitions_session_to_review() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
app.sessions.session_handles_mut().insert(
session_id.clone().into(),
crate::domain::session::SessionHandles::new(String::new(), Status::InProgress),
);
end_in_progress_turn(&mut app, &session_id).await;
assert_eq!(app.sessions.sessions()[0].status, Status::Review);
let handle_status = *app
.sessions
.session_handles()
.get(session_id.as_str())
.expect("handles missing")
.status
.lock()
.expect("lock failed");
assert_eq!(handle_status, Status::Review);
}
#[tokio::test]
async fn test_end_in_progress_turn_does_not_send_sigterm_directly() {
let mut child = tokio::process::Command::new("sleep")
.arg("60")
.spawn()
.expect("failed to spawn sleep");
let child_pid = child.id().expect("child has no pid");
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles =
crate::domain::session::SessionHandles::new(String::new(), Status::InProgress);
if let Ok(mut guard) = handles.child_pid.lock() {
*guard = Some(child_pid);
}
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
end_in_progress_turn(&mut app, &session_id).await;
assert!(
child.try_wait().expect("try_wait failed").is_none(),
"child should still be running — UI must not send SIGTERM"
);
child.kill().await.expect("failed to kill child");
}
#[tokio::test]
async fn test_end_in_progress_turn_cancels_token() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles =
crate::domain::session::SessionHandles::new(String::new(), Status::InProgress);
let cancel_token = std::sync::Arc::clone(&handles.cancel_token);
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
end_in_progress_turn(&mut app, &session_id).await;
let is_cancelled = cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled();
assert!(
is_cancelled,
"cancel_token should be cancelled by end_in_progress_turn"
);
}
#[tokio::test]
async fn test_end_in_progress_turn_keeps_review_session_review_ready() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::Review;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::Review.to_string(), 0)
.await;
app.sessions.session_handles_mut().insert(
session_id.clone().into(),
crate::domain::session::SessionHandles::new(String::new(), Status::Review),
);
end_in_progress_turn(&mut app, &session_id).await;
assert_eq!(app.sessions.sessions()[0].status, Status::Review);
let handle_status = *app
.sessions
.session_handles()
.get(session_id.as_str())
.expect("handles missing")
.status
.lock()
.expect("lock failed");
assert_eq!(handle_status, Status::Review);
}
#[tokio::test]
async fn test_end_in_progress_turn_first_press_with_queue_pops_last_queued_message() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles =
crate::domain::session::SessionHandles::new(String::new(), Status::InProgress);
let cancel_token = std::sync::Arc::clone(&handles.cancel_token);
let queued_messages = std::sync::Arc::clone(&handles.queued_messages);
{
let mut queued = queued_messages.lock().expect("queued_messages lock");
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from("first queued"));
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from(
"second queued",
));
}
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
app.sessions.sessions_mut()[0].queued_messages =
vec!["first queued".to_string(), "second queued".to_string()];
end_in_progress_turn(&mut app, &session_id).await;
let remaining_handle_queue: Vec<String> = queued_messages
.lock()
.expect("queued_messages lock")
.iter()
.map(crate::domain::turn_prompt::TurnPrompt::transcript_text)
.collect();
assert_eq!(
remaining_handle_queue,
vec!["first queued".to_string()],
"only the most recently queued chat message should be popped on first Ctrl+C"
);
assert_eq!(
app.sessions.sessions()[0].queued_messages,
vec!["first queued".to_string()],
"snapshot queued_messages should mirror the handle after LIFO pop"
);
assert_eq!(
app.sessions.sessions()[0].status,
Status::InProgress,
"status should stay InProgress while only a queued message is popped"
);
let handle_status = *app
.sessions
.session_handles()
.get(session_id.as_str())
.expect("handles missing")
.status
.lock()
.expect("lock failed");
assert_eq!(handle_status, Status::InProgress);
assert!(
!cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled(),
"cancel_token must not be cancelled when only a queued message is popped"
);
}
#[tokio::test]
async fn test_end_in_progress_turn_drains_queue_one_press_at_a_time_then_cancels() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles =
crate::domain::session::SessionHandles::new(String::new(), Status::InProgress);
let cancel_token = std::sync::Arc::clone(&handles.cancel_token);
let queued_messages = std::sync::Arc::clone(&handles.queued_messages);
{
let mut queued = queued_messages.lock().expect("queued_messages lock");
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from("first queued"));
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from(
"second queued",
));
}
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
app.sessions.sessions_mut()[0].queued_messages =
vec!["first queued".to_string(), "second queued".to_string()];
end_in_progress_turn(&mut app, &session_id).await;
end_in_progress_turn(&mut app, &session_id).await;
assert!(
queued_messages
.lock()
.expect("queued_messages lock")
.is_empty(),
"queue should be drained after one press per queued message"
);
assert!(
app.sessions.sessions()[0].queued_messages.is_empty(),
"snapshot queued_messages should be empty after LIFO drain"
);
assert_eq!(app.sessions.sessions()[0].status, Status::InProgress);
assert!(
!cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled(),
"cancel_token must not be cancelled while queued messages are still being drained"
);
end_in_progress_turn(&mut app, &session_id).await;
assert!(
cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled(),
"cancel_token must be cancelled once the queue is drained"
);
assert_eq!(app.sessions.sessions()[0].status, Status::Review);
}
#[tokio::test]
async fn test_end_in_progress_turn_second_press_after_empty_queue_cancels_turn() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles =
crate::domain::session::SessionHandles::new(String::new(), Status::InProgress);
let cancel_token = std::sync::Arc::clone(&handles.cancel_token);
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
end_in_progress_turn(&mut app, &session_id).await;
assert!(
cancel_token
.lock()
.expect("cancel token lock")
.is_cancelled(),
"cancel_token must be cancelled when the queue is empty"
);
assert_eq!(app.sessions.sessions()[0].status, Status::Review);
let handle_status = *app
.sessions
.session_handles()
.get(session_id.as_str())
.expect("handles missing")
.status
.lock()
.expect("lock failed");
assert_eq!(handle_status, Status::Review);
}
#[tokio::test]
async fn test_pop_last_queued_chat_message_resyncs_snapshot_after_worker_drain() {
let (mut app, _base_dir, session_id) = new_test_app_with_session().await;
app.sessions.sessions_mut()[0].status = Status::InProgress;
let _ = app
.services
.db()
.sessions()
.update_session_status_with_timing_at(&session_id, &Status::InProgress.to_string(), 0)
.await;
let handles =
crate::domain::session::SessionHandles::new(String::new(), Status::InProgress);
let queued_messages = std::sync::Arc::clone(&handles.queued_messages);
{
let mut queued = queued_messages.lock().expect("queued_messages lock");
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from("first queued"));
queued.push_back(crate::domain::turn_prompt::TurnPrompt::from(
"second queued",
));
}
app.sessions
.session_handles_mut()
.insert(session_id.clone().into(), handles);
app.sessions.sessions_mut()[0].queued_messages =
vec!["first queued".to_string(), "second queued".to_string()];
{
let mut queued = queued_messages.lock().expect("queued_messages lock");
queued.pop_front();
}
end_in_progress_turn(&mut app, &session_id).await;
assert!(
queued_messages
.lock()
.expect("queued_messages lock")
.is_empty(),
"handle queue should be empty after retracting the only remaining entry"
);
assert!(
app.sessions.sessions()[0].queued_messages.is_empty(),
"snapshot must rebuild from the handle state and not show a phantom row for a prompt \
the worker is already executing"
);
}
}