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:
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user