//! Session loading, session pickers, and deep-search dispatchers.
use super::foreign::{dispatch_fetch_session_list, invalidate_foreign_picker};
use super::fork::build_child_fork_marker;
use super::lifecycle::{
    clear_startup_actions, dispatch_new_session_inner, dispatch_new_worktree_session,
    refuse_chat_mode_build_agent,
};
use crate::acp::tracker::AcpUpdateTracker;
use crate::app::actions::{Action, Effect};
use crate::app::agent::{AgentCommand, AgentId, AgentSession, AgentState};
use crate::app::agent_view::AgentView;
use crate::app::app_view::AppView;
use crate::app::dispatch::ctx::{
    SwitchCause, get_active_agent, get_active_agent_mut, switch_to_agent, with_active_agent,
};
use crate::app::dispatch::modes::inherit_auto_mode;
use crate::app::dispatch::prompt::{defer_to_open_reload_window, supersede_open_reload_window};
use crate::app::dispatch::queue::maybe_drain_queue;
use crate::app::dispatch::router::dispatch;
use crate::app::dispatch::status::notify_session_ready;
use crate::app::dispatch::transcript::extensions_modal_tab_fetches;
use crate::scrollback::block::RenderBlock;
use crate::scrollback::blocks::SessionEvent;
use crate::scrollback::state::ScrollbackState;
use agent_client_protocol as acp;
/// Create a placeholder agent and load an existing session by ID.
///
/// `session_cwd` overrides the CWD in the `LoadSessionRequest`. This is needed
/// when resuming a session that was created in a different CWD (e.g., a worktree).
pub(in crate::app::dispatch) fn dispatch_load_session(
    app: &mut AppView,
    session_id: String,
    session_cwd: Option<std::path::PathBuf>,
    chat_kind: bool,
) -> Vec<Effect> {
    if !app.session_startup_allowed() {
        app.deferred_startup.session =
            Some(crate::app::session_startup::DeferredSessionStartup::Load {
                session_id,
                session_cwd,
                chat_kind,
            });
        return vec![];
    }
    dispatch_load_session_ungated(app, session_id, session_cwd, chat_kind)
}
/// Clear `session_id` from any existing agent that already owns the given
/// session, then return a freshly constructed [`acp::SessionId`].
///
/// Without this, `find_session_match` finds the stale agent first (IndexMap
/// insertion order) and routes all ACP notifications to it instead of the
/// new agent.
pub(in crate::app::dispatch) fn clear_stale_session_id(
    app: &mut AppView,
    session_id: &str,
) -> acp::SessionId {
    let sid = acp::SessionId::new(session_id);
    for agent in app.agents.values_mut() {
        if agent.session.session_id.as_ref() == Some(&sid) {
            agent.session.session_id = None;
        }
    }
    sid
}
/// If a local agent already owns this id **and** matches kind, focus it.
///
/// - Kind: compare against the stamped form `chat_kind || app.chat_mode` (agents
///   store that; the LoadSession arg is conversation-entry only). Conversation
///   vs Build still differs when sticky `--chat` is off.
/// - Eager `session_id` + leftover load placeholder after `SessionLoadFailed`
///   is not "open" — reissue load instead of focusing.
/// - Overlay: retarget when on the dashboard list, already in overlay (attached
///   matches visible), or attached already points at the agent we will show
///   (so switch activates overlay with the correct `focus_row`).
pub(in crate::app::dispatch) fn focus_if_session_already_open(
    app: &mut AppView,
    session_id: &str,
    chat_kind: bool,
) -> Option<AgentId> {
    use crate::app::app_view::ActiveView;
    use crate::views::dashboard::DashboardRowId;
    let expected_kind = chat_kind || app.chat_mode;
    let existing_id = app.agents.iter().find_map(|(id, a)| {
        let sid_ok = a
            .session
            .session_id
            .as_ref()
            .is_some_and(|sid| &*sid.0 == session_id);
        if !sid_ok || a.chat_kind != expected_kind {
            return None;
        }
        if a.loading_placeholder_id.is_some() && !a.session.loading_replay {
            return None;
        }
        Some(*id)
    })?;
    if let Some(agent) = app.agents.get_mut(&existing_id) {
        agent.active_subagent = None;
    }
    let retarget_overlay = match app.active_view {
        ActiveView::AgentDashboard => true,
        ActiveView::Agent(visible) => app.dashboard.as_ref().is_some_and(|d| {
            d.attached_agent == Some(visible) || d.attached_agent == Some(existing_id)
        }),
        _ => false,
    };
    if retarget_overlay && let Some(d) = app.dashboard.as_mut() {
        d.focus_row(DashboardRowId::TopLevel(existing_id));
        d.attached_agent = Some(existing_id);
    }
    switch_to_agent(app, existing_id, SwitchCause::Load);
    Some(existing_id)
}
fn dispatch_load_session_ungated(
    app: &mut AppView,
    session_id: String,
    session_cwd: Option<std::path::PathBuf>,
    chat_kind: bool,
) -> Vec<Effect> {
    if crate::app::session_startup::chat_mode_refuses_local_build_load(
        app.chat_mode,
        chat_kind,
        &session_id,
        &app.cwd,
    ) {
        app.show_toast(crate::app::session_startup::CHAT_MODE_LOCAL_BUILD_REFUSAL);
        return vec![];
    }
    invalidate_picker_fetch_on_dismiss(app);
    if focus_if_session_already_open(app, &session_id, chat_kind).is_some() {
        return vec![];
    }
    let acp_session_id = clear_stale_session_id(app, &session_id);
    let agent_id = AgentId(app.next_agent_id);
    app.next_agent_id += 1;
    let mut scrollback = ScrollbackState::new();
    scrollback.set_appearance(app.appearance.clone());
    let loading_msg = if matches!(app.restore_code, Some(true)) {
        format!("Restoring code for session {}...", session_id)
    } else {
        format!("Loading session {}...", session_id)
    };
    let loading_placeholder_id = scrollback.push_block(RenderBlock::system(loading_msg));
    let agent = AgentView::new(
        AgentSession {
            id: agent_id,
            acp_tx: app.acp_tx.clone(),
            session_id: Some(acp_session_id),
            models: app.models.clone(),
            state: AgentState::Idle,
            tracker: AcpUpdateTracker::new(),
            cwd: session_cwd.clone().unwrap_or_else(|| app.cwd.clone()),
            is_worktree: false,
            forked_from: None,
            pending_prompts: std::collections::VecDeque::new(),
            next_queue_id: 0,
            yolo_mode: app.default_yolo,
            auto_mode: inherit_auto_mode(app),
            prompt_history: Vec::new(),
            prompt_history_loading: true,
            loading_replay: true,
            restore_degree: None,
            rate_limited: false,
            model_incompatible: false,
            available_commands: app.bootstrap_acp_commands.clone(),
            available_commands_generation: 1,
            available_tools: None,
            model_switch_pending: false,
            user_model_preference: None,
            deferred_model_switch: app.deferred_model_switch_from_cli(),
            bg_tasks: std::collections::BTreeMap::new(),
            bg_tool_call_to_task: std::collections::HashMap::new(),
            scheduled_tasks: std::collections::HashMap::new(),
            in_flight_prompt: None,
            current_prompt_id: None,
            created_via_new: false,
        },
        scrollback,
    );
    app.agents.insert(agent_id, agent);
    let agent_mut = app.agents.get_mut(&agent_id).unwrap();
    agent_mut.attached_as_viewer = true;
    agent_mut.begin_replay_window();
    agent_mut.loading_placeholder_id = Some(loading_placeholder_id);
    agent_mut.prompt.set_compact(app.appearance.prompt.compact);
    agent_mut.prompt.adopt_slash_mru(app.slash_mru.clone());
    agent_mut
        .prompt
        .set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
    agent_mut.set_session_recap_available(app.session_recap_available);
    agent_mut.scrollback.begin_batch();
    if matches!(app.restore_code, Some(true)) {
        agent_mut.session.start_command(AgentCommand::RestoreCode);
        agent_mut.turn_started_at = Some(std::time::Instant::now());
    }
    agent_mut.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
    agent_mut.chat_kind = chat_kind || app.chat_mode;
    agent_mut
        .prompt
        .slash_controller
        .registry_mut()
        .set_plugins_visible(!app.appearance.disable_plugins);
    app.mark_project_picker_done();
    switch_to_agent(app, agent_id, SwitchCause::Load);
    vec![Effect::LoadSession {
        agent_id,
        session_id,
        session_cwd,
        chat_kind,
    }]
}
/// Load the session selected in the session picker.
pub(in crate::app::dispatch) fn dispatch_pick_session(
    app: &mut AppView,
    index: usize,
) -> Vec<Effect> {
    use crate::views::modal::ActiveModal;
    if session_picker_external_filter_active(app) {
        let source = get_active_agent(app)
            .and_then(|agent| match agent.active_modal.as_ref() {
                Some(ActiveModal::SessionPicker {
                    entries: Some(entries),
                    ..
                }) => entries.get(index),
                _ => None,
            })
            .or_else(|| {
                app.session_picker_entries
                    .as_ref()
                    .and_then(|entries| entries.get(index))
            })
            .map(|entry| entry.source.as_str());
        if !source.is_some_and(crate::app::foreign_sessions::is_foreign_picker_source) {
            return vec![];
        }
    }
    let mut picker_dismissed = false;
    let entry_data = if let Some(agent) = get_active_agent_mut(app) {
        if let Some(ActiveModal::SessionPicker { entries, .. }) = agent.active_modal.as_mut() {
            let data = entries
                .as_ref()
                .and_then(|s| s.get(index))
                .map(|e| (e.id.clone(), e.source.clone(), e.cwd.clone()));
            agent.active_modal = None;
            picker_dismissed = true;
            data
        } else {
            None
        }
    } else {
        None
    };
    if picker_dismissed {
        invalidate_picker_fetch_on_dismiss(app);
    }
    let (session_id, source, cwd) = match entry_data {
        Some(d) => d,
        None => {
            let sessions = match app.session_picker_entries.take() {
                Some(s) => s,
                None => return vec![],
            };
            if !picker_dismissed {
                invalidate_picker_fetch_on_dismiss(app);
            }
            let entry = match sessions.get(index) {
                Some(e) => e,
                None => return vec![],
            };
            let d = (entry.id.clone(), entry.source.clone(), entry.cwd.clone());
            app.session_picker_loading = false;
            app.session_picker_state.query.clear();
            app.session_picker_state.query_cursor = 0;
            app.session_picker_state.search_active = false;
            app.session_picker_state.expanded.clear();
            app.session_picker_content_results = None;
            app.session_picker_content_loading = false;
            d
        }
    };
    if let Some(foreign_source) =
        crate::app::foreign_sessions::ForeignPickerSource::from_picker_source(&source)
    {
        let prompt = foreign_source.resume_prompt(&session_id);
        clear_startup_actions(app);
        if !app.session_startup_allowed() {
            app.deferred_startup.session = Some(
                crate::app::session_startup::DeferredSessionStartup::ForeignResume {
                    tool: foreign_source.tool(),
                    native_id: session_id,
                },
            );
            return vec![];
        }
        let mut effects = dispatch_new_session_inner(app, None);
        effects.extend(dispatch(Action::SendPrompt(prompt), app));
        return effects;
    }
    let chat_kind = source == "conversation";
    if chat_kind {
        return dispatch_load_session(app, session_id, None, true);
    }
    let local_cwd = app.cwd.to_string_lossy().to_string();
    if kigi_shell::session::resolve_local_session(&session_id, &local_cwd).is_some() {
        return dispatch_load_session(app, session_id, None, false);
    }
    if let Some(original_cwd) = kigi_shell::session::resolve_local_session_any_cwd(&session_id) {
        return dispatch_load_session(
            app,
            session_id,
            Some(std::path::PathBuf::from(original_cwd)),
            false,
        );
    }
    if source == "remote" || source == "both" {
        if focus_if_session_already_open(app, &session_id, false).is_some() {
            return vec![];
        }
        app.show_toast("Restoring session from remote...");
        dispatch_load_session_with_restore(app, session_id, cwd)
    } else {
        app.show_toast("Session not found locally");
        vec![]
    }
}
/// Pick a session from the picker and resume it in a new git worktree.
pub(in crate::app::dispatch) fn dispatch_pick_session_in_worktree(
    app: &mut AppView,
    index: usize,
) -> Vec<Effect> {
    use crate::views::modal::ActiveModal;
    if session_picker_external_filter_active(app) {
        return vec![];
    }
    let is_foreign = get_active_agent(app)
        .and_then(|agent| match agent.active_modal.as_ref() {
            Some(ActiveModal::SessionPicker {
                entries: Some(entries),
                ..
            }) => entries.get(index),
            _ => None,
        })
        .or_else(|| {
            app.session_picker_entries
                .as_ref()
                .and_then(|entries| entries.get(index))
        })
        .is_some_and(|entry| crate::app::foreign_sessions::is_foreign_picker_source(&entry.source));
    if is_foreign {
        app.show_toast("External sessions can't be resumed in a worktree");
        return vec![];
    }
    let mut picker_dismissed = false;
    let entry_data = if let Some(agent) = get_active_agent_mut(app) {
        if let Some(ActiveModal::SessionPicker { entries, .. }) = agent.active_modal.as_mut() {
            let data = entries
                .as_ref()
                .and_then(|s| s.get(index))
                .map(|e| (e.id.clone(), e.source.clone()));
            agent.active_modal = None;
            picker_dismissed = true;
            data
        } else {
            None
        }
    } else {
        None
    };
    if picker_dismissed {
        invalidate_picker_fetch_on_dismiss(app);
    }
    let (session_id, source) = match entry_data {
        Some(d) => d,
        None => {
            let sessions = match app.session_picker_entries.take() {
                Some(s) => s,
                None => return vec![],
            };
            if !picker_dismissed {
                invalidate_picker_fetch_on_dismiss(app);
            }
            let entry = match sessions.get(index) {
                Some(e) => e,
                None => return vec![],
            };
            let d = (entry.id.clone(), entry.source.clone());
            app.session_picker_loading = false;
            app.session_picker_state.query.clear();
            app.session_picker_state.query_cursor = 0;
            app.session_picker_state.search_active = false;
            app.session_picker_state.expanded.clear();
            d
        }
    };
    if source == "conversation" {
        app.show_toast("Chat conversations can't be resumed in a worktree");
        return vec![];
    }
    dispatch_new_worktree_session(app, Some(session_id), None, None, None, None, None)
}
/// Remove a deleted session identity from the modal session picker and the
/// welcome-screen picker, then re-anchor the selection on a real row.
///
/// Called after [`crate::app::actions::TaskResult::DeleteSessionComplete`] so
/// the just-deleted entry vanishes from the open list without a full refetch.
pub(in crate::app::dispatch) fn remove_session_from_pickers(
    app: &mut AppView,
    source: &str,
    session_id: &str,
) {
    use crate::views::modal::ActiveModal;
    use crate::views::session_picker::build_entry_map;
    app.session_picker_detail_generation += 1;
    if let Some(agent) = get_active_agent_mut(app)
        && let Some(ActiveModal::SessionPicker {
            entries,
            content_results,
            state,
            source_filter,
            content_loading,
            entries_query,
            pending_delete,
            ..
        }) = agent.active_modal.as_mut()
    {
        if pending_delete
            .as_ref()
            .is_some_and(|(pending_source, pending_id, _)| {
                pending_source == source && pending_id == session_id
            })
        {
            *pending_delete = None;
        }
        if let Some(list) = entries.as_mut() {
            list.retain(|entry| entry.source != source || entry.id != session_id);
        }
        if let Some(hits) = content_results.as_mut() {
            hits.retain(|h| h.session_id != session_id);
        }
        let current_repo =
            crate::views::session_picker::repo_name_from_cwd(&agent.session.cwd.to_string_lossy());
        let map = build_entry_map(
            entries.as_deref(),
            content_results.as_deref(),
            crate::views::session_picker::effective_filter_query(
                &state.query,
                entries_query.as_deref(),
            ),
            true,
            *content_loading,
            *source_filter,
            Some(current_repo.as_str()),
        );
        reanchor_grouped_selection(state, &map);
    }
    if let Some(list) = app.session_picker_entries.as_mut() {
        list.retain(|entry| entry.source != source || entry.id != session_id);
    }
    if let Some(hits) = app.session_picker_content_results.as_mut() {
        hits.retain(|h| h.session_id != session_id);
    }
    let welcome_current_repo =
        crate::views::session_picker::repo_name_from_cwd(&app.cwd.to_string_lossy());
    let welcome_map = build_entry_map(
        app.session_picker_entries.as_deref(),
        app.session_picker_content_results.as_deref(),
        crate::views::session_picker::effective_filter_query(
            &app.session_picker_state.query,
            app.session_picker_entries_query.as_deref(),
        ),
        app.session_picker_grouped,
        app.session_picker_content_loading,
        app.session_picker_source_filter,
        Some(welcome_current_repo.as_str()),
    );
    reanchor_grouped_selection(&mut app.session_picker_state, &welcome_map);
}
/// Clamp `state.selected` to a selectable slot in a grouped picker `map`
/// (`Some` = selectable row, `None` = non-selectable header).
pub(in crate::app::dispatch) fn reanchor_grouped_selection<T>(
    state: &mut crate::views::picker::PickerState,
    map: &[Option<T>],
) {
    state.scroll_offset = None;
    if map.is_empty() {
        state.selected = 0;
        return;
    }
    let mut sel = state.selected.min(map.len() - 1);
    while sel > 0 && map[sel].is_none() {
        sel -= 1;
    }
    if map[sel].is_none() {
        sel = map.iter().position(|e| e.is_some()).unwrap_or(0);
    }
    state.selected = sel;
}
/// Trigger a deep content search when the session picker query changes.
///
/// Any query of 2+ chars searches content — title matches never suppress
/// it. Forced (Ctrl+/) searches fire immediately; keystrokes otherwise
/// coalesce through [`Effect::DebounceSessionSearch`], whose expiry runs
/// the search only if its seq is still current. Shorter queries clear the
/// content results.
///
/// Checks the active agent's modal first; if no modal session picker
/// exists, falls back to the welcome-screen picker state.
pub(in crate::app::dispatch) fn dispatch_cycle_session_source_filter(
    app: &mut AppView,
) -> Vec<Effect> {
    use crate::views::modal::ActiveModal;
    app.session_picker_detail_generation += 1;
    if let Some(agent) = get_active_agent_mut(app)
        && let Some(ActiveModal::SessionPicker {
            state,
            content_results,
            content_loading,
            deep_search_seq,
            source_filter,
            pending_delete,
            ..
        }) = agent.active_modal.as_mut()
    {
        *source_filter = source_filter.next();
        state.selected = 0;
        state.scroll_offset = None;
        if *source_filter == crate::views::session_picker::SourceFilter::External {
            *content_results = None;
            *content_loading = false;
            *deep_search_seq += 1;
            state.expanded.clear();
            *pending_delete = None;
        }
        return vec![];
    }
    app.session_picker_source_filter = app.session_picker_source_filter.next();
    app.session_picker_state.selected = 0;
    app.session_picker_state.scroll_offset = None;
    if app.session_picker_source_filter == crate::views::session_picker::SourceFilter::External {
        app.session_picker_content_results = None;
        app.session_picker_content_loading = false;
        app.session_picker_deep_search_seq += 1;
        app.session_picker_state.expanded.clear();
    }
    vec![]
}
pub(in crate::app::dispatch) fn dispatch_trigger_deep_search(
    app: &mut AppView,
    force: bool,
) -> Vec<Effect> {
    use crate::views::modal::ActiveModal;
    if app.chat_mode {
        return dispatch_chat_search_refetch(app, force);
    }
    if let Some(agent) = get_active_agent_mut(app)
        && let Some(ActiveModal::SessionPicker {
            state,
            content_results,
            content_loading,
            deep_search_seq,
            source_filter,
            ..
        }) = agent.active_modal.as_mut()
    {
        if *source_filter == crate::views::session_picker::SourceFilter::External {
            *deep_search_seq += 1;
            *content_results = None;
            *content_loading = false;
            state.expanded.clear();
            return vec![];
        }
        let query = state.query.trim().to_string();
        *deep_search_seq += 1;
        let seq = *deep_search_seq;
        if query.len() < 2 {
            *content_results = None;
            *content_loading = false;
            return vec![];
        }
        *content_loading = true;
        if force {
            return vec![Effect::DeepSearchSessions { query, seq }];
        }
        return vec![Effect::DebounceSessionSearch { query, seq }];
    }
    if app.session_picker_source_filter == crate::views::session_picker::SourceFilter::External {
        app.session_picker_deep_search_seq += 1;
        app.session_picker_content_results = None;
        app.session_picker_content_loading = false;
        app.session_picker_state.expanded.clear();
        return vec![];
    }
    let query = app.session_picker_state.query.trim().to_string();
    app.session_picker_deep_search_seq += 1;
    let seq = app.session_picker_deep_search_seq;
    if query.len() < 2 {
        app.session_picker_content_results = None;
        app.session_picker_content_loading = false;
        return vec![];
    }
    app.session_picker_content_loading = true;
    if force {
        vec![Effect::DeepSearchSessions { query, seq }]
    } else {
        vec![Effect::DebounceSessionSearch { query, seq }]
    }
}
/// Chat-mode replacement for local deep search: refetch the session list
/// with the picker query pushed down as `kigi/session/list` `query`.
/// Keystrokes are coalesced through [`Effect::DebounceSessionSearch`]; a
/// forced search (Ctrl+/) or a cleared query fetches immediately. Every
/// trigger bumps `session_picker_list_seq`, so stale in-flight debounces
/// and fetches are dropped when they complete.
fn dispatch_chat_search_refetch(app: &mut AppView, force: bool) -> Vec<Effect> {
    use crate::views::modal::ActiveModal;
    let query = if let Some(agent) = get_active_agent(app)
        && let Some(ActiveModal::SessionPicker { state, .. }) = agent.active_modal.as_ref()
    {
        state.query.trim().to_string()
    } else {
        app.session_picker_state.query.trim().to_string()
    };
    app.session_picker_list_seq += 1;
    let seq = app.session_picker_list_seq;
    if query.is_empty() {
        set_chat_search_loading(app, false);
        return vec![Effect::FetchSessionList { query: None, seq }];
    }
    set_chat_search_loading(app, true);
    if force {
        vec![Effect::FetchSessionList {
            query: Some(query),
            seq,
        }]
    } else {
        vec![Effect::DebounceSessionSearch { query, seq }]
    }
}
/// Flip the search in-flight flag on the active picker surface (modal first,
/// welcome fallback — same order as `dispatch_chat_search_refetch`'s query
/// read).
fn set_chat_search_loading(app: &mut AppView, loading: bool) {
    use crate::views::modal::ActiveModal;
    if let Some(agent) = get_active_agent_mut(app)
        && let Some(ActiveModal::SessionPicker {
            content_loading, ..
        }) = agent.active_modal.as_mut()
    {
        *content_loading = loading;
        return;
    }
    app.session_picker_content_loading = loading;
}
fn session_picker_entry_source<'a>(app: &'a AppView, session_id: &str) -> Option<&'a str> {
    use crate::views::modal::ActiveModal;
    if let Some(agent) = get_active_agent(app)
        && let Some(ActiveModal::SessionPicker {
            entries: Some(entries),
            ..
        }) = agent.active_modal.as_ref()
        && let Some(e) = entries.iter().find(|e| e.id == session_id)
    {
        return Some(e.source.as_str());
    }
    app.session_picker_entries
        .as_ref()
        .and_then(|entries| entries.iter().find(|e| e.id == session_id))
        .map(|entry| entry.source.as_str())
}
pub(in crate::app::dispatch) fn session_picker_external_filter_active(app: &AppView) -> bool {
    use crate::views::modal::ActiveModal;
    if let Some(agent) = get_active_agent(app)
        && let Some(ActiveModal::SessionPicker { source_filter, .. }) = agent.active_modal.as_ref()
    {
        return *source_filter == crate::views::session_picker::SourceFilter::External;
    }
    app.session_picker_source_filter == crate::views::session_picker::SourceFilter::External
}
/// Whether the picker row with `session_id` is a backend conversation.
pub(in crate::app::dispatch) fn session_picker_entry_is_conversation(
    app: &AppView,
    session_id: &str,
) -> bool {
    session_picker_entry_source(app, session_id) == Some("conversation")
}
pub(in crate::app::dispatch) fn session_picker_entry_matches(
    app: &AppView,
    source: &str,
    session_id: &str,
) -> bool {
    use crate::views::modal::ActiveModal;
    if let Some(agent) = get_active_agent(app)
        && let Some(ActiveModal::SessionPicker {
            entries,
            content_results,
            ..
        }) = agent.active_modal.as_ref()
    {
        return entries.as_ref().is_some_and(|entries| {
            entries
                .iter()
                .any(|entry| entry.source == source && entry.id == session_id)
        }) || (source == "local"
            && content_results
                .as_ref()
                .is_some_and(|results| results.iter().any(|hit| hit.session_id == session_id)));
    }
    app.session_picker_entries.as_ref().is_some_and(|entries| {
        entries
            .iter()
            .any(|entry| entry.source == source && entry.id == session_id)
    }) || (source == "local"
        && app
            .session_picker_content_results
            .as_ref()
            .is_some_and(|results| results.iter().any(|hit| hit.session_id == session_id)))
}
/// Pick a session from deep content search results.
pub(in crate::app::dispatch) fn dispatch_pick_content_session(
    app: &mut AppView,
    session_id: String,
    cwd: String,
) -> Vec<Effect> {
    if session_picker_external_filter_active(app) {
        return vec![];
    }
    let chat_kind = session_picker_entry_is_conversation(app, &session_id);
    app.session_picker_entries = None;
    app.session_picker_loading = false;
    app.session_picker_state.reset();
    app.session_picker_content_results = None;
    app.session_picker_content_loading = false;
    invalidate_picker_fetch_on_dismiss(app);
    if chat_kind {
        return dispatch_load_session(app, session_id, None, true);
    }
    let local_cwd = app.cwd.to_string_lossy().to_string();
    if kigi_shell::session::resolve_local_session(&session_id, &local_cwd).is_some() {
        return dispatch_load_session(app, session_id, None, false);
    }
    if let Some(original_cwd) = kigi_shell::session::resolve_local_session_any_cwd(&session_id) {
        return dispatch_load_session(
            app,
            session_id,
            Some(std::path::PathBuf::from(original_cwd)),
            false,
        );
    }
    if focus_if_session_already_open(app, &session_id, false).is_some() {
        return vec![];
    }
    app.show_toast("Restoring session from remote...");
    dispatch_load_session_with_restore(app, session_id, cwd)
}
/// Create a placeholder agent and restore a remote session before loading.
/// Build rows only — conversation rows never reach the restore path.
pub(in crate::app::dispatch) fn dispatch_load_session_with_restore(
    app: &mut AppView,
    session_id: String,
    session_cwd: String,
) -> Vec<Effect> {
    if crate::app::session_startup::chat_mode_refuses_local_build_load(
        app.chat_mode,
        false,
        &session_id,
        &app.cwd,
    ) {
        app.show_toast(crate::app::session_startup::CHAT_MODE_LOCAL_BUILD_REFUSAL);
        return vec![];
    }
    if focus_if_session_already_open(app, &session_id, false).is_some() {
        return vec![];
    }
    let agent_id = AgentId(app.next_agent_id);
    app.next_agent_id += 1;
    let mut scrollback = ScrollbackState::new();
    scrollback.set_appearance(app.appearance.clone());
    scrollback.push_block(RenderBlock::system(format!(
        "Restoring session {session_id} from remote..."
    )));
    let agent = AgentView::new(
        AgentSession {
            id: agent_id,
            acp_tx: app.acp_tx.clone(),
            session_id: None,
            models: app.models.clone(),
            state: AgentState::Idle,
            tracker: AcpUpdateTracker::new(),
            cwd: app.cwd.clone(),
            is_worktree: false,
            forked_from: None,
            pending_prompts: std::collections::VecDeque::new(),
            next_queue_id: 0,
            yolo_mode: app.default_yolo,
            auto_mode: inherit_auto_mode(app),
            prompt_history: Vec::new(),
            prompt_history_loading: true,
            loading_replay: true,
            restore_degree: None,
            rate_limited: false,
            model_incompatible: false,
            available_commands: app.bootstrap_acp_commands.clone(),
            available_commands_generation: 1,
            available_tools: None,
            model_switch_pending: false,
            user_model_preference: None,
            deferred_model_switch: app.deferred_model_switch_from_cli(),
            bg_tasks: std::collections::BTreeMap::new(),
            bg_tool_call_to_task: std::collections::HashMap::new(),
            scheduled_tasks: std::collections::HashMap::new(),
            in_flight_prompt: None,
            current_prompt_id: None,
            created_via_new: false,
        },
        scrollback,
    );
    app.agents.insert(agent_id, agent);
    {
        let agent = app.agents.get_mut(&agent_id).unwrap();
        agent.attached_as_viewer = true;
        agent.begin_replay_window();
        agent.prompt.set_compact(app.appearance.prompt.compact);
        agent.prompt.adopt_slash_mru(app.slash_mru.clone());
        agent
            .prompt
            .set_contextual_hints(app.contextual_hints.undo, app.contextual_hints.plan_mode);
        agent.set_session_recap_available(app.session_recap_available);
        agent.apply_app_scoped_gates(app.usage_visible, app.chat_mode, app.screen_mode, &[]);
        agent.chat_kind = app.chat_mode;
        agent
            .prompt
            .slash_controller
            .registry_mut()
            .set_plugins_visible(!app.appearance.disable_plugins);
    }
    switch_to_agent(app, agent_id, SwitchCause::Load);
    vec![Effect::RestoreAndLoadSession {
        agent_id,
        session_id,
        session_cwd,
    }]
}
#[allow(clippy::too_many_arguments)]
pub(in crate::app::dispatch) fn handle_session_loaded(
    app: &mut AppView,
    agent_id: AgentId,
    session_id: acp::SessionId,
    new_models: Option<acp::SessionModelState>,
    code_restored: bool,
    restore_summary: Option<String>,
    restore_degree: Option<kigi_workspace::session::git::RestoreDegree>,
    running_prompt_id: Option<String>,
) -> Vec<Effect> {
    tracing::info!(
        "Session loaded for agent {:?} session {:?}",
        agent_id,
        session_id,
    );
    if let Some(agent) = app.agents.get_mut(&agent_id) {
        if defer_to_open_reload_window(agent, agent_id, "SessionLoaded") {
            return vec![];
        }
        let hydrate_sid = session_id.clone();
        agent.bind_session_id(session_id);
        agent.scrollback.end_batch();
        agent.session.loading_replay = false;
        agent.session.restore_degree = restore_degree;
        agent.session.finish_turn(&mut agent.scrollback);
        agent.mark_turn_finished();
        if let Some(placeholder_id) = agent.loading_placeholder_id.take() {
            agent.scrollback.remove_entry(placeholder_id);
        }
        if let Some(m) = new_models {
            app.models = Some(m).into();
            agent.session.models = app.models.clone();
        }
        let deferred = crate::app::dispatch::session::lifecycle::apply_deferred_model_switch(
            agent,
            app.cli_effort_token.as_deref(),
        );
        match (code_restored, restore_summary.as_deref()) {
            (true, Some(s)) => {
                agent
                    .scrollback
                    .push_block(RenderBlock::system(format!("\u{2713} Code restored: {s}")));
            }
            (false, Some(s)) => {
                agent.scrollback.push_block(RenderBlock::system(format!(
                    "\u{26A0} Code restore failed: {s}"
                )));
            }
            _ => {}
        }
        if let Some(info) = agent.pending_fork_banner.take() {
            let sid = agent
                .session
                .session_id
                .as_ref()
                .map(|s| s.0.as_ref())
                .unwrap_or("???");
            let banner = build_child_fork_marker(
                sid,
                &info.parent_sid,
                info.worktree,
                crate::views::dashboard::session_switch_hint_command(app.screen_mode.is_minimal()),
            );
            agent.scrollback.push_block(RenderBlock::system(banner));
        }
        let adopting = running_prompt_id
            .as_deref()
            .is_some_and(|pid| agent.should_adopt_running_prompt(pid));
        let preserve = running_prompt_id.as_deref().filter(|_| adopting);
        agent.reset_follow_ups_for_reload_preserving(preserve);
        if adopting && let Some(running_pid) = running_prompt_id {
            agent.adopt_running_prompt(running_pid);
        } else {
            agent.scrollback.finish_all_running();
            for child in agent.subagent_views.values_mut() {
                child.scrollback.finish_all_running();
            }
        }
        let mut effects = Vec::new();
        if let Some(directive) = agent.pending_first_prompt.take() {
            agent.session.enqueue_prompt_front(directive);
        }
        effects.extend(maybe_drain_queue(agent));
        let cwd = agent.session.cwd.clone();
        effects.push(Effect::HydrateSessionTitleFromDisk {
            agent_id,
            session_id: hydrate_sid.clone(),
            cwd: cwd.clone(),
        });
        agent.session.prompt_history_loading = true;
        effects.push(Effect::FetchPromptHistory {
            agent_id,
            cwd,
            session_id: hydrate_sid.to_string(),
        });
        effects.push(Effect::FetchSessionAgentName {
            agent_id,
            session_id: hydrate_sid.clone(),
        });
        if let Some((model_id, effort)) = deferred {
            agent.session.model_switch_pending = true;
            effects.push(Effect::SwitchModel {
                agent_id,
                session_id: hydrate_sid.clone(),
                model_id,
                effort,
                prev_model_id: None,
            });
        }
        if std::mem::take(&mut agent.pending_extensions_fetch) && agent.extensions_modal.is_some() {
            effects.extend(extensions_modal_tab_fetches(agent_id, hydrate_sid.clone()));
        }
        effects.push(Effect::RegisterActiveSession {
            session_id: hydrate_sid,
            cwd: agent.session.cwd.display().to_string(),
        });
        notify_session_ready(&app.notification_service, agent);
        crate::memory_release::release_retained_memory_with("session-load-replay");
        return effects;
    }
    vec![]
}
pub(in crate::app::dispatch) fn handle_session_load_failed(
    app: &mut AppView,
    agent_id: AgentId,
    session_id: acp::SessionId,
    error: String,
) -> Vec<Effect> {
    tracing::error!(
        agent = ? agent_id, session = ? session_id, error = % error,
        "Session load failed"
    );
    if let Some(agent) = app.agents.get_mut(&agent_id) {
        if defer_to_open_reload_window(agent, agent_id, "SessionLoadFailed") {
            return vec![];
        }
        agent.pending_extensions_fetch = false;
        agent.session.prompt_history_loading = false;
        agent.session.finish_command();
        agent.mark_turn_finished();
        agent.scrollback.end_batch();
        agent.session.loading_replay = false;
        agent.pending_first_prompt = None;
        agent.pending_fork_banner = None;
        agent
            .scrollback
            .push_block(RenderBlock::session_event(SessionEvent::TurnFailed {
                error: format!("Couldn't load session: {error}"),
                elapsed: None,
            }));
    }
    vec![]
}
pub(in crate::app::dispatch) fn handle_session_search_debounce_expired(
    app: &mut AppView,
    query: String,
    seq: u64,
) -> Vec<Effect> {
    if app.chat_mode {
        if seq != app.session_picker_list_seq {
            return vec![];
        }
        return vec![Effect::FetchSessionList {
            query: (!query.is_empty()).then_some(query),
            seq,
        }];
    }
    if live_deep_search_seq(app) != Some(seq) {
        return vec![];
    }
    vec![Effect::DeepSearchSessions { query, seq }]
}
/// The deep-search seq of the surface that can still consume results: an
/// open modal SessionPicker (its own counter), else the welcome-screen
/// picker only while the welcome view is showing. `None` when neither
/// surface is live — dismissing a modal bumps the WELCOME counter, which
/// can collide with (not invalidate) a modal-armed seq, so those expiries
/// are dropped by liveness rather than counter arithmetic.
fn live_deep_search_seq(app: &AppView) -> Option<u64> {
    use crate::views::modal::ActiveModal;
    if let Some(agent) = get_active_agent(app)
        && let Some(ActiveModal::SessionPicker {
            deep_search_seq, ..
        }) = agent.active_modal.as_ref()
    {
        return Some(*deep_search_seq);
    }
    matches!(app.active_view, crate::app::app_view::ActiveView::Welcome)
        .then_some(app.session_picker_deep_search_seq)
}
pub(in crate::app::dispatch) fn handle_card_detail_loaded(
    app: &mut AppView,
    source: String,
    session_id: String,
    generation: u64,
    detail: crate::app::app_view::CardDetail,
) -> Vec<Effect> {
    use crate::views::modal::ActiveModal;
    if generation != app.session_picker_detail_generation
        || crate::app::foreign_sessions::is_foreign_picker_source(&source)
    {
        return vec![];
    }
    if let Some(agent) = get_active_agent_mut(app)
        && let Some(ActiveModal::SessionPicker { entries, .. }) = agent.active_modal.as_mut()
    {
        if let Some(entry) = entries.as_mut().and_then(|sessions| {
            sessions.iter_mut().find(|entry| {
                entry.source == source
                    && entry.id == session_id
                    && !crate::app::foreign_sessions::is_foreign_picker_source(&entry.source)
            })
        }) {
            entry.card_detail = Some(detail);
        }
        return vec![];
    }
    if let Some(ref mut sessions) = app.session_picker_entries
        && let Some(entry) = sessions.iter_mut().find(|entry| {
            entry.source == source
                && entry.id == session_id
                && !crate::app::foreign_sessions::is_foreign_picker_source(&entry.source)
        })
    {
        entry.card_detail = Some(detail);
    }
    vec![]
}
pub(in crate::app::dispatch) fn handle_session_restored(
    app: &mut AppView,
    agent_id: AgentId,
    local_session_id: String,
) -> Vec<Effect> {
    if crate::app::session_startup::chat_mode_refuses_local_build_load(
        app.chat_mode,
        false,
        &local_session_id,
        &app.cwd,
    ) {
        refuse_chat_mode_build_agent(app, agent_id);
        return vec![];
    }
    let sid = clear_stale_session_id(app, &local_session_id);
    if let Some(agent) = app.agents.get_mut(&agent_id) {
        supersede_open_reload_window(agent, agent_id, "SessionRestored");
        agent.bind_session_id(sid);
        agent.chat_kind = app.chat_mode;
        agent.scrollback.push_block(RenderBlock::system(format!(
            "Session restored. Loading {local_session_id}..."
        )));
    }
    let cwd = app.cwd.clone();
    vec![Effect::LoadSession {
        agent_id,
        session_id: local_session_id,
        session_cwd: Some(cwd),
        chat_kind: false,
    }]
}
pub(in crate::app::dispatch) fn handle_session_restore_failed(
    app: &mut AppView,
    agent_id: AgentId,
    error: String,
) -> Vec<Effect> {
    tracing::error!(agent = ? agent_id, error = % error, "Session restore failed");
    if let Some(agent) = app.agents.get_mut(&agent_id) {
        if defer_to_open_reload_window(agent, agent_id, "SessionRestoreFailed") {
            return vec![];
        }
        agent.pending_extensions_fetch = false;
        agent.session.loading_replay = false;
        agent.session.prompt_history_loading = false;
        agent
            .scrollback
            .push_block(RenderBlock::session_event(SessionEvent::TurnFailed {
                error: format!("Couldn't restore session: {error}"),
                elapsed: None,
            }));
    }
    vec![]
}
pub(in crate::app::dispatch) fn handle_deep_search_results(
    app: &mut AppView,
    results: Vec<kigi_shell::extensions::session_search::SearchSessionHit>,
    seq: u64,
) -> Vec<Effect> {
    use crate::views::modal::ActiveModal;
    if let Some(agent) = get_active_agent_mut(app)
        && let Some(ActiveModal::SessionPicker {
            content_results,
            content_loading,
            deep_search_seq,
            source_filter,
            ..
        }) = agent.active_modal.as_mut()
    {
        if seq == *deep_search_seq
            && *source_filter != crate::views::session_picker::SourceFilter::External
        {
            *content_results = Some(results);
            *content_loading = false;
        }
        return vec![];
    }
    if seq == app.session_picker_deep_search_seq
        && app.session_picker_source_filter != crate::views::session_picker::SourceFilter::External
    {
        app.session_picker_content_results = Some(results);
        app.session_picker_content_loading = false;
    }
    vec![]
}
pub(in crate::app::dispatch) fn dispatch_show_session_picker(app: &mut AppView) -> Vec<Effect> {
    use crate::views::modal::ActiveModal;
    with_active_agent(app, |agent| {
        agent.active_modal = Some(ActiveModal::SessionPicker {
            state: crate::views::picker::PickerState::default(),
            entries: None,
            loading: true,
            lanes: Default::default(),
            previous_palette: None,
            window: crate::views::modal_window::ModalWindowState::new(),
            content_results: None,
            content_loading: false,
            deep_search_seq: 0,
            entries_query: None,
            source_filter: crate::views::session_picker::SourceFilter::default(),
            pending_delete: None,
        });
    });
    dispatch_fetch_session_list(app)
}
/// The picker (modal `/resume` or welcome screen) was dismissed without a
/// pick. Its own fields die with it, but a still-current in-flight
/// list/search fetch would fall through to the welcome picker fields in
/// `handle_session_list_loaded`, stamping them with a query the welcome
/// search box never had — or repopulating a picker the user just closed.
/// Invalidate it (same seq idiom as `dispatch_fetch_session_list`).
pub(in crate::app::dispatch) fn dispatch_session_picker_closed(app: &mut AppView) -> Vec<Effect> {
    invalidate_picker_fetch_on_dismiss(app);
    vec![]
}
/// Fetch invalidation shared by EVERY picker-dismissal path:
/// modal Esc/mouse close, modal and welcome picks (all variants), and the
/// welcome-screen Esc. Only chat mode can have a query-stamped search in
/// flight; Build mode must NOT bump — only the plain list fetch exists there
/// and its responses keep their pre-existing last-write-wins behavior.
fn invalidate_picker_fetch_on_dismiss(app: &mut AppView) {
    invalidate_foreign_picker(app);
    if app.chat_mode {
        app.session_picker_list_seq += 1;
    }
    app.session_picker_deep_search_seq += 1;
    app.session_picker_content_loading = false;
}
pub(in crate::app::dispatch) fn dispatch_pick_content_session_in_worktree(
    app: &mut AppView,
    session_id: String,
    _: String,
) -> Vec<Effect> {
    if session_picker_external_filter_active(app) {
        return vec![];
    }
    if session_picker_entry_is_conversation(app, &session_id) {
        app.show_toast("Chat conversations can't be resumed in a worktree");
        return vec![];
    }
    app.session_picker_entries = None;
    app.session_picker_loading = false;
    app.session_picker_state.reset();
    app.session_picker_content_results = None;
    app.session_picker_content_loading = false;
    if let Some(agent) = get_active_agent_mut(app) {
        agent.active_modal = None;
    }
    invalidate_picker_fetch_on_dismiss(app);
    dispatch_new_worktree_session(app, Some(session_id), None, None, None, None, None)
}
