From 9e822a357692a5ea2d1288c2cadc5c56a47571af Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 23 May 2026 13:18:21 -0500 Subject: [PATCH] fix(tui): hide stale completed tasks from Work sidebar Filter old terminal task records out of the Work sidebar while keeping active and recent task state visible. --- crates/tui/src/tui/app.rs | 5 ++ crates/tui/src/tui/ui.rs | 68 +++++++++++++-- crates/tui/src/tui/ui/tests.rs | 148 +++++++++++++++++++++++++++++++++ 3 files changed, 215 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 13de38d5..7e5479d2 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1340,6 +1340,10 @@ pub struct App { pub workspace_context_refreshed_at: Option, /// Cached background tasks for sidebar rendering. pub task_panel: Vec, + /// Wall-clock time when this TUI session started. Used by the Work + /// sidebar projection to hide completed durable tasks that finished + /// before the current session (bug #1913). + pub session_started_at: chrono::DateTime, /// Whether the UI needs to be redrawn. pub needs_redraw: bool, /// When the current thinking block started (for duration tracking). @@ -1889,6 +1893,7 @@ impl App { workspace_context_cell: std::sync::Arc::new(std::sync::Mutex::new(None)), workspace_context_refreshed_at: None, task_panel: Vec::new(), + session_started_at: chrono::Utc::now(), needs_redraw: true, thinking_started_at: None, is_compacting: false, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 7cf3b0cb..4b90a72f 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -54,7 +54,7 @@ use crate::session_manager::{ create_saved_session_with_id_and_mode, create_saved_session_with_mode, update_session, }; use crate::task_manager::{ - NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, + NewTaskRequest, SharedTaskManager, TaskManager, TaskManagerConfig, TaskStatus, TaskSummary, }; use crate::tools::spec::RuntimeToolServices; use crate::tools::subagent::SubAgentStatus; @@ -724,13 +724,69 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { } } +/// How long after a task finishes it should still appear in the Work +/// sidebar even if its `ended_at` predates the current TUI session. +/// +/// Tasks completing during the current session always show (until the +/// next session boundary). Tasks that completed shortly before the +/// session also show, so users coming back to a terminal see "you just +/// finished X". Anything older than this window is hidden — preventing +/// the sidebar from accumulating indefinitely (bug #1913). +const WORK_SIDEBAR_RECENT_COMPLETED_TTL: chrono::Duration = chrono::Duration::hours(2); + +/// Choose which durable-task summaries should appear in the Work +/// sidebar's Tasks panel. +/// +/// Active tasks (`Queued`/`Running`) are always included. Terminal +/// tasks (`Completed`/`Failed`/`Canceled`) are kept only if their +/// `ended_at` falls within the "recent" window — defined as either: +/// +/// - within the current TUI session (`ended_at >= session_started_at`), or +/// - within `recent_ttl` of `now` (so a task that finished a few +/// minutes before the session started still shows). +/// +/// Anything older than that — including the multi-day-old completed +/// tasks reported in bug #1913 — is excluded so the sidebar does not +/// accumulate indefinitely across sessions. +/// +/// A terminal task missing `ended_at` is treated as not-recent and +/// dropped: durable tasks always stamp `ended_at` when they reach a +/// terminal state, so absence of it indicates a record from a much +/// older schema and isn't worth surfacing. +pub(crate) fn select_work_sidebar_tasks( + tasks: Vec, + session_started_at: chrono::DateTime, + now: chrono::DateTime, + recent_ttl: chrono::Duration, +) -> Vec { + let recent_cutoff = now - recent_ttl; + tasks + .into_iter() + .filter(|task| match task.status { + TaskStatus::Queued | TaskStatus::Running => true, + TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Canceled => { + match task.ended_at { + Some(ended_at) => ended_at >= session_started_at || ended_at >= recent_cutoff, + None => false, + } + } + }) + .collect() +} + async fn refresh_active_task_panel(app: &mut App, task_manager: &SharedTaskManager) { let tasks = task_manager.list_tasks(None).await; - let mut entries: Vec = tasks - .into_iter() - .filter(|task| matches!(task.status, TaskStatus::Queued | TaskStatus::Running)) - .map(task_summary_to_panel_entry) - .collect(); + let session_started_at = app.session_started_at; + let now = chrono::Utc::now(); + let mut entries: Vec = select_work_sidebar_tasks( + tasks, + session_started_at, + now, + WORK_SIDEBAR_RECENT_COMPLETED_TTL, + ) + .into_iter() + .map(task_summary_to_panel_entry) + .collect(); entries.extend(active_rlm_task_entries(app)); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 5e386bb5..e8b11d51 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -6178,3 +6178,151 @@ fn toast_stack_overlay_respects_composer_boundary() { "max_above ({max_above}) must never exceed the composer→footer gap ({gap})" ); } + +// === Bug #1913: Work sidebar should hide stale completed tasks ============ +// +// The Work sidebar reads `~/.deepseek/tasks/` on startup, which holds every +// durable task the user has ever run. Without filtering, completed tasks +// from prior sessions persist indefinitely. The projection helper keeps +// active tasks, keeps tasks that finished during this session, keeps tasks +// that finished within the last `recent_ttl`, and drops everything older. + +mod work_sidebar_projection_tests { + use super::*; + use crate::task_manager::{TaskStatus, TaskSummary}; + use chrono::{Duration, TimeZone, Utc}; + + fn sample_task( + id: &str, + status: TaskStatus, + ended_at: Option>, + ) -> TaskSummary { + TaskSummary { + id: id.to_string(), + status, + prompt_summary: format!("task {id}"), + model: "deepseek-v4-flash".to_string(), + mode: "agent".to_string(), + created_at: Utc.with_ymd_and_hms(2026, 5, 16, 12, 0, 0).unwrap(), + started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()), + ended_at, + duration_ms: ended_at.map(|_| 1_234), + error: None, + thread_id: None, + turn_id: None, + } + } + + #[test] + fn work_sidebar_hides_stale_completed_tasks_but_keeps_active_and_recent() { + // Pretend the TUI session started on 2026-05-23T10:00:00Z. "Now" + // is one minute into the session. + let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap(); + let now = session_started_at + Duration::minutes(1); + let recent_ttl = Duration::hours(2); + + let active_running = sample_task("active_run", TaskStatus::Running, None); + let active_queued = sample_task("active_q", TaskStatus::Queued, None); + + // Completed during the current session — must show. + let just_finished = sample_task( + "just_done", + TaskStatus::Completed, + Some(session_started_at + Duration::seconds(30)), + ); + + // Completed shortly before the session started, inside the + // recent-TTL window — must show. + let recently_finished_before_session = sample_task( + "recent_done", + TaskStatus::Failed, + Some(session_started_at - Duration::minutes(15)), + ); + + // Stale completed from 6 days ago (the exact scenario in #1913) — + // must be hidden. + let stale_completed = sample_task( + "stale_done", + TaskStatus::Completed, + Some(session_started_at - Duration::days(6)), + ); + let stale_canceled = sample_task( + "stale_cancel", + TaskStatus::Canceled, + Some(session_started_at - Duration::days(7)), + ); + let stale_failed = sample_task( + "stale_fail", + TaskStatus::Failed, + Some(session_started_at - Duration::days(3)), + ); + + // A terminal task without `ended_at` shouldn't sneak through. + let terminal_no_timestamp = sample_task("ghost", TaskStatus::Completed, None); + + let tasks = vec![ + active_running.clone(), + active_queued.clone(), + just_finished.clone(), + recently_finished_before_session.clone(), + stale_completed.clone(), + stale_canceled.clone(), + stale_failed.clone(), + terminal_no_timestamp.clone(), + ]; + + let kept = select_work_sidebar_tasks(tasks, session_started_at, now, recent_ttl); + let kept_ids: Vec<&str> = kept.iter().map(|t| t.id.as_str()).collect(); + + assert!( + kept_ids.contains(&"active_run"), + "active running task must always show: {kept_ids:?}" + ); + assert!( + kept_ids.contains(&"active_q"), + "active queued task must always show: {kept_ids:?}" + ); + assert!( + kept_ids.contains(&"just_done"), + "task completed during the current session must show: {kept_ids:?}" + ); + assert!( + kept_ids.contains(&"recent_done"), + "task completed within the recent TTL before session start must show: \ + {kept_ids:?}" + ); + + assert!( + !kept_ids.contains(&"stale_done"), + "completed task from 6 days ago must be hidden (bug #1913): {kept_ids:?}" + ); + assert!( + !kept_ids.contains(&"stale_cancel"), + "canceled task from 7 days ago must be hidden: {kept_ids:?}" + ); + assert!( + !kept_ids.contains(&"stale_fail"), + "failed task from 3 days ago must be hidden: {kept_ids:?}" + ); + assert!( + !kept_ids.contains(&"ghost"), + "terminal task missing ended_at must be hidden: {kept_ids:?}" + ); + } + + #[test] + fn work_sidebar_keeps_tasks_completed_at_session_boundary() { + // Edge case: a task that finished at exactly the same instant the + // session started should still be visible (>= comparison). + let session_started_at = Utc.with_ymd_and_hms(2026, 5, 23, 10, 0, 0).unwrap(); + let now = session_started_at + Duration::seconds(1); + let recent_ttl = Duration::hours(2); + + let at_boundary = sample_task("boundary", TaskStatus::Completed, Some(session_started_at)); + + let kept = + select_work_sidebar_tasks(vec![at_boundary], session_started_at, now, recent_ttl); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].id, "boundary"); + } +}