784 lines
30 KiB
Rust
784 lines
30 KiB
Rust
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 <prompt>` 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 <prompt>` slash command, waiting for
|
|
/// the summarize to finish.
|
|
CompactAndSlashCommand,
|
|
/// Filed as the follow-up prompt of a `/fork-and-compact <prompt>` 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<PendingAttachment> },
|
|
/// 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<PendingAttachment>,
|
|
) -> 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<PendingAttachment>,
|
|
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<QueuedQuery>,
|
|
editing: Option<QueuedQueryId>,
|
|
/// 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<bool>,
|
|
/// 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<bool>,
|
|
}
|
|
|
|
/// 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<AIConversationId, ConversationQueueState>,
|
|
/// 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>) -> 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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<AIConversationId> {
|
|
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<QueuedQueryId> {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) -> 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<Self>,
|
|
) -> Option<QueuedQuery> {
|
|
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<AutofireAction> {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) -> Option<QueuedQuery> {
|
|
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<Self>,
|
|
) -> Option<QueuedQuery> {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>,
|
|
) {
|
|
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<Self>) {
|
|
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;
|