use std::collections::HashMap; use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity}; use uuid::Uuid; use crate::ai::agent::conversation::AIConversationId; use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel, PendingAttachment}; use crate::features::FeatureFlag; use crate::settings::{ AISettings, AISettingsChangedEvent, LongRunningCommandSubmissionMode, PromptSubmissionMode, }; use crate::terminal::model::block::Block; /// A globally unique identifier for a single queued prompt row. /// Used by the queue panel to address rows across reorder, edit, and delete. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct QueuedQueryId(Uuid); impl QueuedQueryId { fn new() -> Self { Self(Uuid::new_v4()) } } /// Where a queued prompt came from. /// The origin is informational for telemetry; FIFO ordering and firing semantics are uniform. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum QueuedQueryOrigin { /// Filed while the initial Cloud Mode prompt waits to be handed off. InitialCloudMode, /// Filed via the `/queue ` slash command. QueueSlashCommand, /// Filed via the auto-queue toggle in the warping indicator. AutoQueueToggle, /// Filed because auto-queue was in effect during an agent-requested long-running command. LrcAutoQueue, /// Filed as the follow-up prompt of a `/compact-and ` slash command, waiting for /// the summarize to finish. CompactAndSlashCommand, /// Filed as the follow-up prompt of a `/fork-and-compact ` slash command on the /// forked conversation, waiting for the fork's summarize to finish. ForkAndCompactSlashCommand, } /// Whether a queued row is an agent prompt or a shell command. Attachments live inside the /// `Prompt` variant so a `Command` structurally cannot carry any. #[derive(Debug, Clone)] enum QueuedQueryKind { /// An agent prompt, with any image/file attachments captured from the input when it was /// queued. The attachments fire with the prompt and are dropped when the row is removed. Prompt { attachments: Vec }, /// A shell command run in the terminal (or via the shared session for cloud panes). Command, } /// A single queued row: an agent prompt or a shell command. #[derive(Debug, Clone)] pub struct QueuedQuery { id: QueuedQueryId, text: String, origin: QueuedQueryOrigin, kind: QueuedQueryKind, } impl QueuedQuery { pub fn new(text: String, origin: QueuedQueryOrigin) -> Self { Self::new_with_attachments(text, origin, Vec::new()) } pub fn new_with_attachments( text: String, origin: QueuedQueryOrigin, attachments: Vec, ) -> Self { Self { id: QueuedQueryId::new(), text, origin, kind: QueuedQueryKind::Prompt { attachments }, } } /// Builds a queued shell command. Commands never carry attachments. pub fn new_command(text: String, origin: QueuedQueryOrigin) -> Self { Self { id: QueuedQueryId::new(), text, origin, kind: QueuedQueryKind::Command, } } pub fn id(&self) -> QueuedQueryId { self.id } pub fn text(&self) -> &str { &self.text } pub fn origin(&self) -> QueuedQueryOrigin { self.origin } /// Returns true if this row is a shell command rather than an agent prompt. pub fn is_command(&self) -> bool { matches!(self.kind, QueuedQueryKind::Command) } pub fn attachments(&self) -> &[PendingAttachment] { match &self.kind { QueuedQueryKind::Prompt { attachments } => attachments, QueuedQueryKind::Command => &[], } } /// Returns true if this row is locked from user mutation, reorder, and auto-fire. /// Currently only the locked initial Cloud Mode row is non-mutable; lifecycle code /// removes it explicitly via [`QueuedQueryModel::remove_initial_cloud_mode_row`]. pub fn is_locked(&self) -> bool { matches!(self.origin, QueuedQueryOrigin::InitialCloudMode) } } /// What the auto-fire drain should do with the head row. Produced by /// [`QueuedQueryModel::peek_autofire`] *without* removing the row; the caller removes it via /// [`QueuedQueryModel::remove_fired_row`] once the prompt has been dispatched or restored. #[derive(Debug)] pub enum AutofireAction { /// Submit this prompt as a normal queued user query. The row stays in the queue so the send /// path can read its attachments by `query_id`; the caller removes it afterward. Submit { query_id: QueuedQueryId, text: String, }, /// The head row was in edit mode. The caller restores `text` (the row's last committed text) /// and `attachments` to the input box, then removes the row. `is_command` distinguishes a /// shell command (no attachments; restored in shell mode) from an agent prompt, so the /// restored row keeps its kind instead of being re-submitted as the wrong type. PopFromEditMode { query_id: QueuedQueryId, text: String, attachments: Vec, is_command: bool, }, /// Execute this row as a shell command (its kind is `Command`). The caller runs the command, /// removes the row, and waits for the command to finish before draining the next row. ExecuteCommand { query_id: QueuedQueryId, command: String, }, } /// Per-conversation queue / edit / toggle state. /// Lives inside [`QueuedQueryModel::queues`]; a missing key means empty queue, no edit in /// progress, and no explicit auto-queue override (so the cached default from /// [`AISettings::default_prompt_submission_mode`] is used). #[derive(Default)] struct ConversationQueueState { queue: Vec, editing: Option, /// Explicit per-conversation override. `None` defers to the model's cached /// `default_mode`; `Some` means the user has toggled this conversation /// at least once. queue_next_prompt_override: Option, /// True while a drained shell command from this queue is running. Set when the command is /// dispatched and cleared when it finishes; keeps the queue accepting new rows while the /// agent is idle and gates the next drain until the command completes. command_in_flight: bool, /// Manual queue toggle made during an agent-requested long-running command. Cleared when /// the command ends; never touches `queue_next_prompt_override`. queue_next_lrc_prompt_override: Option, } /// App-wide singleton owning the queued prompts and auto-queue toggle for every conversation, /// indexed by [`AIConversationId`]. Queues outlive the agent-view session that originated them; /// cleanup is driven by [`BlocklistAIHistoryModel`] lifecycle events that this model subscribes /// to in [`QueuedQueryModel::new`]. pub struct QueuedQueryModel { queues: HashMap, /// Cached value of the `AISettings::default_prompt_submission_mode` setting, /// refreshed by an `AISettingsChangedEvent::DefaultPromptSubmissionMode` /// subscription. Used as the fallback when a conversation has no explicit /// per-conversation override. Caching keeps the warping-indicator render /// path doing only a hashmap lookup plus a comparison. default_mode: PromptSubmissionMode, } /// Events emitted by [`QueuedQueryModel`]. Every variant carries the `conversation_id` it applies /// to so subscribers can filter to the conversation they care about. #[derive(Debug, Clone)] pub enum QueuedQueryEvent { Appended { conversation_id: AIConversationId, query_id: QueuedQueryId, }, Removed { conversation_id: AIConversationId, query_id: QueuedQueryId, }, Reordered { conversation_id: AIConversationId, }, EditEntered { conversation_id: AIConversationId, query_id: QueuedQueryId, }, EditCommitted { conversation_id: AIConversationId, query_id: QueuedQueryId, }, EditCancelled { conversation_id: AIConversationId, #[allow(dead_code)] query_id: QueuedQueryId, }, Cleared { conversation_id: AIConversationId, }, QueueNextPromptToggled { conversation_id: AIConversationId, }, /// The `AISettings::default_prompt_submission_mode` setting changed, so the /// effective value of `is_queue_next_prompt_enabled` may have changed for /// every conversation without an explicit override. Subscribers that /// display the toggle state should re-render. DefaultModeChanged, } impl Entity for QueuedQueryModel { type Event = QueuedQueryEvent; } impl SingletonEntity for QueuedQueryModel {} impl QueuedQueryModel { pub fn new(ctx: &mut ModelContext) -> Self { // Drop queue/toggle state for any conversation that is removed, deleted, or cleared // from its owning terminal view. Agent-view exit is intentionally NOT subscribed to: // conversations (cloud agents in particular) outlive their visible session. let history_handle = BlocklistAIHistoryModel::handle(ctx); ctx.subscribe_to_model(&history_handle, |this, _, event, ctx| { this.handle_history_event(event, ctx); }); // Cache the default submission mode and refresh whenever the AI setting // changes. The render path consults the cache instead of dereferencing // the setting on every call. The LRC submission-mode setting is read by // callers directly, but its changes also re-emit `DefaultModeChanged` so // the chip and ghost text re-render with the new effective state. let default_mode = AISettings::as_ref(ctx).default_prompt_submission_mode; let ai_settings_handle = AISettings::handle(ctx); ctx.subscribe_to_model(&ai_settings_handle, |this, _, event, ctx| match event { AISettingsChangedEvent::PromptSubmissionMode { .. } => { this.default_mode = AISettings::as_ref(ctx).default_prompt_submission_mode; ctx.emit(QueuedQueryEvent::DefaultModeChanged); } AISettingsChangedEvent::LongRunningCommandSubmissionMode { .. } => { ctx.emit(QueuedQueryEvent::DefaultModeChanged); } _ => {} }); Self { queues: HashMap::new(), default_mode, } } fn handle_history_event( &mut self, event: &BlocklistAIHistoryEvent, ctx: &mut ModelContext, ) { match event { BlocklistAIHistoryEvent::RemoveConversation { conversation_id, .. } | BlocklistAIHistoryEvent::DeletedConversation { conversation_id, .. } => { self.drop_conversation(*conversation_id, ctx); } BlocklistAIHistoryEvent::ClearedConversationsForTerminalSurface { cleared_conversation_ids, .. } => { for conversation_id in cleared_conversation_ids.clone() { self.drop_conversation(conversation_id, ctx); } } _ => {} } } fn drop_conversation( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) { if self.queues.remove(&conversation_id).is_some() { ctx.emit(QueuedQueryEvent::Cleared { conversation_id }); } } /// Returns the queue for `conversation_id`. Returns an empty slice when no entry exists. pub fn queue(&self, conversation_id: AIConversationId) -> &[QueuedQuery] { self.queues .get(&conversation_id) .map(|state| state.queue.as_slice()) .unwrap_or(&[]) } /// Returns true when `conversation_id` has at least one queued prompt. pub fn has_queue(&self, conversation_id: AIConversationId) -> bool { self.queues .get(&conversation_id) .is_some_and(|state| !state.queue.is_empty()) } /// Returns true when a queued row would auto-fire for `conversation_id` the next time the /// conversation finishes successfully. Mirrors [`Self::peek_autofire`]'s gating: false for an /// empty queue or a locked head row (which never auto-fires). pub fn has_autofireable_prompt(&self, conversation_id: AIConversationId) -> bool { self.queues .get(&conversation_id) .and_then(|state| state.queue.first()) .is_some_and(|first| !first.is_locked()) } /// Marks that a dispatched queued command is running for `conversation_id`. While set, the /// queue keeps accepting new rows (the agent is idle) and the next drain waits for the /// command to finish. pub fn arm_command_in_flight(&mut self, conversation_id: AIConversationId) { self.queues .entry(conversation_id) .or_default() .command_in_flight = true; } /// Clears the in-flight-command marker for `conversation_id`. pub fn clear_command_in_flight(&mut self, conversation_id: AIConversationId) { if let Some(state) = self.queues.get_mut(&conversation_id) { state.command_in_flight = false; } } /// Returns true while a dispatched queued command is running for `conversation_id`. pub fn has_command_in_flight(&self, conversation_id: AIConversationId) -> bool { self.queues .get(&conversation_id) .is_some_and(|state| state.command_in_flight) } /// Returns the conversation owned by `terminal_view_id` that currently has a queued command in /// flight, if any. pub fn command_in_flight_for_terminal_view( &self, terminal_view_id: EntityId, history_model: &BlocklistAIHistoryModel, ) -> Option { history_model .all_live_conversations_for_terminal_surface(terminal_view_id) .find_map(|conversation| { self.has_command_in_flight(conversation.id()) .then_some(conversation.id()) }) } /// Returns the row currently in edit mode for `conversation_id`, if any. pub fn editing_row(&self, conversation_id: AIConversationId) -> Option { self.queues .get(&conversation_id) .and_then(|state| state.editing) } /// Returns true when the head row of `conversation_id`'s queue is currently being edited. pub fn first_row_is_in_edit_mode(&self, conversation_id: AIConversationId) -> bool { let Some(state) = self.queues.get(&conversation_id) else { return false; }; let Some(editing_id) = state.editing else { return false; }; state.queue.first().is_some_and(|q| q.id == editing_id) } /// Returns the effective auto-queue state for `conversation_id`, given the terminal's /// `active_block`. pub fn is_queue_next_prompt_enabled( &self, conversation_id: AIConversationId, active_block: &Block, app: &AppContext, ) -> bool { if is_lrc_auto_queue_active(active_block, conversation_id, app) { // While an agent controls the active agent-requested command, the command-scoped // toggle governs queueing. self.is_queue_next_prompt_enabled_during_lrc(conversation_id) } else { // Otherwise the per-conversation toggle governs queueing. self.is_queue_next_prompt_toggle_enabled(conversation_id) } } /// Auto-queue state while an eligible agent-requested long-running command is active: on unless /// toggled off for the duration of the command. fn is_queue_next_prompt_enabled_during_lrc(&self, conversation_id: AIConversationId) -> bool { self.queues .get(&conversation_id) .and_then(|state| state.queue_next_lrc_prompt_override) .unwrap_or(true) } /// Per-conversation auto-queue toggle state, ignoring any long-running-command override: /// the explicit toggle when set, otherwise on when the default submission mode is `Queue`. pub(crate) fn is_queue_next_prompt_toggle_enabled( &self, conversation_id: AIConversationId, ) -> bool { self.queues .get(&conversation_id) .and_then(|state| state.queue_next_prompt_override) .unwrap_or(self.default_mode == PromptSubmissionMode::Queue) } /// Toggles the per-conversation auto-queue state. Computes the effective /// current value (which may come from the cached default) before writing /// its inverse as an explicit override, so toggling from the setting-driven /// default flips correctly. pub fn toggle_queue_next_prompt( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) { let current = self.is_queue_next_prompt_toggle_enabled(conversation_id); let state = self.queues.entry(conversation_id).or_default(); state.queue_next_prompt_override = Some(!current); ctx.emit(QueuedQueryEvent::QueueNextPromptToggled { conversation_id }); } /// Toggles the auto-queue state for the duration of the eligible long-running command. pub fn toggle_queue_next_prompt_during_lrc( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) { let current = self.is_queue_next_prompt_enabled_during_lrc(conversation_id); let state = self.queues.entry(conversation_id).or_default(); state.queue_next_lrc_prompt_override = Some(!current); ctx.emit(QueuedQueryEvent::QueueNextPromptToggled { conversation_id }); } /// Clears the LRC-scoped auto-queue override when the long-running command ends, so the /// conversation reverts to its pre-command queue state. pub fn clear_queue_next_lrc_prompt_override( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) { let Some(state) = self.queues.get_mut(&conversation_id) else { return; }; if state.queue_next_lrc_prompt_override.take().is_some() { ctx.emit(QueuedQueryEvent::QueueNextPromptToggled { conversation_id }); } } /// Appends `query` to the tail of `conversation_id`'s queue. pub fn append( &mut self, conversation_id: AIConversationId, query: QueuedQuery, ctx: &mut ModelContext, ) -> QueuedQueryId { let query_id = query.id; let state = self.queues.entry(conversation_id).or_default(); state.queue.push(query); ctx.emit(QueuedQueryEvent::Appended { conversation_id, query_id, }); query_id } /// Pops the first row in `conversation_id`'s queue and returns it. /// Used by the non-clean drain path (Error / Cancelled) to restore a single popped /// prompt to the input editor. No-ops when the head is locked /// ([`QueuedQuery::is_locked`]) so a status-transition arriving before the lifecycle /// cleanup events cannot clobber the locked initial Cloud Mode row. pub fn pop_front( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) -> Option { let state = self.queues.get_mut(&conversation_id)?; if state.queue.first()?.is_locked() { return None; } let popped = state.queue.remove(0); if state.editing == Some(popped.id) { state.editing = None; } ctx.emit(QueuedQueryEvent::Removed { conversation_id, query_id: popped.id, }); Some(popped) } /// Auto-fire drain entry point for `conversation_id`. Returns the action for the head row /// *without* removing it (so the send path can read its attachments by id), or `None` for an /// empty queue or a locked head ([`QueuedQuery::is_locked`]). The caller removes the row via /// [`Self::remove_fired_row`] once it has been dispatched or restored to the input. pub fn peek_autofire(&self, conversation_id: AIConversationId) -> Option { let state = self.queues.get(&conversation_id)?; let first = state.queue.first()?; if first.is_locked() { return None; } let first_in_edit_mode = state.editing == Some(first.id); Some(if first_in_edit_mode { AutofireAction::PopFromEditMode { query_id: first.id, text: first.text.clone(), attachments: first.attachments().to_vec(), is_command: first.is_command(), } } else if first.is_command() { AutofireAction::ExecuteCommand { query_id: first.id, command: first.text.clone(), } } else { AutofireAction::Submit { query_id: first.id, text: first.text.clone(), } }) } /// Removes the row `query_id` from `conversation_id`'s queue after it has been fired. In the /// edit-mode auto-fire path, the caller first restores the row's committed text and /// attachments to the input, then calls this to drop the row and clear edit state. pub fn remove_fired_row( &mut self, conversation_id: AIConversationId, query_id: QueuedQueryId, ctx: &mut ModelContext, ) { let Some(state) = self.queues.get_mut(&conversation_id) else { return; }; let Some(idx) = state.queue.iter().position(|q| q.id == query_id) else { return; }; state.queue.remove(idx); if state.editing == Some(query_id) { state.editing = None; } ctx.emit(QueuedQueryEvent::Removed { conversation_id, query_id, }); } /// Restores a fired row when submission fails after the row was removed. pub(crate) fn restore_fired_row( &mut self, conversation_id: AIConversationId, insert_index: usize, query: QueuedQuery, ctx: &mut ModelContext, ) { let state = self.queues.entry(conversation_id).or_default(); let query_id = query.id; if state.queue.iter().any(|queued| queued.id == query_id) { return; } let insert_index = insert_index.min(state.queue.len()); state.queue.insert(insert_index, query); ctx.emit(QueuedQueryEvent::Appended { conversation_id, query_id, }); } /// Returns the attachments captured on the queued row `query_id` within `conversation_id`'s /// queue, or an empty slice if no such row exists. Used by the send path to attach a fired /// queued prompt's images/files without removing the row first. pub fn attachments_for( &self, conversation_id: AIConversationId, query_id: QueuedQueryId, ) -> &[PendingAttachment] { self.queues .get(&conversation_id) .and_then(|state| state.queue.iter().find(|q| q.id == query_id)) .map(QueuedQuery::attachments) .unwrap_or(&[]) } /// Removes a specific row by id within `conversation_id`'s queue, if present. Returns the /// removed row. No-ops when the target row is locked ([`QueuedQuery::is_locked`]); the /// locked initial Cloud Mode row is only removable via /// [`Self::remove_initial_cloud_mode_row`]. pub fn remove_by_id( &mut self, conversation_id: AIConversationId, query_id: QueuedQueryId, ctx: &mut ModelContext, ) -> Option { let state = self.queues.get_mut(&conversation_id)?; let idx = state.queue.iter().position(|q| q.id == query_id)?; if state.queue[idx].is_locked() { return None; } let removed = state.queue.remove(idx); if state.editing == Some(query_id) { state.editing = None; } ctx.emit(QueuedQueryEvent::Removed { conversation_id, query_id, }); Some(removed) } /// Removes the locked initial Cloud Mode row from `conversation_id`'s queue, if it is still /// at the queue head. pub fn remove_initial_cloud_mode_row( &mut self, conversation_id: AIConversationId, ctx: &mut ModelContext, ) -> Option { let state = self.queues.get_mut(&conversation_id)?; if !state .queue .first() .is_some_and(|row| row.origin == QueuedQueryOrigin::InitialCloudMode) { return None; } let removed = state.queue.remove(0); if state.editing == Some(removed.id) { state.editing = None; } ctx.emit(QueuedQueryEvent::Removed { conversation_id, query_id: removed.id, }); Some(removed) } /// Moves the row identified by `source_id` to position `target_index` within /// `conversation_id`'s queue. `target_index` is interpreted as the index in the post-removal /// list and is clamped to the queue length. No-ops when the source row is locked /// ([`QueuedQuery::is_locked`]) or when the move would displace a locked row off the head of /// the queue. pub fn reorder( &mut self, conversation_id: AIConversationId, source_id: QueuedQueryId, target_index: usize, ctx: &mut ModelContext, ) { let Some(state) = self.queues.get_mut(&conversation_id) else { return; }; let Some(source_idx) = state.queue.iter().position(|q| q.id == source_id) else { return; }; let head_is_locked = state.queue.first().is_some_and(|row| row.is_locked()); if state.queue[source_idx].is_locked() || (target_index == 0 && head_is_locked) { return; } let row = state.queue.remove(source_idx); let clamped = target_index.min(state.queue.len()); state.queue.insert(clamped, row); ctx.emit(QueuedQueryEvent::Reordered { conversation_id }); } /// Enters edit mode for `query_id` in `conversation_id`'s queue. If another row was being /// edited, that edit is cancelled (its text is unchanged, per the spec). No-ops when the /// target row is locked ([`QueuedQuery::is_locked`]). pub fn enter_edit_mode( &mut self, conversation_id: AIConversationId, query_id: QueuedQueryId, ctx: &mut ModelContext, ) { let Some(state) = self.queues.get_mut(&conversation_id) else { return; }; if !state .queue .iter() .any(|q| q.id == query_id && !q.is_locked()) { return; } let prev_edit = state.editing.replace(query_id); if let Some(prev) = prev_edit { if prev != query_id { ctx.emit(QueuedQueryEvent::EditCancelled { conversation_id, query_id: prev, }); } } ctx.emit(QueuedQueryEvent::EditEntered { conversation_id, query_id, }); } /// Commits the in-progress edit in `conversation_id` by replacing the row's text with /// `new_text` and clearing edit state. An empty `new_text` cancels the edit and leaves the /// original row text untouched. pub fn commit_edit( &mut self, conversation_id: AIConversationId, new_text: String, ctx: &mut ModelContext, ) { let Some(state) = self.queues.get_mut(&conversation_id) else { return; }; let Some(query_id) = state.editing.take() else { return; }; if new_text.is_empty() { ctx.emit(QueuedQueryEvent::EditCancelled { conversation_id, query_id, }); return; } if let Some(row) = state.queue.iter_mut().find(|q| q.id == query_id) { row.text = new_text; } ctx.emit(QueuedQueryEvent::EditCommitted { conversation_id, query_id, }); } /// Cancels the in-progress edit in `conversation_id` without modifying the row's text. pub fn cancel_edit(&mut self, conversation_id: AIConversationId, ctx: &mut ModelContext) { let Some(state) = self.queues.get_mut(&conversation_id) else { return; }; let Some(query_id) = state.editing.take() else { return; }; ctx.emit(QueuedQueryEvent::EditCancelled { conversation_id, query_id, }); } } /// Returns true when queue mode is auto-enabled for `conversation_id`: an agent controls /// `active_block`'s agent-requested long-running command, and the user's settings opt into /// queueing prompts for the duration of such commands. pub(crate) fn is_lrc_auto_queue_active( active_block: &Block, conversation_id: AIConversationId, app: &AppContext, ) -> bool { let ai_settings = AISettings::as_ref(app); FeatureFlag::QueueSlashCommand.is_enabled() && ai_settings.default_prompt_submission_mode == PromptSubmissionMode::Interrupt && ai_settings.long_running_command_submission_mode == LongRunningCommandSubmissionMode::QueueUntilCommandCompletes && active_block.is_agent_in_control() && active_block.is_agent_requested_command() && active_block.ai_conversation_id() == Some(conversation_id) } #[cfg(test)] #[path = "queued_query_tests.rs"] mod tests;