diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index b7b21b54..d4c806e6 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -14059,6 +14059,7 @@ mod tests { state: construct_protocol::SessionState::Running, created_at: chrono::Utc::now(), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, diff --git a/crates/cli/src/app/lineage_section.rs b/crates/cli/src/app/lineage_section.rs index 7125b6b3..45f0be11 100644 --- a/crates/cli/src/app/lineage_section.rs +++ b/crates/cli/src/app/lineage_section.rs @@ -366,6 +366,7 @@ mod tests { state: construct_protocol::SessionState::Running, created_at: chrono::Utc::now(), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, diff --git a/crates/cli/src/app/session_picker.rs b/crates/cli/src/app/session_picker.rs index 723c5861..426b66d2 100644 --- a/crates/cli/src/app/session_picker.rs +++ b/crates/cli/src/app/session_picker.rs @@ -835,6 +835,7 @@ mod tests { state: construct_protocol::SessionState::Running, created_at: chrono::Utc::now(), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, diff --git a/crates/cli/src/lineage.rs b/crates/cli/src/lineage.rs index dd3c269c..2aa42183 100644 --- a/crates/cli/src/lineage.rs +++ b/crates/cli/src/lineage.rs @@ -2166,6 +2166,7 @@ mod tests { state: SessionState::Running, created_at: Utc.timestamp_opt(0, 0).unwrap(), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 5c797d1a..67ad3583 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -2038,7 +2038,12 @@ fn session_detail_segments(s: &SessionSummary, now_ms: i64) -> Vec<(String, u8)> ), 2, )); - } else if let Some(at) = s.last_event_at { + } else if let Some(at) = s.last_message_at.or(s.last_event_at) { + // Prefer the last actual chat message so the age means "since the + // conversation last moved" — `last_event_at` also refreshes on + // status rows, tool blocks, and daemon-restart resume plumbing. + // Sessions with no messages at all (plain shells) fall back to the + // last recorded event. segs.push(( format!( "{} ago", @@ -18139,6 +18144,14 @@ mod tests { let segs = session_detail_segments(&s, 7_200_000); assert_eq!(segs[0].0, "2h ago"); + // With a message timestamp present, the age tracks the MESSAGE, not + // the newer status/tool event — so restart resume plumbing (which + // refreshes `last_event_at`) doesn't snap the age back to zero. + s.last_message_at = Some(chrono::Utc.timestamp_millis_opt(0).unwrap()); + s.last_event_at = Some(chrono::Utc.timestamp_millis_opt(7_100_000).unwrap()); + let segs = session_detail_segments(&s, 7_200_000); + assert_eq!(segs[0].0, "2h ago"); + // A session reporting no model/usage data at all (a plain shell) // falls back to where it lives. let mut sh = lineage_test_summary("sh"); @@ -19141,6 +19154,7 @@ mod tests { state, created_at: chrono::Utc::now(), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index ba013e9e..50059382 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -1541,12 +1541,13 @@ impl SessionManager { // context gauge (spec 0104) restores from the LAST report seen, // and a Reset along the way clears it — mirroring the live fold. let path = storage.transcript_path(&s.id); - let (count, message_count, tokens, context) = if path.exists() { + let (count, message_count, last_message_at, tokens, context) = if path.exists() { let f = std::fs::File::open(&path)?; let reader = std::io::BufReader::new(f); use std::io::BufRead; let mut n = 0u64; let mut msgs = 0u64; + let mut last_msg_at: Option> = None; let mut tally = construct_protocol::TokenTally::default(); let mut context: (Option, Option) = (None, None); for line in reader.lines() { @@ -1559,7 +1560,10 @@ impl SessionManager { serde_json::from_str::(&line) { match ts.event { - SessionEvent::Message { .. } => msgs += 1, + SessionEvent::Message { .. } => { + msgs += 1; + last_msg_at = Some(ts.at); + } SessionEvent::Cost { tokens_in, tokens_out, @@ -1580,12 +1584,19 @@ impl SessionManager { } } } - (n, msgs, tally, context) + (n, msgs, last_msg_at, tally, context) } else { - (0, 0, construct_protocol::TokenTally::default(), (None, None)) + ( + 0, + 0, + None, + construct_protocol::TokenTally::default(), + (None, None), + ) }; let mut s = s; s.message_count = message_count; + s.last_message_at = last_message_at; s.tokens = tokens; s.context_used = context.0; s.context_window = context.1; @@ -4922,6 +4933,7 @@ impl SessionManager { state: construct_protocol::SessionState::Done, created_at: now, last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, @@ -5587,6 +5599,7 @@ mod tests { state: SessionState::Running, created_at: "2026-06-17T00:00:00Z".parse().expect("timestamp"), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, @@ -6102,6 +6115,7 @@ mod tests { state: SessionState::Running, created_at: Utc::now(), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, @@ -8100,6 +8114,7 @@ mod tests { state: SessionState::Running, created_at: Utc::now(), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, @@ -8247,6 +8262,7 @@ mod tests { state: SessionState::AwaitingInput, created_at: Utc::now(), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, @@ -9376,10 +9392,18 @@ mod tests { text: "even more".into(), }, ]; + // Distinct timestamps per event: the last MESSAGE is at index 2, + // while later Reasoning events carry newer stamps. + let base = Utc::now(); + let mut last_message_stamp = None; for (i, event) in events.into_iter().enumerate() { + let at = base + chrono::Duration::seconds(i as i64); + if matches!(event, construct_protocol::SessionEvent::Message { .. }) { + last_message_stamp = Some(at); + } let ts = construct_protocol::TimestampedEvent { seq: i as u64 + 1, - at: Utc::now(), + at, event, }; storage.append_event("recount", &ts).expect("append event"); @@ -9398,6 +9422,11 @@ mod tests { .summary() .await; assert_eq!(loaded.message_count, 2, "only Message events count"); + assert_eq!( + loaded.last_message_at, last_message_stamp, + "last_message_at restores from the newest Message event's own \ + timestamp — the trailing Reasoning events don't move it" + ); } /// `SessionManager::search` must use the live, in-memory session list diff --git a/crates/daemon/src/session/events.rs b/crates/daemon/src/session/events.rs index 5e861039..04f2638b 100644 --- a/crates/daemon/src/session/events.rs +++ b/crates/daemon/src/session/events.rs @@ -56,6 +56,7 @@ impl SessionManager { s.last_event_at = Some(now); s.event_count = 0; s.message_count = 0; + s.last_message_at = None; s.tokens = Default::default(); s.context_used = None; s.context_window = None; @@ -396,6 +397,7 @@ impl SessionManager { s.event_count = seq; if matches!(&event, SessionEvent::Message { .. }) { s.message_count = s.message_count.saturating_add(1); + s.last_message_at = Some(now); } let prev_state = s.state; match &event { @@ -631,6 +633,7 @@ impl SessionManager { state, created_at: now, last_event_at: None, + last_message_at: None, cost_usd: None, model: owner_summary.model.clone(), effort: owner_summary.effort.clone(), diff --git a/crates/daemon/src/session/lifecycle.rs b/crates/daemon/src/session/lifecycle.rs index 00fe2945..5a96bae2 100644 --- a/crates/daemon/src/session/lifecycle.rs +++ b/crates/daemon/src/session/lifecycle.rs @@ -72,6 +72,7 @@ impl SessionManager { state: SessionState::Pending, created_at: now, last_event_at: None, + last_message_at: None, cost_usd: None, model: params.model.clone(), effort: None, diff --git a/crates/daemon/src/storage.rs b/crates/daemon/src/storage.rs index ec8408f6..3115c5b0 100644 --- a/crates/daemon/src/storage.rs +++ b/crates/daemon/src/storage.rs @@ -2469,6 +2469,7 @@ mod search_tests { state: SessionState::Running, created_at: at, last_event_at: Some(at), + last_message_at: None, cost_usd: None, model: None, effort: None, diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 5ba7e842..c5691294 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -2214,6 +2214,7 @@ mod tests { state: SessionState::Running, created_at: "2026-05-24T00:00:00Z".parse().expect("timestamp"), last_event_at: None, + last_message_at: None, cost_usd: None, model: None, effort: None, diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 041549e0..eccddac7 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -2006,6 +2006,14 @@ pub struct SessionSummary { pub created_at: chrono::DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_event_at: Option>, + /// When the most recent chat `Message` event persisted — unlike + /// `last_event_at`, which every transcript event refreshes (status rows, + /// tool blocks, usage reports, resume plumbing), this only moves on + /// actual conversation. Restored from the transcript at load so it + /// survives daemon restarts and self-heals for sessions recorded before + /// the field existed. `None` for sessions with no messages. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_message_at: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cost_usd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/specs/0106-session-list-view-modes.md b/specs/0106-session-list-view-modes.md index 5ba5cede..05df6989 100644 --- a/specs/0106-session-list-view-modes.md +++ b/specs/0106-session-list-view-modes.md @@ -15,7 +15,10 @@ The session list renders in one of two user-selectable view modes: - **Full**: the compact line plus a muted second detail line aligned under the name, showing (in display order) the model and reasoning effort, a small context-window gauge with percentage, current activity (live busy time while - running, coarse age since last activity otherwise), and lifetime token + running; otherwise a coarse age since the last chat message, so status + rows, tool blocks, and daemon-restart resume events do not reset it — + sessions with no messages fall back to the last recorded event), and + lifetime token volume. Cost is deliberately excluded. The gauge's fill rounds to the nearest step so the bar tracks the percentage (just over half reads as half, not three quarters).