1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
//! The persistent Sessions rail (#2934).
//!
//! A bounded, workspace-scoped list of recent sessions that lives in the
//! sidebar panel stack alongside Work, Activity, Agents, and Context. It is a
//! *jump* affordance, not a second session browser: every row hands off to the
//! existing [`crate::tui::session_picker::SessionPickerView`], which already
//! owns preview, search, sort, rename, archive, delete, workspace-scope
//! toggling, and the resume contract. Duplicating any of that here would give
//! us two behaviours to keep in sync and one of them would eventually lie.
//!
//! Two properties this module exists to guarantee:
//!
//! * **No filesystem work per frame.** Rows are read once into a
//! [`SessionsRailCache`] and reused until the TTL expires or a session
//! lifecycle event invalidates them. A sidebar that re-listed a 50-session
//! directory on every keystroke would be a visible stall.
//! * **No provider or network call.** Browsing sessions is offline by
//! construction — the rail only ever reads already-persisted metadata.
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::session_manager::{SessionListFilter, SessionManager, SessionMetadata};
use crate::session_projection::{
DEFAULT_RAIL_ROWS, SessionQuery, SessionSortMode, SessionSummary, count_sessions,
project_sessions,
};
/// How long cached rows stay warm. Long enough that scrolling and typing never
/// hit the disk, short enough that a session saved by this process or another
/// one shows up without the user doing anything.
pub const RAIL_CACHE_TTL: Duration = Duration::from_secs(20);
/// Rail rows plus the inputs they were computed from.
///
/// The inputs are stored so a workspace change or a different active session
/// invalidates the cache on its own, without every call site having to
/// remember to clear it.
#[derive(Debug, Clone)]
pub struct SessionsRailCache {
rows: Vec<SessionSummary>,
workspace: PathBuf,
current_session_id: Option<String>,
/// Total sessions in scope before the row cap, so the rail can say "8 of
/// 31" rather than implying it is showing everything.
total_in_scope: usize,
/// Row budget this cache was built for. A resized sidebar changes the
/// budget, so it participates in freshness — otherwise a grown panel would
/// render short rows until the TTL happened to lapse.
max_rows: usize,
read_at: Instant,
/// Set when the sessions directory could not be read. The rail renders
/// this instead of an empty list, so a permissions or path problem is not
/// silently displayed as "you have no sessions".
error: Option<String>,
}
impl SessionsRailCache {
#[must_use]
pub fn rows(&self) -> &[SessionSummary] {
&self.rows
}
#[must_use]
pub fn total_in_scope(&self) -> usize {
self.total_in_scope
}
#[must_use]
pub fn error(&self) -> Option<&str> {
self.error.as_deref()
}
/// Is this cache still usable for the given surface state?
#[must_use]
pub fn is_fresh(
&self,
workspace: &Path,
current_session_id: Option<&str>,
max_rows: usize,
now: Instant,
) -> bool {
self.workspace == workspace
&& self.current_session_id.as_deref() == current_session_id
&& self.max_rows == max_rows
&& now.duration_since(self.read_at) < RAIL_CACHE_TTL
}
}
/// Build rail rows from an already-loaded metadata list.
///
/// Split out from [`load_rail_cache`] so the projection is testable without a
/// sessions directory, and so the rail and the picker demonstrably agree: both
/// go through [`project_sessions`].
#[must_use]
pub fn build_rail_cache(
sessions: &[SessionMetadata],
workspace: &Path,
current_session_id: Option<&str>,
max_rows: usize,
now: Instant,
) -> SessionsRailCache {
let scoped = SessionQuery::default()
.with_filter(SessionListFilter::ActiveOnly)
.with_sort(SessionSortMode::Recent)
.scoped_to(workspace);
// Count the full scoped set without building rows. Projecting with a huge
// limit would silently re-clamp at `MAX_PROJECTED_SESSIONS` and under-report
// the total, which is the one number this footer exists to tell the truth
// about.
let total_in_scope = count_sessions(sessions, &scoped);
let rows = project_sessions(sessions, &scoped.with_limit(max_rows), current_session_id);
SessionsRailCache {
rows,
workspace: workspace.to_path_buf(),
current_session_id: current_session_id.map(str::to_string),
total_in_scope,
max_rows,
read_at: now,
error: None,
}
}
/// Read the sessions directory and project rail rows.
///
/// Errors are captured into the cache rather than returned: a rail that cannot
/// read the store should say so once and keep rendering, not propagate a
/// failure into the render loop.
#[must_use]
pub fn load_rail_cache(
workspace: &Path,
current_session_id: Option<&str>,
max_rows: usize,
) -> SessionsRailCache {
let now = Instant::now();
let listed = SessionManager::default_location().and_then(|manager| manager.list_sessions());
match listed {
Ok(sessions) => build_rail_cache(&sessions, workspace, current_session_id, max_rows, now),
Err(err) => SessionsRailCache {
rows: Vec::new(),
workspace: workspace.to_path_buf(),
current_session_id: current_session_id.map(str::to_string),
total_in_scope: 0,
max_rows,
read_at: now,
error: Some(err.to_string()),
},
}
}
/// The command a rail row dispatches when activated.
///
/// Opening the picker preselected on the row — rather than resuming inline —
/// is deliberate. Resume has draft, unsaved-work, and confirmation semantics
/// that the picker already implements; a rail row that resumed directly would
/// either re-implement them or quietly skip them.
#[must_use]
pub fn row_command(session_id: &str) -> String {
format!("/sessions open {session_id}")
}
/// The command the rail's footer row dispatches: the full session browser.
#[must_use]
pub fn browse_all_command() -> &'static str {
"/sessions list"
}
/// Default row budget for a sidebar of `height` rows.
///
/// Narrow and short terminals get fewer rows rather than a clipped panel; the
/// footer still reports the full in-scope count so nothing is silently
/// dropped.
#[must_use]
pub fn rows_for_height(height: u16) -> usize {
// Two rows of chrome (border + title) and one footer row.
let usable = usize::from(height).saturating_sub(3);
usable.clamp(1, DEFAULT_RAIL_ROWS)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn metadata(id: &str, title: &str, workspace: &Path, minutes_ago: i64) -> SessionMetadata {
let ts = Utc::now() - chrono::Duration::minutes(minutes_ago);
SessionMetadata {
id: id.to_string(),
title: title.to_string(),
created_at: ts,
updated_at: ts,
message_count: 3,
total_tokens: 10,
model: "deepseek-chat".to_string(),
model_provider: "deepseek".to_string(),
model_provider_id: None,
workspace: workspace.to_path_buf(),
mode: Some("agent".to_string()),
cost: Default::default(),
parent_session_id: None,
forked_from_message_count: None,
cumulative_turn_secs: 0,
archived: false,
}
}
#[test]
fn rail_is_scoped_to_the_workspace_and_capped() {
let here = PathBuf::from("/repo-a");
let there = PathBuf::from("/repo-b");
let mut sessions: Vec<SessionMetadata> = (0..12)
.map(|i| metadata(&format!("a{i}"), &format!("Here {i}"), &here, i))
.collect();
sessions.push(metadata("elsewhere", "There", &there, 1));
let cache = build_rail_cache(&sessions, &here, None, 5, Instant::now());
assert_eq!(cache.rows().len(), 5);
assert_eq!(cache.total_in_scope(), 12);
assert!(
cache.rows().iter().all(|row| row.workspace == here),
"rail must not leak another workspace's sessions"
);
}
#[test]
fn archived_sessions_never_appear_in_the_rail() {
let here = PathBuf::from("/repo");
let mut archived = metadata("gone", "Archived", &here, 1);
archived.archived = true;
let sessions = vec![archived, metadata("live", "Live", &here, 2)];
let cache = build_rail_cache(&sessions, &here, None, 8, Instant::now());
assert_eq!(
cache
.rows()
.iter()
.map(|r| r.id.as_str())
.collect::<Vec<_>>(),
vec!["live"]
);
assert_eq!(cache.total_in_scope(), 1);
}
#[test]
fn the_active_session_is_marked_current() {
let here = PathBuf::from("/repo");
let sessions = vec![metadata("a", "A", &here, 1), metadata("b", "B", &here, 2)];
let cache = build_rail_cache(&sessions, &here, Some("b"), 8, Instant::now());
let current: Vec<&str> = cache
.rows()
.iter()
.filter(|r| r.is_current)
.map(|r| r.id.as_str())
.collect();
assert_eq!(current, vec!["b"]);
}
#[test]
fn cache_expires_on_ttl_workspace_session_or_row_budget_change() {
let here = PathBuf::from("/repo");
let now = Instant::now();
let cache = build_rail_cache(&[metadata("a", "A", &here, 1)], &here, Some("a"), 8, now);
assert!(cache.is_fresh(&here, Some("a"), 8, now));
assert!(
!cache.is_fresh(
&here,
Some("a"),
8,
now + RAIL_CACHE_TTL + Duration::from_secs(1)
),
"TTL must expire the cache"
);
assert!(
!cache.is_fresh(Path::new("/other"), Some("a"), 8, now),
"a workspace change must invalidate the cache"
);
assert!(
!cache.is_fresh(&here, Some("b"), 8, now),
"switching sessions must invalidate the cache"
);
assert!(
!cache.is_fresh(&here, Some("a"), 4, now),
"a resized sidebar changes the row budget and must invalidate the cache"
);
}
#[test]
fn row_activation_opens_the_picker_rather_than_resuming_inline() {
assert_eq!(row_command("abc123"), "/sessions open abc123");
assert_eq!(browse_all_command(), "/sessions list");
}
#[test]
fn narrow_and_short_sidebars_get_fewer_rows_but_never_zero() {
assert_eq!(rows_for_height(0), 1);
assert_eq!(rows_for_height(4), 1);
assert_eq!(rows_for_height(7), 4);
assert_eq!(rows_for_height(40), DEFAULT_RAIL_ROWS);
}
}