Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/app/lineage_section.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/app/session_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/lineage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 15 additions & 1 deletion crates/cli/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 34 additions & 5 deletions crates/daemon/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<chrono::DateTime<chrono::Utc>> = None;
let mut tally = construct_protocol::TokenTally::default();
let mut context: (Option<u64>, Option<u64>) = (None, None);
for line in reader.lines() {
Expand All @@ -1559,7 +1560,10 @@ impl SessionManager {
serde_json::from_str::<construct_protocol::TimestampedEvent>(&line)
{
match ts.event {
SessionEvent::Message { .. } => msgs += 1,
SessionEvent::Message { .. } => {
msgs += 1;
last_msg_at = Some(ts.at);
}
SessionEvent::Cost {
tokens_in,
tokens_out,
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions crates/daemon/src/session/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions crates/daemon/src/session/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/daemon/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions crates/protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2006,6 +2006,14 @@ pub struct SessionSummary {
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_event_at: Option<chrono::DateTime<chrono::Utc>>,
/// 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<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down
5 changes: 4 additions & 1 deletion specs/0106-session-list-view-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading