diff --git a/Cargo.lock b/Cargo.lock index de021623..bcb6786d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5191,7 +5191,11 @@ dependencies = [ [[package]] name = "galaxy" +<<<<<<< HEAD +version = "1.5.3" +======= version = "1.5.4" +>>>>>>> master dependencies = [ "addr", "aho-corasick", diff --git a/app/src/ai/blocklist/block/status_bar.rs b/app/src/ai/blocklist/block/status_bar.rs index efff0256..8b44a77a 100644 --- a/app/src/ai/blocklist/block/status_bar.rs +++ b/app/src/ai/blocklist/block/status_bar.rs @@ -133,6 +133,13 @@ pub struct BlocklistAIStatusBar { /// the warping indicator while the active block has a recorded LRC snapshot. last_read_refresh_handle: Option, + /// The time the warping indicator started showing for the current exchange. + warping_start_time: Option, + /// The random loading message chosen for the current exchange, stable across re-renders. + warping_message: Option<&'static str>, + /// Handle for the periodic timer that updates the warping elapsed timer UI. + warping_timer_handle: Option, + latest_response_stream_id: Option, /// Agent tip to display below the warping indicator. @@ -185,6 +192,7 @@ impl BlocklistAIStatusBar { } BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. } => { me.active_exchange_model = None; + me.stop_warping_timer(); ctx.notify(); } BlocklistAIHistoryEvent::ClearedActiveConversation { @@ -199,6 +207,7 @@ impl BlocklistAIStatusBar { .is_some_and(|id| id == *conversation_id) }) { me.active_exchange_model = None; + me.stop_warping_timer(); ctx.notify(); } } @@ -394,6 +403,9 @@ impl BlocklistAIStatusBar { summarization_timer_handle: None, summarization_start_time: None, last_read_refresh_handle: None, + warping_start_time: None, + warping_message: None, + warping_timer_handle: None, ambient_agent_view_model, current_tip: None, ephemeral_message_model, @@ -463,6 +475,7 @@ impl BlocklistAIStatusBar { }) { let Some(conversation) = conversation else { self.active_exchange_model = None; + self.stop_warping_timer(); ctx.notify(); return; }; @@ -486,6 +499,7 @@ impl BlocklistAIStatusBar { }); self.is_summarization_cancel_dialog_open = false; self.stop_summarization_timer(); + self.start_warping_timer(ctx); if FeatureFlag::AgentTips.is_enabled() { self.update_agent_tip(ctx); @@ -499,6 +513,13 @@ impl BlocklistAIStatusBar { }; let status = model.status(ctx); + let is_finished = matches!( + status, + AIBlockOutputStatus::Complete { .. } + | AIBlockOutputStatus::Cancelled { .. } + | AIBlockOutputStatus::Failed { .. } + ); + // Auto-clear summarization confirmation dialog if summarization is no longer active if self.is_summarization_cancel_dialog_open && !model.is_conversation_summarization_active(ctx) @@ -525,6 +546,13 @@ impl BlocklistAIStatusBar { AIBlockOutputStatus::Pending | AIBlockOutputStatus::Failed { .. } => (), } + // Stop the warping timer when the exchange is no longer streaming/pending. + // Placed after the match so `model` (which borrows self.active_exchange_model) + // is no longer used, avoiding borrow conflicts with &mut self. + if is_finished { + self.stop_warping_timer(); + } + ctx.notify(); } @@ -668,6 +696,58 @@ impl BlocklistAIStatusBar { } } + /// Starts a 1-second periodic timer that keeps the warping elapsed-time indicator fresh. + fn start_warping_timer(&mut self, ctx: &mut ViewContext) { + self.warping_start_time = Some(Instant::now()); + self.warping_message = Some(random_load_output_message()); + // Don't start a new timer if one is already running + if self.warping_timer_handle.is_some() { + return; + } + let handle = ctx.spawn( + async move { + Timer::after(Duration::from_secs(1)).await; + }, + |me, _, ctx| { + me.warping_timer_handle = None; + if me.warping_start_time.is_some() { + ctx.notify(); + me.restart_warping_timer(ctx); + } + }, + ); + self.warping_timer_handle = Some(handle); + } + + /// Restarts the warping timer for the next tick (called from within the timer callback). + fn restart_warping_timer(&mut self, ctx: &mut ViewContext) { + if self.warping_timer_handle.is_some() { + return; + } + let handle = ctx.spawn( + async move { + Timer::after(Duration::from_secs(1)).await; + }, + |me, _, ctx| { + me.warping_timer_handle = None; + if me.warping_start_time.is_some() { + ctx.notify(); + me.restart_warping_timer(ctx); + } + }, + ); + self.warping_timer_handle = Some(handle); + } + + /// Stops the warping elapsed-time timer. + fn stop_warping_timer(&mut self) { + self.warping_start_time = None; + self.warping_message = None; + if let Some(handle) = self.warping_timer_handle.take() { + handle.abort(); + } + } + fn should_refresh_last_read_timer(&self, ctx: &ViewContext) -> bool { let active_block_id = self .terminal_model @@ -813,7 +893,8 @@ impl BlocklistAIStatusBar { ); let default_warping_text = fallback_warping_text .as_deref() - .unwrap_or(random_load_output_message()) + .or(self.warping_message) + .unwrap_or_else(|| random_load_output_message()) .to_owned(); let secondary_element = if fallback_warping_text.is_some() { Some(render_fallback_explanation(model.as_ref(), app)) @@ -870,6 +951,7 @@ impl BlocklistAIStatusBar { default_warping_text, secondary_element, last_snapshot_at, + warping_start_time: self.warping_start_time, }, app, )) diff --git a/app/src/ai/blocklist/block/view_impl/common.rs b/app/src/ai/blocklist/block/view_impl/common.rs index 790e4626..0665b817 100644 --- a/app/src/ai/blocklist/block/view_impl/common.rs +++ b/app/src/ai/blocklist/block/view_impl/common.rs @@ -240,6 +240,8 @@ pub struct WarpingProps<'a, V> { pub secondary_element: Option>, /// When an LRC subagent has sent at least one snapshot, the timestamp of the most recent snapshot. pub last_snapshot_at: Option, + /// When the warping indicator started showing, used to display an elapsed timer. + pub warping_start_time: Option, } pub struct ButtonProps<'a> { @@ -470,6 +472,11 @@ pub fn render_warping_indicator( } } } else { + // Show elapsed timer alongside the random Galaxy status message. + if let Some(start_time) = props.warping_start_time { + non_shimmering_text = + Some(format!(" ({})", format_elapsed_compact(start_time.elapsed()))); + } props.default_warping_text.clone() } } @@ -725,6 +732,24 @@ pub fn format_elapsed_seconds(elapsed: std::time::Duration) -> String { } } +/// Formats elapsed time in a compact form for the warping indicator timer. +/// - Less than 60 seconds: shows seconds (e.g. "5s", "42s") +/// - Less than 120 minutes: shows minutes (e.g. "1m", "45m") +/// - 120 minutes or more: shows hours (e.g. "2h", "3h") +pub fn format_elapsed_compact(elapsed: std::time::Duration) -> String { + let total_seconds = elapsed.as_secs(); + if total_seconds < 60 { + format!("{total_seconds}s") + } else { + let total_minutes = total_seconds / 60; + if total_minutes < 120 { + format!("{total_minutes}m") + } else { + format!("{}h", total_minutes / 60) + } + } +} + /// Render output text as shown in the "stopped" and "loading" status banners pub fn render_output_status_text( label: MaybeShimmeringText, diff --git a/app/src/ai/blocklist/block/view_impl/common_tests.rs b/app/src/ai/blocklist/block/view_impl/common_tests.rs index d573f84d..4e3470b7 100644 --- a/app/src/ai/blocklist/block/view_impl/common_tests.rs +++ b/app/src/ai/blocklist/block/view_impl/common_tests.rs @@ -1,5 +1,5 @@ use itertools::Itertools; -use std::{collections::HashMap, path::PathBuf, sync::Arc}; +use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; use ai::skills::{ParsedSkill, SkillProvider, SkillScope}; #[cfg(feature = "local_fs")] @@ -10,9 +10,10 @@ use galaxyui::App; use super::{blocklist_image_asset_source, ResolvedBlocklistImageSources}; use super::{ collect_visual_markdown_lightbox_collection, compute_visual_section_width, - inline_image_source_label, lightbox_trigger_for_section, query_prefix_highlight_len, - render_scrollable_collapsible_content, text_sections_with_indices, CollapsibleElementState, - CollapsibleExpansionState, VisualMarkdownLightboxCollection, + format_elapsed_compact, inline_image_source_label, lightbox_trigger_for_section, + query_prefix_highlight_len, render_scrollable_collapsible_content, + text_sections_with_indices, CollapsibleElementState, CollapsibleExpansionState, + VisualMarkdownLightboxCollection, }; use crate::{ ai::agent::{ @@ -292,3 +293,27 @@ fn blocklist_image_asset_source_uses_cached_resolution_when_available() { other => panic!("expected cached local file asset source, got {other:?}"), } } + +#[test] +fn format_elapsed_compact_shows_seconds_under_one_minute() { + assert_eq!(format_elapsed_compact(Duration::from_secs(0)), "0s"); + assert_eq!(format_elapsed_compact(Duration::from_secs(1)), "1s"); + assert_eq!(format_elapsed_compact(Duration::from_secs(42)), "42s"); + assert_eq!(format_elapsed_compact(Duration::from_secs(59)), "59s"); +} + +#[test] +fn format_elapsed_compact_shows_minutes_under_120_minutes() { + assert_eq!(format_elapsed_compact(Duration::from_secs(60)), "1m"); + assert_eq!(format_elapsed_compact(Duration::from_secs(90)), "1m"); + assert_eq!(format_elapsed_compact(Duration::from_secs(120)), "2m"); + assert_eq!(format_elapsed_compact(Duration::from_secs(45 * 60)), "45m"); + assert_eq!(format_elapsed_compact(Duration::from_secs(119 * 60 + 59)), "119m"); +} + +#[test] +fn format_elapsed_compact_shows_hours_at_120_minutes_and_above() { + assert_eq!(format_elapsed_compact(Duration::from_secs(120 * 60)), "2h"); + assert_eq!(format_elapsed_compact(Duration::from_secs(180 * 60)), "3h"); + assert_eq!(format_elapsed_compact(Duration::from_secs(600 * 60)), "10h"); +}