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
+83 -1
View File
@@ -133,6 +133,13 @@ pub struct BlocklistAIStatusBar {
/// the warping indicator while the active block has a recorded LRC snapshot.
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>,
/// 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>) {
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 {
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,
))
@@ -240,6 +240,8 @@ pub struct WarpingProps<'a, V> {
pub secondary_element: Option<Box<dyn Element>>,
/// When an LRC subagent has sent at least one snapshot, the timestamp of the most recent snapshot.
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> {
@@ -470,6 +472,11 @@ pub fn render_warping_indicator<V: View>(
}
}
} 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,
@@ -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");
}