show thinking timer

This commit is contained in:
Josh Woodcock
2026-05-28 12:08:38 -05:00
parent 1fee031ea5
commit 4e678de9a8
4 changed files with 138 additions and 6 deletions
Generated
+1 -1
View File
@@ -5191,7 +5191,7 @@ dependencies = [
[[package]] [[package]]
name = "galaxy" name = "galaxy"
version = "1.5.2" version = "1.5.3"
dependencies = [ dependencies = [
"addr", "addr",
"aho-corasick", "aho-corasick",
+83 -1
View File
@@ -133,6 +133,13 @@ pub struct BlocklistAIStatusBar {
/// the warping indicator while the active block has a recorded LRC snapshot. /// the warping indicator while the active block has a recorded LRC snapshot.
last_read_refresh_handle: Option<SpawnedFutureHandle>, last_read_refresh_handle: Option<SpawnedFutureHandle>,
/// The time the warping indicator started showing for the current exchange.
warping_start_time: Option<Instant>,
/// 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<SpawnedFutureHandle>,
latest_response_stream_id: Option<ResponseStreamId>, latest_response_stream_id: Option<ResponseStreamId>,
/// Agent tip to display below the warping indicator. /// Agent tip to display below the warping indicator.
@@ -185,6 +192,7 @@ impl BlocklistAIStatusBar {
} }
BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. } => { BlocklistAIHistoryEvent::ClearedConversationsInTerminalView { .. } => {
me.active_exchange_model = None; me.active_exchange_model = None;
me.stop_warping_timer();
ctx.notify(); ctx.notify();
} }
BlocklistAIHistoryEvent::ClearedActiveConversation { BlocklistAIHistoryEvent::ClearedActiveConversation {
@@ -199,6 +207,7 @@ impl BlocklistAIStatusBar {
.is_some_and(|id| id == *conversation_id) .is_some_and(|id| id == *conversation_id)
}) { }) {
me.active_exchange_model = None; me.active_exchange_model = None;
me.stop_warping_timer();
ctx.notify(); ctx.notify();
} }
} }
@@ -394,6 +403,9 @@ impl BlocklistAIStatusBar {
summarization_timer_handle: None, summarization_timer_handle: None,
summarization_start_time: None, summarization_start_time: None,
last_read_refresh_handle: None, last_read_refresh_handle: None,
warping_start_time: None,
warping_message: None,
warping_timer_handle: None,
ambient_agent_view_model, ambient_agent_view_model,
current_tip: None, current_tip: None,
ephemeral_message_model, ephemeral_message_model,
@@ -463,6 +475,7 @@ impl BlocklistAIStatusBar {
}) { }) {
let Some(conversation) = conversation else { let Some(conversation) = conversation else {
self.active_exchange_model = None; self.active_exchange_model = None;
self.stop_warping_timer();
ctx.notify(); ctx.notify();
return; return;
}; };
@@ -486,6 +499,7 @@ impl BlocklistAIStatusBar {
}); });
self.is_summarization_cancel_dialog_open = false; self.is_summarization_cancel_dialog_open = false;
self.stop_summarization_timer(); self.stop_summarization_timer();
self.start_warping_timer(ctx);
if FeatureFlag::AgentTips.is_enabled() { if FeatureFlag::AgentTips.is_enabled() {
self.update_agent_tip(ctx); self.update_agent_tip(ctx);
@@ -499,6 +513,13 @@ impl BlocklistAIStatusBar {
}; };
let status = model.status(ctx); 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 // Auto-clear summarization confirmation dialog if summarization is no longer active
if self.is_summarization_cancel_dialog_open if self.is_summarization_cancel_dialog_open
&& !model.is_conversation_summarization_active(ctx) && !model.is_conversation_summarization_active(ctx)
@@ -525,6 +546,13 @@ impl BlocklistAIStatusBar {
AIBlockOutputStatus::Pending | AIBlockOutputStatus::Failed { .. } => (), 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(); 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>) {
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<Self>) {
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<Self>) -> bool { fn should_refresh_last_read_timer(&self, ctx: &ViewContext<Self>) -> bool {
let active_block_id = self let active_block_id = self
.terminal_model .terminal_model
@@ -813,7 +893,8 @@ impl BlocklistAIStatusBar {
); );
let default_warping_text = fallback_warping_text let default_warping_text = fallback_warping_text
.as_deref() .as_deref()
.unwrap_or(random_load_output_message()) .or(self.warping_message)
.unwrap_or_else(|| random_load_output_message())
.to_owned(); .to_owned();
let secondary_element = if fallback_warping_text.is_some() { let secondary_element = if fallback_warping_text.is_some() {
Some(render_fallback_explanation(model.as_ref(), app)) Some(render_fallback_explanation(model.as_ref(), app))
@@ -870,6 +951,7 @@ impl BlocklistAIStatusBar {
default_warping_text, default_warping_text,
secondary_element, secondary_element,
last_snapshot_at, last_snapshot_at,
warping_start_time: self.warping_start_time,
}, },
app, app,
)) ))
@@ -240,6 +240,8 @@ pub struct WarpingProps<'a, V> {
pub secondary_element: Option<Box<dyn Element>>, pub secondary_element: Option<Box<dyn Element>>,
/// When an LRC subagent has sent at least one snapshot, the timestamp of the most recent snapshot. /// When an LRC subagent has sent at least one snapshot, the timestamp of the most recent snapshot.
pub last_snapshot_at: Option<instant::Instant>, pub last_snapshot_at: Option<instant::Instant>,
/// When the warping indicator started showing, used to display an elapsed timer.
pub warping_start_time: Option<instant::Instant>,
} }
pub struct ButtonProps<'a> { pub struct ButtonProps<'a> {
@@ -470,6 +472,11 @@ pub fn render_warping_indicator<V: View>(
} }
} }
} else { } 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() 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 /// Render output text as shown in the "stopped" and "loading" status banners
pub fn render_output_status_text( pub fn render_output_status_text(
label: MaybeShimmeringText, label: MaybeShimmeringText,
@@ -1,5 +1,5 @@
use itertools::Itertools; 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}; use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
#[cfg(feature = "local_fs")] #[cfg(feature = "local_fs")]
@@ -10,9 +10,10 @@ use galaxyui::App;
use super::{blocklist_image_asset_source, ResolvedBlocklistImageSources}; use super::{blocklist_image_asset_source, ResolvedBlocklistImageSources};
use super::{ use super::{
collect_visual_markdown_lightbox_collection, compute_visual_section_width, collect_visual_markdown_lightbox_collection, compute_visual_section_width,
inline_image_source_label, lightbox_trigger_for_section, query_prefix_highlight_len, format_elapsed_compact, inline_image_source_label, lightbox_trigger_for_section,
render_scrollable_collapsible_content, text_sections_with_indices, CollapsibleElementState, query_prefix_highlight_len, render_scrollable_collapsible_content,
CollapsibleExpansionState, VisualMarkdownLightboxCollection, text_sections_with_indices, CollapsibleElementState, CollapsibleExpansionState,
VisualMarkdownLightboxCollection,
}; };
use crate::{ use crate::{
ai::agent::{ 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:?}"), 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");
}