//! Dashboard dispatchers: attach, overlays, rows, renames, and permissions. use super::ctx::{show_welcome, surface_yolo_launch_block_notice}; use super::modes::{dispatch_cycle_mode_and_sync, set_yolo_mode, yolo_enable_blocked}; use super::permissions::resolve_permission_queue_transition; use super::queue::maybe_drain_queue; use super::router::dispatch; use super::session::lifecycle::{ dispatch_new_session_inner_with_id, dispatch_new_worktree_session, }; use super::session::load::dispatch_load_session; use super::session::load::focus_if_session_already_open; use super::session::modal::dispatch_sessions_confirm_close; use super::turn::dispatch_cancel_turn; use crate::app::actions::{Action, Effect}; use crate::app::agent::AgentId; use crate::app::agent_view::AgentView; use crate::app::app_view::{ActiveView, AppView, TrustState}; use agent_client_protocol as acp; // --------------------------------------------------------------------------- // Agent Dashboard dispatchers // --------------------------------------------------------------------------- /// Build a `DashboardState` from the persisted layout (pins / reorder / /// grouping), loading + caching `app.dashboard_persisted` on first use. Used /// both to materialize the real dashboard and to compute a correct cycle order /// before the dashboard has been opened. fn dashboard_state_from_persisted(app: &mut AppView) -> crate::views::dashboard::DashboardState { use crate::views::dashboard::{DashboardState, load_persisted}; if app.dashboard_persisted.is_none() { app.dashboard_persisted = load_persisted(); } let persisted = app .dashboard_persisted .clone() .unwrap_or_else(crate::views::dashboard::PersistedDashboard::defaults); let resolver = crate::views::dashboard::SessionIdResolver::from_agents(&app.agents); DashboardState::from_persisted(&persisted, &resolver) } pub(super) fn ensure_dashboard_state(app: &mut AppView) { if app.dashboard.is_some() { return; } let mut state = dashboard_state_from_persisted(app); state.gc_stale_refs(&dashboard_alive_fn(&app.agents)); state.adopt_slash_mru(app.slash_mru.clone()); state.set_screen_mode(app.screen_mode); state.set_recap_visible(app.session_recap_available); state.set_restricted_commands(&[]); app.dashboard = Some(state); } /// Configure the dashboard for display: snapshot app-wide state (cwd, models, /// plugins, `default_yolo`) and reset per-session staging. Shared by /// `dispatch_open_dashboard` and the overlay-cycle path; no-op if unallocated. fn configure_dashboard_state(app: &mut AppView) { let bootstrap_commands = app.bootstrap_acp_commands.clone(); let models = app.models.clone(); let disable_plugins = app.appearance.disable_plugins; let default_yolo = app.default_yolo; let cwd = app.cwd.clone(); let cwd_has_git_ancestor = app.cwd_has_git_ancestor; let has_agents = !app.agents.is_empty(); if let Some(d) = app.dashboard.as_mut() { d.close_popup(); d.location_picker = None; d.cwd = cwd.clone(); d.cwd_has_git_ancestor = cwd_has_git_ancestor; d.dispatch_worktree = false; d.worktree_dialog = None; d.pending_worktree_prompt = None; d.pending_worktree_attach = false; d.focus_new_agent_button(); d.list_focused = has_agents; d.dispatch.file_search.retarget(&cwd); // Tool gating disabled (None): the dashboard has no agent toolset. d.dispatch .slash_controller .registry_mut() .set_plugins_visible(!disable_plugins); d.dispatch .sync_acp_commands(&bootstrap_commands, None, &models); d.models = models; d.pending_model = None; d.pending_mode = if default_yolo { crate::views::dashboard::DashboardDispatchMode::AlwaysApprove } else { crate::views::dashboard::DashboardDispatchMode::Normal }; } } /// Open the dashboard view. Respects the /// [`crate::views::dashboard::dashboard_enabled`] feature flag (env var /// override + persisted setting). The dashboard is independent of leader /// mode: it renders local sessions from `app.agents` and, when connected /// via a leader, additionally polls the leader roster (see the roster-poll /// gate in the event loop). pub(super) fn dispatch_open_dashboard(app: &mut AppView) -> Vec { use crate::views::dashboard::dashboard_enabled; if !dashboard_enabled() { app.show_toast("Agent dashboard is disabled in this configuration"); return vec![]; } // Gate behind auth. Until login completes, the // backend rejects new sessions; activating the dashboard view // visually dismisses the auth UI. Toast and stay put. if !matches!(app.auth_state, crate::app::app_view::AuthState::Done) { app.show_toast("Sign in to open the dashboard"); return vec![]; } // Same rationale for folder trust: opening the dashboard would visually // dismiss the trust question with the folder still unanswered. Toast and // stay put (mirrors the auth gate above) so the question is resolved first. if matches!(app.trust_state, TrustState::Pending { .. }) { app.show_toast("Answer the folder-trust question to open the dashboard"); return vec![]; } // Edge case 24: idempotent toggle — opening from the dashboard view // itself just closes. // // Ctrl+\ is now a single-shot toggle between the // agent view and the dashboard view. The previous design used // Ctrl+\ as a 3-state cascade (open dashboard → close popup → // exit dashboard) which fought the user's mental model of // "Ctrl+\ flips views". With auto-attach landing a popup on every // open, the close-popup step would have eaten the user's expected // exit press. Now Ctrl+\ always exits when already in the // dashboard; closing the popup-only stays bound to Esc inside the // popup mouse/key cascade. if matches!(app.active_view, ActiveView::AgentDashboard) { return dispatch_exit_dashboard(app); } // Preserve in-memory state across reopen. // `app.dashboard.is_some()` means we've previously initialised // it; preserve the user's filter / dispatch text / hover / // selection. Otherwise seed from persisted state. if app.dashboard.is_none() { ensure_dashboard_state(app); } else if let Some(d) = app.dashboard.as_mut() { // Subsequent reopen — just gc dead ids; in-memory state stays. d.gc_stale_refs(&dashboard_alive_fn(&app.agents)); d.set_recap_visible(app.session_recap_available); d.set_restricted_commands(&[]); } // Refresh each local agent's git context (branch / worktree / label) // from disk so the row subtitles show the LATEST branch and worktree // name, not a value that may have gone stale since the last // `git_head_changed` notification. Runs on open (a deliberate user // action), never per frame; `compute_cwd_git_info` returns `None` for // non-repo cwds so existing values are left intact rather than cleared. let agent_cwds: Vec<(AgentId, std::path::PathBuf)> = app .agents .iter() .map(|(id, a)| (*id, a.session.cwd.clone())) .collect(); for (id, cwd) in agent_cwds { if let Some(info) = crate::git_info::compute_cwd_git_info(&cwd) && let Some(agent) = app.agents.get_mut(&id) { agent.current_branch = info.branch; agent.is_worktree = info.is_worktree; agent.main_repo = info.main_repo; agent.worktree_label = info.worktree_label; } } // The previous "auto-attach popup overlay" path // showed BOTH the dashboard (as a top banner) AND the focused // agent (as a bottom popup) on every `/dashboard` open. The // stacked layout was confusing — the user couldn't tell which // view owned the prompt, and the popup's keybindings (Enter // to send, etc.) had subtle input-routing bugs. Dashboard // open now shows ONLY the dashboard; pressing Enter on a row // switches the whole view to the agent's fullscreen view // (handled in `dispatch_dashboard_attach`). // // Always open in NEW-SESSION mode: focus the `[+ New Agent]` button // (no row selected) so typing a prompt + Enter dispatches a brand // new agent. Reply mode is opt-in — the user navigates (↑/↓ or j/k) // or clicks a row to select it, which arms "reply to that agent". // // Previously the dashboard pre-seeded `selected` to the agent the // user came from, which silently armed reply mode: a prompt typed // right after opening went to the old agent instead of spawning a // new one, AND the reply path never clears `selected`, so EVERY // subsequent dispatch kept replying to the same agent — the user // got "stuck to the same agent" and couldn't quickly dispatch new // sessions. New-session is the dashboard's primary gesture, so it is // the default; reply stays one explicit selection away. // configure_dashboard_state(app); app.active_view = ActiveView::AgentDashboard; // Outside leader mode there is no live leader roster to poll, so the // dashboard would only show this process's in-memory agents. Seed it with // the local on-disk session list (dormant/idle sessions) so it isn't empty; // the event loop keeps it fresh on the roster-poll timer while open. if !app.leader_mode { app.dashboard_sessions_loading = true; return vec![Effect::FetchDashboardSessions]; } vec![] } /// Helper: produce a closure that answers "does this DashboardRowId /// still exist in `agents`?". Static lifetime not possible (closures /// borrow), so callers pass `&app.agents`. fn dashboard_alive_fn( agents: &indexmap::IndexMap, ) -> impl Fn(&crate::views::dashboard::DashboardRowId) -> bool + '_ { move |id| match id { crate::views::dashboard::DashboardRowId::TopLevel(a) => agents.contains_key(a), crate::views::dashboard::DashboardRowId::Subagent { parent, child_session_id, } => agents .get(parent) .is_some_and(|a| a.subagent_sessions.contains_key(child_session_id)), // Roster-only rows are not locally hosted; they aren't tracked by // `agents` and are never persisted, so treat them as not alive for // pinned/reorder GC purposes. crate::views::dashboard::DashboardRowId::Roster { .. } => false, } } pub(super) fn dispatch_exit_dashboard(app: &mut AppView) -> Vec { // Also clear any popup attachment so a fresh // reopen lands on the row list, not on a stale popup // (`close_popup()` atomically clears the hit // rects too.) if let Some(d) = app.dashboard.as_mut() { d.close_popup(); } // Return to either Welcome or the most recently active agent. if let Some(id) = app.agents.keys().next().copied() { app.active_view = ActiveView::Agent(id); surface_yolo_launch_block_notice(app, id); } else { show_welcome(app); } vec![] } pub(super) fn dispatch_dashboard_attach( app: &mut AppView, id: crate::views::dashboard::DashboardRowId, ) -> Vec { use crate::views::dashboard::DashboardRowId; // Attach is a fullscreen view switch AND signals // "session-overlay mode" via `attached_agent`. The agent view // takes the full screen and the renderer wraps it in a bordered // frame with `[Prev] [Next] [✗]` affordances at the top right // (mirrors the subagent fullscreen takeover). Input goes // straight to the agent because `active_view = Agent(id)`, so // Enter/Shift+Tab/etc. all work as in any regular agent view. // // Attaching re-targets the overlay — an overlay stop-confirm armed // on a previously attached agent (legacy popup row-click path // reaches here without a key press) must not follow the user in. clear_pending_overlay_stop(app); match id { DashboardRowId::TopLevel(agent_id) => { if !app.agents.contains_key(&agent_id) { if let Some(d) = app.dashboard.as_mut() { d.set_error_toast("Session no longer exists"); } return vec![]; } if let Some(agent) = app.agents.get_mut(&agent_id) { // Drop any prior subagent takeover so the agent // view paints the parent, not whatever subagent // the user last opened fullscreen. agent.active_subagent = None; } if let Some(d) = app.dashboard.as_mut() { // `focus_row` (not a bare `selected` assignment) so the // section cursor / button focus are cleared — exactly one // cursor target stays active. d.focus_row(DashboardRowId::TopLevel(agent_id)); // Signal session-overlay mode: render the agent // wrapped in the bordered frame with cycle/close // affordances at the top right. d.attached_agent = Some(agent_id); } app.active_view = ActiveView::Agent(agent_id); surface_yolo_launch_block_notice(app, agent_id); } DashboardRowId::Subagent { parent, child_session_id, } => { // If the subagent has finished/disappeared // between row build and attach, toast and stay put rather // than silently switching to the parent. let alive = app .agents .get(&parent) .is_some_and(|a| a.subagent_sessions.contains_key(&child_session_id)); if !alive { if let Some(d) = app.dashboard.as_mut() { d.set_error_toast("Subagent no longer running"); } return vec![]; } if let Some(agent) = app.agents.get_mut(&parent) && agent.subagent_views.contains_key(&child_session_id) { // Mirror `open_subagent_fullscreen`: load the resume-deferred // child transcript on demand so the dashboard shows full history. crate::app::subagent::ensure_subagent_child_replayed(agent, &child_session_id); agent.active_subagent = Some(child_session_id.clone()); } let row_id = DashboardRowId::Subagent { parent, child_session_id, }; if let Some(d) = app.dashboard.as_mut() { d.focus_row(row_id.clone()); d.attached_agent = Some(parent); } app.active_view = ActiveView::Agent(parent); surface_yolo_launch_block_notice(app, parent); } DashboardRowId::Roster { session_id } => { // A roster row is a leader-hosted session this client is not // locally attached to. Attaching issues an ACP `session/load`, // which the leader join-not-steal-subscribes: it adds this // client to the session's subscriber set (replay + live // broadcast) instead of stealing ownership. Mirror the session // picker's strict resume path — inserting a // leader-hosted, possibly cross-cwd session into THIS cwd's // recent list would be wrong. // Resolve cwd + origin first (also drives kind for focus-if-open). let (session_cwd, conversation_entry) = app .leader_roster .iter() .chain(app.dashboard_local_sessions.iter()) .find(|e| e.session_id == session_id) .map(|e| { let is_conversation = e.origin.kind == "conversation"; ( // Conversation rows have no cwd to re-home into. (!is_conversation).then(|| std::path::PathBuf::from(&e.cwd)), is_conversation, ) }) .unwrap_or((None, false)); // Already local (e.g. double-click after the row converted): focus only. if focus_if_session_already_open(app, session_id.as_str(), conversation_entry).is_some() { return vec![]; } // Mirror the picker resume path: allocate a new local agent and // emit `Effect::LoadSession` (strict load). `dispatch_load_session` // already sets `app.active_view` to the new agent. Conversation- // origin roster rows carry the conversation-entry bit so they take // the direct chat load, never local/GCS resolution. let effects = dispatch_load_session(app, session_id, session_cwd, conversation_entry); if let Some(new_id) = effects.iter().find_map(|e| match e { Effect::LoadSession { agent_id, .. } => Some(*agent_id), _ => None, }) && let Some(d) = app.dashboard.as_mut() { d.focus_row(DashboardRowId::TopLevel(new_id)); d.attached_agent = Some(new_id); } return effects; } } vec![] } /// Exit the dashboard's session-overlay: dismiss the bordered /// chrome and return to the dashboard view. Mirrors the popup /// `[✗]` close from the older design but applied to the new /// fullscreen-with-frame layout. pub(super) fn dispatch_dashboard_overlay_exit(app: &mut AppView) -> Vec { if let Some(d) = app.dashboard.as_mut() { d.close_popup(); } // Leaving the overlay by mouse (`[Dashboard]` click) doesn't pass // through the key-press disarm in `handle_input`, so an armed // overlay stop-confirm would otherwise survive the exit and let a // later Ctrl+X on the dashboard close a session without the // two-press ritual. The confirm is bound to "this overlay, this // agent" — clear it on the way out. clear_pending_overlay_stop(app); app.active_view = ActiveView::AgentDashboard; vec![] } /// Disarm a pending overlay stop-confirm (see /// [`dispatch_dashboard_overlay_stop`]). Called from every overlay /// navigation that can happen WITHOUT a key press (mouse clicks on /// `[Dashboard]` / `[‹]` / `[›]`); key presses already disarm via the /// pending-action fast path in `AppView::handle_input`. fn clear_pending_overlay_stop(app: &mut AppView) { if app .pending_action .as_ref() .is_some_and(|p| matches!(p.action, Action::DashboardOverlayStop)) { app.pending_action = None; } } /// Confirmed stop from inside the dashboard's session-overlay — the /// second Ctrl+X press within the confirm window. Canonical state /// machine for overlay Ctrl+X (the intercept in /// `app_view::handle_input` and the `DashboardOverlayStop` def both /// point here): /// /// - First press, turn RUNNING → `Action::CancelTurn` (the agent /// view's Ctrl+C behaviour: keep-subagents prompt, prompt rewind). /// Never arms, so mashing Ctrl+X to stop a turn can't close the /// session. /// - First press, any other state (idle, command in flight, cancel /// pending) → arms `AppView::pending_action` with the dashboard's /// 2s `STOP_CONFIRM_WINDOW`; the shortcuts bar paints "press Ctrl+x /// again to close this session". Cancel can't help in the non-idle /// variants of this arm — `dispatch_cancel_turn` no-ops unless a /// turn is running, and command cancellation isn't implemented (see /// `handle_agent_action`'s `CancelTurn` TODO) — so the two-press /// close is the only termination the user can reach, matching the /// dashboard list's Ctrl+X which arms even while busy. /// - Second press inside the window lands here via the pending-action /// fast path; any other key disarms via that same path. A turn that /// STARTED inside the window (queued prompt drained, user sent one) /// downgrades the confirmed press to a cancel instead of closing /// work in flight. /// /// Mirrors `dispatch_dashboard_stop`'s second press, except the user /// is INSIDE the session being closed: the view returns to the /// dashboard instead of falling back to another agent. pub(super) fn dispatch_dashboard_overlay_stop(app: &mut AppView) -> Vec { let Some(id) = app.dashboard.as_ref().and_then(|d| d.attached_agent) else { return vec![]; }; if app .agents .get(&id) .is_some_and(|a| a.session.state.is_turn_running()) { return dispatch_cancel_turn(app); } // Land on the dashboard BEFORE closing: `dispatch_sessions_confirm_close` // only re-targets the active view when the closed agent IS the // active view, so switching first keeps it from picking a fallback // agent. This also means a refused close (e.g. "Cannot close the // only session") still returns to the dashboard — with the refusal // toast surfaced there via `AppView::show_toast`. // Pick the cursor's next home on the dashboard BEFORE the row // vanishes, so returning to the list lands the selection on the // neighbouring agent (down 1) instead of a stale cursor that // bounces the next ↑/↓ back to the top. let neighbor = dashboard_neighbor_row(app, &crate::views::dashboard::DashboardRowId::TopLevel(id)); if let Some(d) = app.dashboard.as_mut() { d.close_popup(); } app.active_view = ActiveView::AgentDashboard; let effects = dispatch_sessions_confirm_close(app, id); if !app.agents.contains_key(&id) && let Some(d) = app.dashboard.as_mut() { // Move the cursor onto the neighbouring row, or the always-present // `[+ New Agent]` button when none is left — both go through the // focus helpers so the "exactly one cursor active" invariant holds // (a bare `selected = None` would leave no cursor and drop the footer // into its defensive fallback). match neighbor { Some(n) => d.focus_row(n), None => d.focus_new_agent_button(), } // Success feedback — the close path itself is silent. Mirrors the // "Session closed" toast the render path shows when an attached // agent disappears externally (`app_view.rs`, AgentDashboard arm). // Don't clobber a refusal toast planted by the close path above. if d.error_toast.is_none() { d.error_toast = Some(format!("{} Session closed", crate::glyphs::check_mark())); } } effects } /// Toggle worktree-dispatch mode for the dashboard (bound to Ctrl+W). /// /// When armed, the next agent dispatched from the dashboard spawns in a fresh /// git worktree and the `[+ New Agent]` button reads `[+ New Worktree]`. /// Worktrees require a git repo, so outside one the toggle is a no-op and we /// surface an explanatory toast instead of silently doing nothing (and the /// dashboard is never left in worktree mode in a non-git directory). pub(super) fn dispatch_dashboard_toggle_worktree(app: &mut AppView) -> Vec { let has_git = app.cwd_has_git_ancestor; if let Some(d) = app.dashboard.as_mut() { if has_git { d.dispatch_worktree = !d.dispatch_worktree; } else { d.dispatch_worktree = false; d.set_error_toast("Not a git repository — worktrees need one"); } } vec![] } /// Toggle auto-approve (YOLO mode) on the selected dashboard /// row's owning agent. Subagents inherit their parent's mode, so /// a subagent selection routes to the parent. /// /// Reuses `set_yolo_mode` (which reads `active_view` to target /// the agent) by temporarily switching the active view to the /// selected agent for the duration of the call — keeps all the /// existing drain / persist / toast logic in a single code path /// instead of duplicating it. pub(super) fn dispatch_dashboard_toggle_auto_approve(app: &mut AppView) -> Vec { use crate::views::dashboard::DashboardRowId; let Some(d) = app.dashboard.as_ref() else { return vec![]; }; let Some(selected) = d.selected.as_ref() else { if let Some(d) = app.dashboard.as_mut() { d.set_error_toast("Select a session first"); } return vec![]; }; let agent_id = match selected { DashboardRowId::TopLevel(id) => *id, DashboardRowId::Subagent { parent, .. } => *parent, DashboardRowId::Roster { .. } => return vec![], }; if !app.agents.contains_key(&agent_id) { if let Some(d) = app.dashboard.as_mut() { d.set_error_toast("Session no longer exists"); } return vec![]; } let agent = app.agents.get(&agent_id).expect("checked above"); let new = !agent.session.yolo_mode; // Managed policy pins always-approve off — toast on the dashboard's own // error slot (the inner gate's agent toast is invisible from here). if let Some(warning) = yolo_enable_blocked(app, new) { if let Some(d) = app.dashboard.as_mut() { d.set_error_toast(warning); } return vec![]; } // Temporarily borrow active_view so `set_yolo_mode` targets // the dashboard's selected agent rather than whichever view // is currently active. Restored before returning. let saved_view = app.active_view; app.active_view = ActiveView::Agent(agent_id); let effects = set_yolo_mode(app, new); app.active_view = saved_view; effects } fn snapshot_prompt_widget( prompt: &mut crate::views::prompt_widget::PromptWidget, text: String, ) -> crate::views::prompt_widget::StashedPrompt { if prompt.text() == text || !prompt.images.is_empty() || !prompt.textarea().elements().is_empty() { prompt.stash().with_transformed_text(text) } else { crate::views::prompt_widget::StashedPrompt::from_submission(text, Vec::new(), Vec::new()) } } /// Open the worktree-label dialog and stash the dispatch prompt until confirm. fn open_dashboard_worktree_dialog( app: &mut AppView, prompt: Option, attach: bool, ) -> Vec { if let Some(d) = app.dashboard.as_mut() { d.pending_worktree_prompt = prompt.map(|text| snapshot_prompt_widget(&mut d.dispatch, text)); d.pending_worktree_attach = attach; d.worktree_dialog = Some(crate::app::app_view::NewWorktreeDialogState::new()); d.dispatch.set_text(""); d.error_toast = None; } vec![] } /// Create a new session AND switch into its detail view. Routed from the /// `[+ New Agent]` button and Enter-on-empty-prompt while it's focused. /// Mirrors `dispatch_dashboard_dispatch`'s new-session arm with `attach=true`, /// minus the prompt enqueue. pub(super) fn dispatch_dashboard_create_new_agent_with_detail(app: &mut AppView) -> Vec { // Worktree mode armed + git repo: open the label dialog (which spawns the // agent in a fresh worktree on confirm) instead of a plain session. The // button opens the detail view, so confirm attaches (`attach = true`). if app.cwd_has_git_ancestor && app.dashboard.as_ref().is_some_and(|d| d.dispatch_worktree) { return open_dashboard_worktree_dialog(app, None, /* attach */ true); } let pending_model = app.dashboard.as_ref().and_then(|d| d.pending_model.clone()); let pending_mode = app .dashboard .as_ref() .map(|d| d.pending_mode) .unwrap_or_default(); let model_id = pending_model.as_ref().map(|m| m.id.clone()); let (new_id, effects) = dispatch_new_session_inner_with_id(app, model_id); let policy_block = app.yolo_policy_block; if let Some(agent) = app.agents.get_mut(&new_id) { apply_pending_dispatch_config(agent, pending_model.as_ref(), pending_mode, policy_block); } if let Some(d) = app.dashboard.as_mut() { // Clear the dispatch input even though we don't enqueue // anything — a stray paste while the button is focused // (no typed Enter) shouldn't survive the view switch. d.dispatch.set_text(""); d.error_toast = None; d.filter = crate::views::dashboard::Filter::None; // Snap the cursor onto the new row so the overlay's `i/n` // indicator matches the active view, AND so the chrome's // `[‹]` / `[›]` cycle anchors on the right starting point. d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id)); d.attached_agent = Some(new_id); } app.active_view = ActiveView::Agent(new_id); surface_yolo_launch_block_notice(app, new_id); effects } /// Open the dashboard's shortcuts cheatsheet modal. /// /// Builds the entry list from the registry, scoped to the `DashboardFocused` /// + `Always` contexts. Mirrors `ActionId::ShortcutsHelp`'s agent-view handler. pub(super) fn dispatch_dashboard_open_shortcuts_help(app: &mut AppView) { let Some(d) = app.dashboard.as_mut() else { return; }; if d.shortcuts_modal.is_some() { // Idempotent — re-pressing Ctrl+. while the modal is // already open is a no-op (rather than blowing away the // user's search query / scroll position with a fresh // build). return; } use crate::actions::When; let contexts = [When::DashboardFocused, When::Always]; let entries = crate::views::shortcuts_help::build_entries( &contexts, &app.registry, /* vim_mode */ false, ); let state = crate::views::shortcuts_help::build_initial_picker_state(&entries); d.shortcuts_modal = Some(Box::new(crate::views::dashboard::ShortcutsModalState { entries, state, window: Default::default(), filter_active: false, collapsed_sections: crate::views::shortcuts_help::default_collapsed(), expanded_ids: std::collections::HashSet::new(), mode: crate::views::shortcuts_help::ShortcutsHelpMode::Browse, })); } /// Short display label for a directory in the location picker — the /// basename (truncated), or `~` for the home directory itself. fn location_picker_label(path: &std::path::Path) -> String { if dirs::home_dir().is_some_and(|h| h == path) { return "~".to_string(); } let raw = path.file_name().and_then(|n| n.to_str()).unwrap_or("/"); crate::render::line_utils::truncate_str(raw, 30) } /// Resolve a raw location-picker / `/cd` path string to an absolute path, /// expanding a leading `~` and joining relative paths against `cwd`. /// Returns `None` for empty input or when `~` can't be expanded. The /// caller is responsible for validating the result is a directory. pub(super) fn resolve_location_input( input: &str, cwd: &std::path::Path, ) -> Option { let trimmed = input.trim(); if trimmed.is_empty() { return None; } let expanded: std::path::PathBuf = if trimmed == "~" { dirs::home_dir()? } else if let Some(rest) = trimmed.strip_prefix("~/") { dirs::home_dir()?.join(rest) } else { std::path::PathBuf::from(trimmed) }; if expanded.is_absolute() { Some(expanded) } else { Some(cwd.join(expanded)) } } /// Open the dashboard's location picker. Seeds the candidate list with /// the current cwd (marked `(current)`) followed by recent project /// directories from session history. Idempotent — a no-op if the picker /// is already open or the dashboard isn't active. pub(super) fn dispatch_dashboard_open_location_picker(app: &mut AppView) -> Vec { use crate::views::dashboard::{LocationCandidate, LocationPickerState}; if !matches!(app.active_view, ActiveView::AgentDashboard) { // `/cd` reached from a non-dashboard surface — the location // picker is a dashboard affordance, so guide the user there. // Gate on the dashboard being the *foreground* view (not merely // `app.dashboard.is_some()`, which stays true for the rest of the // session once the dashboard has been opened even once). app.show_toast("Open the dashboard (/dashboard) to change location"); return vec![]; } // Idempotent — re-triggering while open keeps the current query. if app .dashboard .as_ref() .is_some_and(|d| d.location_picker.is_some()) { return vec![]; } let cwd = app.cwd.clone(); // Same pattern as `open_project_question` — the recent-dirs source is // async; block the current runtime thread briefly to collect it. let recent = tokio::task::block_in_place(|| { tokio::runtime::Handle::current() .block_on(crate::project_picker::sources::collect_recent_dirs(10)) }); // Worktree label index (root path → label), built once and reused to // tag both recents and live directory suggestions. let worktrees = crate::git_info::worktree_label_index(); let worktree_label = |path: &std::path::Path| -> Option { let key = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); worktrees.get(&key).cloned() }; let mut candidates: Vec = Vec::new(); candidates.push(LocationCandidate { label: location_picker_label(&cwd), detail: format!( "{} (current)", crate::project_picker::sources::display_path(&cwd) ), worktree: worktree_label(&cwd), path: cwd.clone(), }); for (path, ts) in recent.into_iter().filter(|(p, _)| p != &cwd) { let detail = format!( "{} ({})", crate::project_picker::sources::display_path(&path), crate::views::session_title::format_relative_time( (chrono::Utc::now() - ts).to_std().unwrap_or_default() ), ); candidates.push(LocationCandidate { label: location_picker_label(&path), detail, worktree: worktree_label(&path), path, }); } if let Some(d) = app.dashboard.as_mut() { let mut lp = LocationPickerState::new(candidates, cwd, worktrees); // Reflect the dashboard's current worktree arming so reopening the // picker shows the toggle in its existing state. lp.worktree_mode = d.dispatch_worktree; d.location_picker = Some(lp); } crate::unified_log::info("dashboard.location_picker.opened", None, None); vec![] } /// Apply a location-picker / `/cd` selection. Resolves + validates the /// path, and on success updates `app.cwd` + the process cwd (so newly /// dispatched dashboard sessions spawn there) and closes the modal. On /// failure the modal stays open with an inline error and the cwd is /// unchanged. pub(super) fn dispatch_dashboard_change_location(app: &mut AppView, input: String) -> Vec { // Gate on the dashboard being the *foreground* view. `/cd ` typed // from another surface (welcome / an agent session / the dashboard // overlay) would otherwise silently change the process cwd, since // `app.dashboard` stays `Some` for the rest of the session once opened. if !matches!(app.active_view, ActiveView::AgentDashboard) { app.show_toast("Open the dashboard (/dashboard) to change location"); return vec![]; } let path = match resolve_location_input(&input, &app.cwd).filter(|p| p.is_dir()) { Some(p) => p, None => { if let Some(lp) = app .dashboard .as_mut() .and_then(|d| d.location_picker.as_mut()) { lp.error = Some(format!("Not a directory: {}", input.trim())); } else if let Some(d) = app.dashboard.as_mut() { // `/cd ` typed into the dispatch box (no picker // open) — surface the error as a dashboard toast. d.set_error_toast(&format!("Not a directory: {}", input.trim())); } return vec![]; } }; crate::unified_log::info( "dashboard.location_picker.changed", None, Some(serde_json::json!({ "path": path.display().to_string() })), ); let changed = app.cwd != path; let display = crate::project_picker::sources::display_path(&path); app.cwd = path.clone(); // Keep the git-repo flag in sync with the new cwd (it's otherwise only // computed at startup). Worktree dispatch reads it. app.cwd_has_git_ancestor = path.ancestors().any(|p| p.join(".git").exists()); // Warm the per-cwd git cache the header / top bar read (keyed on the // new cwd) so the new branch + worktree label show on the next frame // instead of waiting for the first lazy refresh (no git spawn on the // render path). crate::git_info::populate_from_cwd_async(path.clone()); let has_git = app.cwd_has_git_ancestor; if let Some(d) = app.dashboard.as_mut() { // Keep the dashboard's cwd + git-repo snapshot in sync with the new // cwd so the header tracks it immediately (the process cwd changes // later, via `Effect::SetWorkingDir`). d.cwd = path.clone(); d.cwd_has_git_ancestor = has_git; // Carry the picker's worktree toggle onto the dashboard so the next // dispatched agent honors it after the modal closes — but only when // the destination is a git repo. Worktrees require one, so navigating // to a non-git directory forces worktree mode off (the dashboard is // never in worktree mode outside a repo). if let Some(wt) = d.location_picker.as_ref().map(|lp| lp.worktree_mode) { d.dispatch_worktree = wt && has_git; } else if !has_git { d.dispatch_worktree = false; } d.location_picker = None; if changed { // Re-root the dispatch box's `@` file-context picker at the new // cwd so completions walk the same tree that newly dispatched // sessions run in (both keyed off `app.cwd`). Cheap: the walk // only spawns once the user actually types `@`. d.dispatch.file_search.retarget(&path); d.error_toast = Some(format!("\u{2192} {display}")); } } // Change the process cwd via an effect, not inline, so the reducer // stays side-effect free and parallel tests don't leak the process // cwd into each other. vec![Effect::SetWorkingDir { path }] } /// Confirm the dashboard worktree-label dialog: create the next dashboard /// agent in a fresh worktree at `app.cwd`, replaying any prompt stashed when /// the dialog opened. The dialog itself was already cleared by the input /// handler. Surfaces a dashboard toast (instead of creating) when the cwd /// isn't a git repository. pub(super) fn dispatch_dashboard_confirm_worktree( app: &mut AppView, label: Option, ) -> Vec { // Apply the prompt, attach choice, and staged model/mode together. // The worktree path must honor all of these exactly like the normal // dispatch path (`dispatch_dashboard_dispatch`). let (mut prompt, attach) = match app.dashboard.as_mut() { Some(d) => ( d.pending_worktree_prompt.take(), std::mem::replace(&mut d.pending_worktree_attach, false), ), None => (None, false), }; let pending_model = app.dashboard.as_ref().and_then(|d| d.pending_model.clone()); let pending_mode = app .dashboard .as_ref() .map(|d| d.pending_mode) .unwrap_or_default(); if !app.cwd_has_git_ancestor { if let Some(d) = app.dashboard.as_mut() { // Restore the typed prompt if the cwd stopped being a repo. if let Some(p) = prompt { d.dispatch.restore(p); } d.set_error_toast("Not a git repository — can't create a worktree here"); } return vec![]; } let (prompt_text, mut images, chip_elements) = if let Some(stashed) = prompt.take() { let (text, images, chip_elements) = stashed.into_submission(); (Some(text), images, chip_elements) } else { (None, Vec::new(), Vec::new()) }; let model_id = pending_model.as_ref().map(|m| m.id.clone()); let effects = dispatch_new_worktree_session(app, None, label, prompt_text, model_id, None, None); if let Some(new_id) = effects.iter().find_map(|e| match e { Effect::CreateWorktreeSession { agent_id, .. } => Some(*agent_id), _ => None, }) { // Apply the staged effort / plan / always-approve mode to the new // worktree agent (base model is seeded via the effect's `model_id`), // then carry any pasted images onto the replayed prompt — both mirror // `dispatch_dashboard_dispatch`. let policy_block = app.yolo_policy_block; if let Some(agent) = app.agents.get_mut(&new_id) { apply_pending_dispatch_config( agent, pending_model.as_ref(), pending_mode, policy_block, ); if let Some(entry) = agent.session.pending_prompts.back_mut() { entry.images = std::mem::take(&mut images); entry.chip_elements = chip_elements; } } if attach { // Send+open: attach the dashboard's detail-view overlay onto the // agent (`dispatch_new_worktree_session` already set `active_view`). if let Some(d) = app.dashboard.as_mut() { d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id)); d.attached_agent = Some(new_id); } } else { // Plain Enter: undo the view switch `dispatch_new_worktree_session` // made and stay on the dashboard. app.active_view = ActiveView::AgentDashboard; } } crate::prompt_images::drain_and_cleanup(&mut images); effects } /// Cycle the dashboard overlay to the prev (-1) / next (+1) agent in the /// visible row order, wrapping at the ends. Attaches overlay chrome on the /// first cycle from a session not opened via the dashboard. pub(super) fn dispatch_dashboard_overlay_cycle(app: &mut AppView, delta: i32) -> Vec { use crate::views::dashboard::DashboardRowId; // Anchor on the agent on screen, not a possibly-stale `attached_agent`: // keeps cycling correct after a session switch the view without // re-attaching. (Cycling is only ever invoked from an agent view.) let ActiveView::Agent(current) = app.active_view else { return vec![]; }; if app.agents.len() <= 1 { return vec![]; } // Compute the order without permanently materializing the dashboard, so a // no-op cycle (filter hides all / current row hidden) leaves no state. When // the dashboard was never opened, build a throwaway state from the persisted // layout (pins / reorder / grouping) so prev/next match what the user sees // after opening — `load_persisted` is cached on `app.dashboard_persisted`. let order = match app.dashboard.as_ref() { Some(d) => crate::views::dashboard::overlay_cycle_order(d, &app.agents), None => { // Materializing here must honor the same gates as // `dispatch_open_dashboard` (feature flag AND auth), so cycling // can't surface an ungated dashboard on back-out. if !crate::views::dashboard::dashboard_enabled() || !matches!(app.auth_state, crate::app::app_view::AuthState::Done) { return vec![]; } let transient = dashboard_state_from_persisted(app); crate::views::dashboard::overlay_cycle_order(&transient, &app.agents) } }; if order.len() <= 1 { return vec![]; } let Some(idx) = order.iter().position(|id| *id == current) else { return vec![]; }; let n = order.len() as i32; let next_idx = (((idx as i32) + delta).rem_euclid(n)) as usize; let next_id = order[next_idx]; if next_id == current { return vec![]; } // Materialize + configure only on a real switch — otherwise a // cycle-created dashboard renders bare on back-out (default cwd, empty // `/model`, wrong auto-approve). if app.dashboard.is_none() { ensure_dashboard_state(app); configure_dashboard_state(app); } // Drop any prior subagent takeover on the next agent so the // overlay paints the parent view, mirroring the attach path. if let Some(agent) = app.agents.get_mut(&next_id) { agent.active_subagent = None; } // A stop-confirm armed on the CURRENT agent must not carry over to // the next one — a mouse click on `[‹]` / `[›]` lands here without // the key-press disarm ever running. clear_pending_overlay_stop(app); if let Some(d) = app.dashboard.as_mut() { d.attached_agent = Some(next_id); d.focus_row(DashboardRowId::TopLevel(next_id)); } app.active_view = ActiveView::Agent(next_id); surface_yolo_launch_block_notice(app, next_id); vec![] } pub(super) fn dispatch_dashboard_dispatch( app: &mut AppView, text: String, attach: bool, ) -> Vec { // Paste-then-immediate-send: a Cmd+V image probe is still off-thread. Stash // this send and re-issue it once the probe completes so the image is never // dropped from the dispatched prompt's content blocks. if let Some(d) = app.dashboard.as_mut() && d.paste_probe_in_flight > 0 { d.deferred_dispatch_send = Some(crate::views::dashboard::state::DeferredDispatchSend { attach }); return vec![]; } let trimmed = text.trim().to_string(); // Reject only an empty / whitespace-only prompt; any non-empty // input (even a single character) dispatches a new session. The // keyboard path already filters empty input upstream (see // `DashboardState::handle_key`), so this guard mainly protects the // slash-fallback callers. if trimmed.is_empty() { if let Some(d) = app.dashboard.as_mut() { d.set_error_toast("Type a prompt to dispatch a session"); } return vec![]; } // Cap dispatch text at 64 KiB so a runaway paste // doesn't queue a multi-megabyte prompt. // // Toast shows BOTH the character count (what the // user thinks in) AND the byte budget (the actual limit) so the // char/byte unit mismatch isn't surprising. const MAX_DISPATCH_BYTES: usize = 64 * 1024; if text.len() > MAX_DISPATCH_BYTES { let chars = text.chars().count(); if let Some(d) = app.dashboard.as_mut() { d.set_error_toast(&format!( "Prompt too long ({chars} chars / {} bytes; max ~64 KiB)", text.len() )); } return vec![]; } // Worktree mode armed (via the location picker) AND the cwd is a git // repo: stash the prompt and open the worktree-label dialog. Confirming // it spawns the agent in a fresh worktree and replays this prompt (see // `Action::DashboardConfirmWorktree`). The `attach` flag rides along so // confirm honors the same detail-vs-dashboard choice as a normal dispatch // (bare Enter stays on the dashboard; Ctrl+S opens the detail view). // Outside a repo we fall through to a normal session dispatch (the toggle // is hidden there anyway). if app.cwd_has_git_ancestor && app.dashboard.as_ref().is_some_and(|d| d.dispatch_worktree) { return open_dashboard_worktree_dialog(app, Some(text), attach); } // The dashboard's dispatch input ALWAYS spawns a new session — it is // never a reply target. A row being selected is purely the overview // navigation cursor (Enter on it OPENS the agent); it must not turn // the input into "reply to that agent". Conflating the two trapped // the user: navigating to a row (vim j/k) flipped the input to // "Reply to " and there was no obvious way back to spawning a // new session. To converse with an existing agent, open it (navigate // + Enter, or click) and reply inside its own view. // // New-session path. // // Return the new AgentId from the inner constructor // so we don't have to rely on `app.agents.last()`. // // Carry the dashboard's staged model / plan-mode (set via `/model` and // `/plan`) onto the new session: the model id seeds `CreateSession`, and // effort / plan are applied post-creation by `apply_pending_dispatch_config`. let pending_model = app.dashboard.as_ref().and_then(|d| d.pending_model.clone()); let pending_mode = app .dashboard .as_ref() .map(|d| d.pending_mode) .unwrap_or_default(); let model_id = pending_model.as_ref().map(|m| m.id.clone()); let prompt_state = app .dashboard .as_mut() .map(|dashboard| snapshot_prompt_widget(&mut dashboard.dispatch, text.clone())) .unwrap_or_else(|| { crate::views::prompt_widget::StashedPrompt::from_submission( text, Vec::new(), Vec::new(), ) }); let (prompt_text, mut pasted_images, chip_elements) = prompt_state.into_submission(); let saved_shown = app.project_picker_shown; app.project_picker_shown = true; let (new_id, effects) = dispatch_new_session_inner_with_id(app, model_id); app.project_picker_shown = saved_shown; let policy_block = app.yolo_policy_block; if let Some(agent) = app.agents.get_mut(&new_id) { agent.session.enqueue_prompt(prompt_text); if let Some(entry) = agent.session.pending_prompts.back_mut() { entry.images = std::mem::take(&mut pasted_images); entry.chip_elements = chip_elements; } apply_pending_dispatch_config(agent, pending_model.as_ref(), pending_mode, policy_block); } crate::prompt_images::drain_and_cleanup(&mut pasted_images); // Only clear the input AFTER successful session // creation. If the agent failed to register the prompt above we // would have already returned without effects. if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); d.error_toast = None; d.filter = crate::views::dashboard::Filter::None; } if attach { // Ctrl+S (Send+Open) — walk into the new agent's // detail view AND paint the session-overlay chrome. // Mirrors `dispatch_dashboard_attach` and // `dispatch_dashboard_create_new_agent_with_detail`: // both `attached_agent` and `selected` follow the new // row so the overlay's `i/n [‹][›] [✗]` chips have an // anchor and Esc walks back to the dashboard. if let Some(d) = app.dashboard.as_mut() { d.focus_row(crate::views::dashboard::DashboardRowId::TopLevel(new_id)); d.attached_agent = Some(new_id); } app.active_view = ActiveView::Agent(new_id); surface_yolo_launch_block_notice(app, new_id); } else { // Plain Enter (Send) stays on the dashboard, no auto-select. // The freshly-created row is left unselected so the overview // navigation cursor stays where the user had it — the input // always dispatches a NEW session regardless of selection, so // there's no reply state to worry about here. app.active_view = ActiveView::AgentDashboard; // `apply_pending_dispatch_config` clamps a pinned always-approve to // Normal and toasts the new agent, but that toast is invisible while the // view stays on the dashboard — mirror it on the dashboard's error slot. let enabling = pending_mode == crate::views::dashboard::DashboardDispatchMode::AlwaysApprove; if let Some(warning) = yolo_enable_blocked(app, enabling) && let Some(d) = app.dashboard.as_mut() { d.set_error_toast(warning); } } effects } /// Resolve a slash command typed into the dashboard's dispatch input. /// /// The dashboard has no session context, so the execution path is more /// limited than the agent view's: /// /// - Builtin commands that return `CommandResult::Action(...)` (e.g. /// `/dashboard`, `/exit`, `/theme`, `/settings`, `/help`, `/model`, /// `/mcps`, `/plugin`, …) are dispatched identically to the agent path. /// - `CommandResult::Message` / `Error` surface as an `error_toast` /// on the dashboard (no scrollback to push into). `Error` strings /// get the `✗` prefix via `set_error_toast`; `Message` strings are /// stored verbatim (they carry their own glyph). /// - `CommandResult::Handled` / `HandledNoOp` clear the input. /// - `CommandResult::PassThrough` (unknown commands, ACP-advertised /// pass-throughs, `CommandResult::QueueCommand`, and /// `InjectSkill`) fall back to dispatching a new session with the /// text as its first prompt — the dashboard treats them like a /// bare free-text dispatch. This keeps the user's typing from /// being silently dropped on the floor when they invoke a /// plugin/skill from the dashboard surface. /// /// Offer / execute tri-state (matches completion's [`command_offered`]): /// - **Unknown** token → [`dispatch_dashboard_dispatch`] (new session prompt). /// - **Registered, not offered** (session-scoped hidden on this surface, /// or `dashboard_only` off-dashboard) → clear dispatch + error toast; /// do **not** spawn with the slash text as the prompt. /// - **Registered, offered** → MRU + `command.run` (e.g. `/model` / /// `/plan` stage the next spawn). pub(super) fn dispatch_dashboard_dispatch_slash(app: &mut AppView, text: String) -> Vec { use crate::slash::command::{CommandExecCtx, CommandResult}; use crate::slash::parse_invocation; let trimmed = text.trim().to_string(); if trimmed.is_empty() || !trimmed.starts_with('/') { return vec![]; } let show_tips_from_app = app.show_tips; let auto_update_from_app = app.auto_update; let respect_manual_folds_from_app = app.appearance.scrollback.scroll.respect_manual_folds; let auto_mode_gate_from_app = app.auto_mode_gate; let ask_user_question_timeout_enabled_from_app = app.ask_user_question_timeout_enabled; // Build the execution context from app-wide state. The dashboard // is session-less, so `session_id` is `None`. Offered session-less // opt-ins (`/model`, `/plan`) and pager-global commands still run // and may toast if a dispatcher needs an agent. let result = { let Some(invocation) = parse_invocation(trimmed.as_str()) else { return vec![]; }; // Get the slash registry from the dashboard's prompt widget. // The dashboard owns its own registry (populated at open time // with builtins + ACP commands from `bootstrap_acp_commands`). let Some(dashboard) = app.dashboard.as_ref() else { return vec![]; }; let reg = dashboard.dispatch.slash_controller.registry(); let Some(command) = reg.get(invocation.token).cloned() else { // Unknown command. Fall back to the regular dispatch // path so the text becomes a new session's prompt. return dispatch_dashboard_dispatch(app, text, /* attach */ false); }; // Registered but not offered on this surface (session-scoped // hidden from the dropdown, or non-dashboard `dashboard_only`): // error toast — never spawn a session whose first prompt is the // slash text (that was worse than the old loud Action toasts). if !dashboard .dispatch .slash_controller .is_command_offered(command.as_ref(), &app.models) { let name = command.name(); if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); d.set_error_toast(&format!("/{name} only works in a session")); } return vec![]; } if let Some(dashboard) = app.dashboard.as_mut() { // Records MRU and queues an off-thread persist internally. dashboard .dispatch .slash_controller .record_command_use(invocation.token, invocation.token); } let dashboard_multiline = app.dashboard.as_ref().is_some_and(|d| d.multiline_mode); let mut ctx = CommandExecCtx { models: &app.models, session_id: None, bundle_state: &app.bundle_state, screen_mode: app.screen_mode, pager_state: crate::settings::PagerLocalSnapshot { multiline_mode: dashboard_multiline, yolo_mode: app.default_yolo, auto_mode: app.current_ui.permission_mode.as_deref() == Some("auto") && !app.default_yolo, current_model_name: app.models.current_model_name(), available_models: app .models .available .iter() .map(|(id, info)| (info.name.clone(), id.clone())) .collect(), plan_mode_active: false, show_tips: show_tips_from_app, auto_update: auto_update_from_app, vim_mode: crate::appearance::cache::load_vim_mode(), scroll_speed: crate::appearance::cache::load_scroll_speed(), respect_manual_folds: respect_manual_folds_from_app, auto_mode_gate: auto_mode_gate_from_app, ask_user_question_timeout_enabled: ask_user_question_timeout_enabled_from_app, }, }; command.run(&mut ctx, invocation.args) }; match result { CommandResult::Handled | CommandResult::HandledNoOp => { if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); d.error_toast = None; } vec![] } CommandResult::Error(msg) => { if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); // Command errors are plain strings ("Unknown model: …", // "Usage: /model [effort]") with no glyph of their // own — route through `set_error_toast` so the verbatim // badge shows the `✗` error marker. `Message` results // below stay verbatim: they carry their own glyph // (e.g. `✓ Theme: …`). d.set_error_toast(&msg); } vec![] } CommandResult::Message(msg) => { if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); d.error_toast = Some(msg); } vec![] } CommandResult::Action(Action::ExitSession) => { // ExitSession from a session-less surface is meaningless // — collapse it to `/dashboard`'s exit semantics. if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); } dispatch(Action::ExitDashboard, app) } // `/model` on the session-less dashboard stages the model for the // NEXT spawned agent instead of switching a (nonexistent) session. // Both the effort-bearing (`SwitchModel`) and bare // (`SetDefaultModel`) forms map to the same per-spawn staging — we // deliberately do NOT persist a global default here. CommandResult::Action(Action::SwitchModel { model_id, effort }) => { stage_dashboard_model(app, model_id, effort); vec![] } CommandResult::Action(Action::SetDefaultModel(model_id)) => { stage_dashboard_model(app, model_id, None); vec![] } // `/plan` toggles whether the next spawned agent starts in plan // mode. The command always reports `On` here (the dashboard's // `plan_mode_active` snapshot is always false), so we flip between // Plan and Normal to give a session-less way to turn it back off. CommandResult::Action(Action::SetPlanMode(_)) => { use crate::views::dashboard::DashboardDispatchMode; if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); d.error_toast = None; d.pending_mode = if d.pending_mode == DashboardDispatchMode::Plan { DashboardDispatchMode::Normal } else { DashboardDispatchMode::Plan }; } vec![] } // `/plan ` — stage plan mode AND spawn immediately with // the description as the first prompt. CommandResult::Action(Action::EnterPlanMode { description }) => { if let Some(d) = app.dashboard.as_mut() { d.pending_mode = crate::views::dashboard::DashboardDispatchMode::Plan; } match description { Some(desc) => { dispatch_dashboard_dispatch(app, desc, /* attach */ false) } None => { if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); d.error_toast = None; } vec![] } } } // `/plan` with an existing plan has nothing to show on the // session-less dashboard. CommandResult::Action(Action::ShowPlan) => { if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); d.set_error_toast("No plan to show on the dashboard"); } vec![] } CommandResult::Action(action) => { if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); d.error_toast = None; } dispatch(action, app) } CommandResult::QueueCommand(_) | CommandResult::InjectSkill { .. } | CommandResult::PassThrough(_) => { // These results all expect an agent session to consume // them. The dashboard has none, so route the original // text through the normal "new session with this text as // the first prompt" path so the user's intent isn't lost. dispatch_dashboard_dispatch(app, text, /* attach */ false) } } } /// Stage a model (+ optional reasoning effort) for the next agent the /// dashboard spawns. Resolves the human-readable display name from the /// app's model catalog (falling back to the raw id) so the renderer can /// show the indicator without a live `ModelState`. Clears the dispatch /// input + any error toast. fn stage_dashboard_model( app: &mut AppView, model_id: acp::ModelId, effort: Option, ) { let display = app .models .available .get(&model_id) .map(|info| info.name.clone()) .unwrap_or_else(|| model_id.0.to_string()); if let Some(d) = app.dashboard.as_mut() { d.dispatch.set_text(""); d.error_toast = None; // Mirror the staged choice into the dashboard's catalog snapshot so a // subsequent `/model` dropdown marks THIS model as `(current)` (and // its effort as `(active)`) rather than the app default the snapshot // was seeded with at open. Without this the dropdown lags one step // behind `pending_model`. d.models.set_current(model_id.clone(), effort); d.pending_model = Some(crate::views::dashboard::PendingDispatchModel { id: model_id, effort, display, }); } } /// Apply the dashboard's staged model effort + plan mode to a freshly /// spawned agent. The base model is already seeded via `CreateSession`'s /// `model_id`; here we stash the reasoning effort (pushed to the shell once /// the session exists, mirroring the agent-view flow) and the deferred plan /// `SessionMode` (consumed in the `SessionCreated` handlers). pub(super) fn apply_pending_dispatch_config( agent: &mut AgentView, pending_model: Option<&crate::views::dashboard::PendingDispatchModel>, pending_mode: crate::views::dashboard::DashboardDispatchMode, policy_block: Option<&'static str>, ) { use crate::views::dashboard::DashboardDispatchMode; if let Some(m) = pending_model { // The base model is seeded via `CreateSession.model_id`; only stash a // deferred switch when an explicit effort must be pushed. Setting it // (or clearing to `None`) also overrides any CLI `-m` default so the // dashboard's `/model` choice wins. agent.session.deferred_model_switch = m.effort.map(|e| (m.id.clone(), Some(e))); } match pending_mode { DashboardDispatchMode::Normal => { // Explicit normal overrides any `app.default_yolo` seed. agent.session.yolo_mode = false; } DashboardDispatchMode::Plan => { agent.session.yolo_mode = false; agent.deferred_session_mode = Some(kigi_tools::types::SessionMode::Plan); // Optimistic so the agent view reflects plan mode immediately when // opened via Ctrl+S, before the ACP round-trip confirms it. agent.plan_mode_pending = Some(true); } // Backstop: staging is already gated, but this write sits outside // `set_yolo_mode_inner`, so re-check the pin here. DashboardDispatchMode::AlwaysApprove => { if let Some(warning) = policy_block { agent.session.yolo_mode = false; agent.show_toast(warning); } else { // Client-side auto-approve for the spawned session (per-spawn; // not persisted as a global default). agent.session.yolo_mode = true; } } } } /// Send or queue a reply typed into the peek panel's `❯ reply` input. /// /// The reply is enqueued on the row's owning top-level agent and then /// [`maybe_drain_queue`] decides the rest: an **idle** agent sends it /// immediately (a turn starts), a **mid-turn** agent keeps it queued so /// it drains after the current turn finishes. This is the same queue / /// drain pipeline the agent view's own prompt input uses, so the two /// surfaces behave identically. /// /// Subagent rows can't be replied to (they're driven by their parent), /// so they surface a toast and leave the peek open. /// /// `attach` (Ctrl+S) additionally walks into the agent's detail /// view, mirroring the dispatch input's send+open affordance. /// Cycle the PEEKED agent's live mode (Normal → Plan → Always-Approve → /// Normal), the peek-panel counterpart to `DashboardCycleMode`. Reuses /// the shared cycle body `dispatch_cycle_mode_and_sync` by temporarily /// targeting the peeked agent (the same `active_view` swap as /// `dispatch_dashboard_toggle_auto_approve`), so the peek behaves exactly /// like Shift+Tab inside that agent's chat view. The bottom-border badge /// reflects the new mode on the next frame. Only top-level agents have a /// mode to cycle — subagents are parent-driven. pub(super) fn dispatch_dashboard_peek_cycle_mode(app: &mut AppView) -> Vec { use crate::views::dashboard::DashboardRowId; let Some(row) = app .dashboard .as_ref() .and_then(|d| d.peek.as_ref().map(|p| p.row.clone())) else { return vec![]; }; let agent_id = match row { DashboardRowId::TopLevel(id) => id, DashboardRowId::Subagent { .. } => { if let Some(d) = app.dashboard.as_mut() { d.set_error_toast("Can't change a subagent's mode"); } return vec![]; } DashboardRowId::Roster { .. } => return vec![], }; if !app.agents.contains_key(&agent_id) { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); d.set_error_toast("Session no longer exists"); } return vec![]; } // Temporarily target the peeked agent so `dispatch_cycle_mode_and_sync` // (which reads `active_view`) acts on it; restored before returning so the // dashboard stays foregrounded. Uses the telemetry-free cycle body: the // user is viewing the dashboard, not this agent's prompt, so a plan nudge // still within TTL must not attribute a (spurious) acceptance. let saved_view = app.active_view; app.active_view = ActiveView::Agent(agent_id); let effects = dispatch_cycle_mode_and_sync(app); app.active_view = saved_view; effects } pub(super) fn dispatch_dashboard_peek_reply( app: &mut AppView, row: crate::views::dashboard::DashboardRowId, text: String, attach: bool, ) -> Vec { use crate::views::dashboard::DashboardRowId; // Paste-then-immediate-send: a Cmd+V image probe is still off-thread. Stash // this reply and re-issue it once the probe completes so the image is never // dropped from the reply's content blocks. if let Some(d) = app.dashboard.as_mut() && d.paste_probe_in_flight > 0 { d.deferred_peek_send = Some(crate::views::dashboard::state::DeferredPeekSend { row, attach }); return vec![]; } // Only top-level agents accept replies. Subagents are read-only // peeks driven by their parent turn. let DashboardRowId::TopLevel(agent_id) = row else { if let Some(d) = app.dashboard.as_mut() { d.set_error_toast("Can't reply to a subagent"); } return vec![]; }; if !app.agents.contains_key(&agent_id) { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); d.set_error_toast("Session no longer exists"); } return vec![]; } let prompt_state = app .dashboard .as_mut() .map(|dashboard| snapshot_prompt_widget(&mut dashboard.peek_reply, text.clone())) .unwrap_or_else(|| { crate::views::prompt_widget::StashedPrompt::from_submission( text, Vec::new(), Vec::new(), ) }); let (text, images, chip_elements) = prompt_state.into_submission(); if text.trim().is_empty() && images.is_empty() { return vec![]; } let Some(agent) = app.agents.get_mut(&agent_id) else { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); d.set_error_toast("Session no longer exists"); } return vec![]; }; // Enqueue + drain: idle → sends now, running → stays queued. // Untrimmed so `chip_elements` byte ranges stay aligned with the stored text. agent.session.enqueue_prompt(text); if let Some(entry) = agent.session.pending_prompts.back_mut() { entry.chip_elements = chip_elements; if !images.is_empty() { entry.images = images; } } let effects = maybe_drain_queue(agent); // Clear the reply draft now that it's been accepted, and drop any // stale error toast. if let Some(d) = app.dashboard.as_mut() { d.clear_peek_reply(); d.error_toast = None; } if attach { // Ctrl+S (Send+Open) — walk into the agent's detail view // with the session-overlay chrome, mirroring // `dispatch_dashboard_dispatch`'s attach branch. if let Some(d) = app.dashboard.as_mut() { d.focus_row(DashboardRowId::TopLevel(agent_id)); d.attached_agent = Some(agent_id); } app.active_view = ActiveView::Agent(agent_id); surface_yolo_launch_block_notice(app, agent_id); } effects } pub(super) fn dispatch_dashboard_toggle_pin(app: &mut AppView) -> Vec { if let Some(d) = app.dashboard.as_mut() { let _ = d.toggle_pin_selected(); } dispatch_dashboard_persist(app) } pub(super) fn dispatch_dashboard_begin_rename(app: &mut AppView) { let Some(d) = app.dashboard.as_mut() else { return; }; let Some(sel) = d.selected.clone() else { return; }; // Only top-level rows are renameable (subagents are tool-spawned // and have no user-visible name to rename). if sel.is_subagent() { d.set_error_toast("Subagent rows can't be renamed"); return; } // The draft starts EMPTY (not prefilled with the current title): // renames are almost always full rewrites, so prefilling only costs // the user a hold-Backspace. Esc / empty-draft Enter cancel without // touching the existing name. if let Some(d) = app.dashboard.as_mut() { d.rename = Some(crate::views::dashboard::state::RenameDraft { row: sel, draft: String::new(), }); } } pub(super) fn dispatch_dashboard_commit_rename(app: &mut AppView) -> Vec { let Some(d) = app.dashboard.as_mut() else { return vec![]; }; let Some(rn) = d.rename.take() else { return vec![]; }; // Edge case 5: empty/whitespace draft cancels without committing. let trimmed = rn.draft.trim(); if trimmed.is_empty() { return vec![]; } let title: String = crate::views::session_title::sanitize_display_text(trimmed).into_owned(); if title.is_empty() { return vec![]; } let crate::views::dashboard::DashboardRowId::TopLevel(agent_id) = rn.row else { return vec![]; }; let mut effects = Vec::new(); if let Some(agent) = app.agents.get_mut(&agent_id) { if let Some(session_id) = agent.session.session_id.clone() { let cwd = agent.session.cwd.clone(); agent.display_name = Some(title.clone()); effects.push(Effect::RenameSession { agent_id, session_id, title, cwd, }); } else { agent.display_name = Some(title); } } effects } /// Pick the dashboard row the cursor should land on after `closed` is /// removed. Computed against the CURRENT (pre-removal) display order so /// it mirrors what the user sees: the next visible row below `closed` /// (so the cursor stays put while the rows below shift up — i.e. it /// "moves down 1" relative to the list) or, when `closed` is the last /// row, the previous visible row. Section headers are skipped so the /// cursor always lands on an agent row. Returns `None` when `closed` /// is the only row — the caller then clears the selection and lets /// `reanchor_selection` fall back to the `[+ New Agent]` button. /// /// Without this, closing the selected agent leaves a stale cursor that /// `reanchor_selection` drops to `None`, and the next ↑/↓ restarts from /// the top of the list — a jarring jump. fn dashboard_neighbor_row( app: &AppView, closed: &crate::views::dashboard::DashboardRowId, ) -> Option { use crate::views::dashboard::Focusable; let d = app.dashboard.as_ref()?; let home = crate::views::dashboard::render::cached_home(); let roster: &[crate::app::roster::RosterEntry] = if app.leader_mode { &app.leader_roster } else { &app.dashboard_local_sessions }; let rows = crate::views::dashboard::build_rows_with_roster( &app.agents, &d.pinned, &d.reorder, None, d.grouping, &d.filter, home, roster, ); let focusables = crate::views::dashboard::render::focusables( &rows, d.grouping, &d.filter, &d.collapsed_sections, d.idle_show_all, d.search_mode, ); let cur = focusables .iter() .position(|f| matches!(f, Focusable::Row(id) if id == closed))?; // Next row below (down 1); else the closed row was last → previous row. let next = focusables[cur + 1..].iter().find_map(|f| match f { Focusable::Row(id) => Some(id.clone()), Focusable::Section(_) | Focusable::IdleOverflow => None, }); next.or_else(|| { focusables[..cur].iter().rev().find_map(|f| match f { Focusable::Row(id) => Some(id.clone()), Focusable::Section(_) | Focusable::IdleOverflow => None, }) }) } pub(super) fn dispatch_dashboard_stop(app: &mut AppView) -> Vec { use crate::views::dashboard::DashboardRowId; use std::time::Instant; let Some(sel) = app.dashboard.as_ref().and_then(|d| d.selected.clone()) else { return vec![]; }; match &sel { DashboardRowId::TopLevel(id) => { let id = *id; let Some(agent) = app.agents.get(&id) else { return vec![]; }; let now = Instant::now(); // `t.elapsed()` is the idiomatic Instant API // for "how long since this Instant". Behaviour identical // to `now.duration_since(*t)` when `t <= now`, which is // the only case the dispatcher constructs. let already_confirming = app .dashboard .as_ref() .and_then(|d| d.stop_confirm.as_ref()) .is_some_and(|(prev, t)| { *prev == sel && t.elapsed() < crate::views::dashboard::state::STOP_CONFIRM_WINDOW }); if already_confirming { // Pick the cursor's next home BEFORE the row vanishes, so // closing moves the selection down 1 instead of letting it // go stale (which `reanchor_selection` drops to `None`, // bouncing the next ↑/↓ back to the top of the list). let neighbor = dashboard_neighbor_row(app, &sel); if let Some(d) = app.dashboard.as_mut() { d.stop_confirm = None; } // Second press: close the agent. let effects = dispatch_sessions_confirm_close(app, id); // Only move the cursor if the close actually happened // (it's refused for the last remaining session). if !app.agents.contains_key(&id) && let Some(d) = app.dashboard.as_mut() { match neighbor { Some(n) => d.focus_row(n), // No neighbour left — land on the always-present // `[+ New Agent]` button via the focus helper so the // "exactly one cursor active" invariant holds (a bare // `selected = None` would leave no cursor and drop the // footer into its defensive fallback). None => d.focus_new_agent_button(), } } return effects; } // First press: cancel turn if running, plant confirmation. let mut effects = Vec::new(); if !agent.session.state.is_idle() && let Some(sid) = agent.session.session_id.clone() { effects.push(Effect::CancelTurn { session_id: sid, cancel_subagents: true, trigger: None, // Dashboard first-press cancel — no local prompt rewind. rewind_if_pristine: false, }); } if let Some(d) = app.dashboard.as_mut() { // The footer's `ShortcutsBar::with_pending` already // paints the "press Ctrl+X again to close this // session" prompt in the bottom bar. Surfacing the // same line via `error_toast` would also bleed it // into the dispatch input placeholder — two copies // of the same hint, in two different places, with // the dispatch one stealing visual weight from the // user's typing area. The footer hint is the // canonical surface. d.stop_confirm = Some((sel, now)); } effects } DashboardRowId::Subagent { parent, child_session_id, } => { let Some(agent) = app.agents.get_mut(parent) else { return vec![]; }; let Some(info) = agent.subagent_sessions.get_mut(child_session_id) else { return vec![]; }; let subagent_id = info.subagent_id.to_string(); info.pending_kill = true; info.kill_requested_at = Some(Instant::now()); let session_id = agent.session.session_id.clone(); session_id .map(|sid| Effect::KillSubagent { session_id: sid, subagent_id, }) .into_iter() .collect() } // Roster-only rows are hosted elsewhere — this client can't stop // them. DashboardRowId::Roster { .. } => vec![], } } pub(super) fn dispatch_dashboard_toggle_grouping(app: &mut AppView) -> Vec { if let Some(d) = app.dashboard.as_mut() { d.toggle_grouping(); } dispatch_dashboard_persist(app) } pub(super) fn dispatch_dashboard_select(app: &mut AppView, next: bool) { let Some(d) = app.dashboard.as_mut() else { return; }; // We don't have rows cached here; reconstruct from agents. // Use the shared `cached_home()` instead of // re-reading the env var on every keystroke. let home = crate::views::dashboard::render::cached_home(); // Same roster source the renderer uses, so navigation matches the visible // rows: leader roster in leader mode, local idle sessions otherwise. // Disjoint field borrows (`app.dashboard` is held mutably via `d`). let roster: &[crate::app::roster::RosterEntry] = if app.leader_mode { &app.leader_roster } else { &app.dashboard_local_sessions }; let rows = crate::views::dashboard::build_rows_with_roster( &app.agents, &d.pinned, &d.reorder, None, d.grouping, &d.filter, home, roster, ); // Unified, display-order cursor targets: section headers AND visible // rows (collapsed sections contribute only their header, so their // hidden rows are skipped). Placeholders are excluded. let focusables = crate::views::dashboard::render::focusables( &rows, d.grouping, &d.filter, &d.collapsed_sections, d.idle_show_all, d.search_mode, ); let set_cursor = |d: &mut crate::views::dashboard::DashboardState, f: &crate::views::dashboard::Focusable| { match f { crate::views::dashboard::Focusable::Section(key) => d.focus_section(*key), crate::views::dashboard::Focusable::Row(id) => d.focus_row(id.clone()), crate::views::dashboard::Focusable::IdleOverflow => d.focus_idle_overflow(), } }; // Button-focused navigation contract: // - Down on the button → first focusable (header or row). // - Up on the button → stay on the button (no wrap). if d.new_agent_button_focused { if next && !focusables.is_empty() { set_cursor(d, &focusables[0]); d.clear_manual_scroll(); } return; } if focusables.is_empty() { // Nothing to land on AND button isn't focused → fall back to the // button so the cursor lives somewhere. d.focus_new_agent_button(); return; } // Current index from the active cursor (section header or row). let cur = focusables .iter() .position(|f| match f { crate::views::dashboard::Focusable::Section(key) => d.selected_section == Some(*key), crate::views::dashboard::Focusable::Row(id) => d.selected.as_ref() == Some(id), crate::views::dashboard::Focusable::IdleOverflow => d.selected_idle_overflow, }) .unwrap_or(0); // Up on the first focusable → focus the `[+ New Agent]` button. // The button acts as a sentinel above index 0, exactly like the // agent's tabs in the agents modal. if !next && cur == 0 { d.focus_new_agent_button(); d.clear_manual_scroll(); return; } let new = if next { (cur + 1).min(focusables.len() - 1) } else { cur.saturating_sub(1) }; set_cursor(d, &focusables[new]); // Arrow-key nav is selection-driven: re-engage the clamp's // snap-to-selection so the viewport tracks the cursor. Without // this, ↑/↓ after a wheel scroll would leave the cursor visibly // selected on a row outside the viewport. d.clear_manual_scroll(); } pub(super) fn dispatch_dashboard_reorder(app: &mut AppView, up: bool) -> Vec { let Some(d) = app.dashboard.as_mut() else { return vec![]; }; let Some(sel) = d.selected.clone() else { return vec![]; }; // Maintain a stable explicit ordering list. The renderer applies // this list as a "float to position" rule inside the row's group. let pos = d.reorder.iter().position(|r| *r == sel); if up { match pos { Some(0) => { d.reorder.remove(0); } Some(i) => { d.reorder.swap(i, i - 1); } None => { d.reorder.insert(0, sel); } } } else { match pos { Some(i) if i + 1 < d.reorder.len() => { d.reorder.swap(i, i + 1); } Some(_) => { // Already at the bottom — append to end. } None => { d.reorder.push(sel); } } } dispatch_dashboard_persist(app) } fn dispatch_dashboard_persist(app: &mut AppView) -> Vec { let Some(d) = app.dashboard.as_ref() else { return vec![]; }; // Don't hardcode `enabled = true`. Thread through // the on-disk value so a user who deliberately set `enabled = // false` doesn't get it silently overwritten on the next pin or // grouping toggle. let enabled = app .dashboard_persisted .as_ref() .map(|p| p.enabled) .unwrap_or(true); let resolver = crate::views::dashboard::SessionIdResolver::from_agents(&app.agents); let persisted = d.to_persisted(enabled, &resolver); app.dashboard_persisted = Some(persisted.clone()); vec![Effect::PersistDashboard(persisted)] } /// Answer a permission request from the /// dashboard peek panel without going through `PermissionSelect`, /// which only works when `active_view == Agent(_)`. Routes directly /// to the row's owning agent and verifies the request_id has not /// rotated since the peek snapshot was taken. pub(super) fn dispatch_dashboard_permission_select( app: &mut AppView, row: crate::views::dashboard::DashboardRowId, request_id: usize, option_id: acp::PermissionOptionId, ) -> Vec { // Determine the owning AgentId. let target_id = match &row { crate::views::dashboard::DashboardRowId::TopLevel(id) => *id, crate::views::dashboard::DashboardRowId::Subagent { parent, .. } => *parent, crate::views::dashboard::DashboardRowId::Roster { .. } => return vec![], }; let Some(agent) = app.agents.get_mut(&target_id) else { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); d.set_error_toast("Row no longer exists"); } return vec![]; }; // Stale-snapshot guard. let front_matches = agent .permission_queue .front() .is_some_and(|p| p.id == request_id); if !front_matches { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); d.set_error_toast("Permission has changed — re-open peek"); } return vec![]; } let Some(perm) = agent.permission_queue.pop_front() else { return vec![]; }; let meta = if let Some(scope) = perm .mcp_scope .as_ref() .filter(|_| option_id.0.as_ref() == "allow-always-mcp") { let selection = match scope.selected { crate::views::permission_view::McpScope::Tool => { kigi_workspace::permission::McpScopeSelection::Tool { tool_name: scope.tool_name.clone(), } } crate::views::permission_view::McpScope::Server => match &scope.server_prefix { Some(prefix) => kigi_workspace::permission::McpScopeSelection::Server { server: prefix.clone(), }, None => kigi_workspace::permission::McpScopeSelection::Tool { tool_name: scope.tool_name.clone(), }, }, }; serde_json::to_value(selection) .ok() .and_then(|v| v.as_object().cloned()) } else if let Some(ref h) = perm.bash_highlights && perm.bash_selection_count > 0 { let parts: Vec = h.highlighted_words[..perm.bash_selection_count].to_vec(); serde_json::to_value(kigi_workspace::permission::BashCommandSelectedTerms { command_parts: parts, }) .ok() .and_then(|v| v.as_object().cloned()) } else { None }; perm.request .response_tx .send(Ok(acp::RequestPermissionResponse::new( acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id)), ) .meta(meta))) .ok(); resolve_permission_queue_transition(agent); // Refresh the peek (it likely no longer has a question). if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); } vec![] } /// Reject the peeked agent's pending permission with a typed feedback /// message — the peek panel's "No, type to add feedback" path. /// /// Mirrors [`dispatch_permission_followup`](super::permissions::dispatch_permission_followup) (resolve the front request /// with the `RejectOnce` option + `followup_message` meta) but targets /// the dashboard row's agent instead of the active view, with the same /// stale-request guard as [`dispatch_dashboard_permission_select`]. pub(super) fn dispatch_dashboard_permission_followup( app: &mut AppView, row: crate::views::dashboard::DashboardRowId, request_id: usize, text: String, ) -> Vec { let target_id = match &row { crate::views::dashboard::DashboardRowId::TopLevel(id) => *id, crate::views::dashboard::DashboardRowId::Subagent { parent, .. } => *parent, crate::views::dashboard::DashboardRowId::Roster { .. } => return vec![], }; let Some(agent) = app.agents.get_mut(&target_id) else { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); d.set_error_toast("Row no longer exists"); } return vec![]; }; // Stale-snapshot guard — the front request must still match. let front_matches = agent .permission_queue .front() .is_some_and(|p| p.id == request_id); if !front_matches { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); d.set_error_toast("Permission has changed — re-open peek"); } return vec![]; } let Some(perm) = agent.permission_queue.pop_front() else { return vec![]; }; // Resolve with the RejectOnce option + feedback; cancel if the // request somehow has no reject option. let option_id = perm .options .iter() .find(|o| o.kind == acp::PermissionOptionKind::RejectOnce) .map(|o| o.option_id.clone()); let outcome = match option_id { Some(option_id) => { acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(option_id)) } None => acp::RequestPermissionOutcome::Cancelled, }; let meta = if !text.trim().is_empty() { serde_json::json!({ "followup_message": text }) .as_object() .cloned() } else { None }; perm.request .response_tx .send(Ok(acp::RequestPermissionResponse::new(outcome).meta(meta))) .ok(); resolve_permission_queue_transition(agent); if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); } vec![] } /// Answer the peeked agent's pending `AskUserQuestion` (the Ask tool) /// from the dashboard peek panel — `option_idx` selects an option, /// `None` + non-empty `freeform` submits the "Other" free-text answer. /// Delegates to [`AgentView::dashboard_answer_question`], which sends the /// ext-response; the peek closes once an answer is actually submitted. pub(super) fn dispatch_dashboard_question_answer( app: &mut AppView, row: crate::views::dashboard::DashboardRowId, option_idx: Option, freeform: String, ) -> Vec { let target_id = match &row { crate::views::dashboard::DashboardRowId::TopLevel(id) => *id, crate::views::dashboard::DashboardRowId::Subagent { parent, .. } => *parent, crate::views::dashboard::DashboardRowId::Roster { .. } => return vec![], }; let Some(agent) = app.agents.get_mut(&target_id) else { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); d.set_error_toast("Row no longer exists"); } return vec![]; }; match agent.dashboard_answer_question(option_idx, freeform) { // Whole form answered → close the peek. crate::app::agent_view::PeekAnswerOutcome::Submitted => { if let Some(d) = app.dashboard.as_mut() { d.set_peek(None); } } // Advanced to the next question → keep the peek open but reset the // per-question draft (option cursor + free-text) so the next // question starts fresh; the next render refreshes its content. crate::app::agent_view::PeekAnswerOutcome::Advanced => { if let Some(d) = app.dashboard.as_mut() { if let Some(p) = d.peek.as_mut() { p.selected_option = None; } d.clear_peek_reply(); } } // Nothing happened (e.g. empty "Other") → leave the peek as-is. crate::app::agent_view::PeekAnswerOutcome::NoOp => {} } vec![] }