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.
This commit is contained in:
Hunter Bown
2026-05-23 13:18:21 -05:00
committed by GitHub
parent 96b903aa39
commit 9e822a3576
3 changed files with 215 additions and 6 deletions
+5
View File
@@ -1340,6 +1340,10 @@ pub struct App {
pub workspace_context_refreshed_at: Option<Instant>,
/// Cached background tasks for sidebar rendering.
pub task_panel: Vec<TaskPanelEntry>,
/// 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<chrono::Utc>,
/// 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,
+62 -6
View File
@@ -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<TaskSummary>,
session_started_at: chrono::DateTime<chrono::Utc>,
now: chrono::DateTime<chrono::Utc>,
recent_ttl: chrono::Duration,
) -> Vec<TaskSummary> {
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<TaskPanelEntry> = 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<TaskPanelEntry> = 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));
+148
View File
@@ -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<chrono::DateTime<Utc>>,
) -> 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");
}
}