返回 CodeWhale
session_projection.rs
根目录 / crates / tui / src / session_projection.rs
1 //! One projection of durable saved sessions, shared by every surface that
2 //! browses them (#2934 / #4397).
3 //!
4 //! Before this module the TUI session picker and the Runtime API answered
5 //! "what sessions are there?" with two different shapes: the picker filtered,
6 //! sorted, and fuzzy-matched [`SessionMetadata`] in place, while
7 //! `GET /v1/sessions` returned an unfiltered, unsorted metadata dump with an
8 //! ad-hoc substring search. That divergence is exactly what the web dashboard
9 //! could not consume, and it is what made "the rail says one thing, the
10 //! dashboard says another" possible.
11 //!
12 //! Everything here is pure and offline:
13 //!
14 //! * no provider or network call — browsing and resuming must never talk to a
15 //! model, so a projection is computed from already-read metadata only;
16 //! * no session-file reads — [`SessionSummary`] is built from
17 //! [`SessionMetadata`], which the manager already extracts from a bounded
18 //! 64 KB prefix. A rail that re-read every transcript on every render would
19 //! be a per-keystroke I/O storm on a 50-session store.
20 //!
21 //! The consequence is deliberate and worth stating plainly: a summary's
22 //! `preview` is the session's own title, not its last message. Session
23 //! metadata does not record a last message, and inventing one by reading N
24 //! transcripts per frame would trade a truthful cheap row for an expensive
25 //! one. Full transcript preview stays where it already works — the session
26 //! picker, which reads one selected session and caches it.
27
28 use std::path::{Path, PathBuf};
29
30 use chrono::{DateTime, Utc};
31 use serde::Serialize;
32
33 use crate::session_manager::{SessionListFilter, SessionMetadata, workspace_scope_matches};
34
35 /// Maximum rows any single projection returns. Bounds the sidebar rail, the
36 /// `/v1/sessions/summary` response, and search results with one number so a
37 /// user with hundreds of sessions cannot make a surface unbounded.
38 pub const MAX_PROJECTED_SESSIONS: usize = 500;
39
40 /// Ordering for a session listing.
41 ///
42 /// The same three modes the picker has always cycled with `s`; naming them
43 /// here lets the Runtime API offer the identical ordering instead of relying
44 /// on whatever order `read_dir` happened to produce.
45 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46 pub enum SessionSortMode {
47 /// Most recently updated first.
48 #[default]
49 Recent,
50 /// Title, ascending.
51 Name,
52 /// Message count, descending.
53 Size,
54 }
55
56 impl SessionSortMode {
57 /// Parse a wire/config value. Unknown values fall back to `Recent` rather
58 /// than erroring, so a stale client cannot break a listing.
59 #[must_use]
60 pub fn from_str_or_recent(value: &str) -> Self {
61 match value.trim().to_ascii_lowercase().as_str() {
62 "name" | "title" | "alpha" => Self::Name,
63 "size" | "messages" | "length" => Self::Size,
64 _ => Self::Recent,
65 }
66 }
67
68 /// Advance to the next mode in the picker's cycle order.
69 #[must_use]
70 pub fn next(self) -> Self {
71 match self {
72 Self::Recent => Self::Name,
73 Self::Name => Self::Size,
74 Self::Size => Self::Recent,
75 }
76 }
77 }
78
79 /// What a browse surface is asking for.
80 #[derive(Debug, Clone)]
81 pub struct SessionQuery {
82 /// Archive state to include. Defaults to active-only.
83 pub filter: SessionListFilter,
84 pub sort: SessionSortMode,
85 /// Fuzzy query over title, id, and workspace. Empty means "no filter".
86 pub search: String,
87 /// When `Some`, only sessions recorded against an equivalent workspace
88 /// are returned. `None` means the caller deliberately opted out of
89 /// scoping (the picker's `a` toggle, or an API caller that asked for
90 /// every workspace).
91 pub workspace_scope: Option<PathBuf>,
92 /// Hard row cap, clamped to [`MAX_PROJECTED_SESSIONS`].
93 pub limit: usize,
94 }
95
96 impl Default for SessionQuery {
97 fn default() -> Self {
98 Self {
99 filter: SessionListFilter::ActiveOnly,
100 sort: SessionSortMode::Recent,
101 search: String::new(),
102 workspace_scope: None,
103 limit: MAX_PROJECTED_SESSIONS,
104 }
105 }
106 }
107
108 impl SessionQuery {
109 /// Scope the query to one workspace.
110 #[must_use]
111 pub fn scoped_to(mut self, workspace: &Path) -> Self {
112 self.workspace_scope = Some(workspace.to_path_buf());
113 self
114 }
115
116 #[must_use]
117 pub fn with_limit(mut self, limit: usize) -> Self {
118 self.limit = limit;
119 self
120 }
121
122 #[must_use]
123 pub fn with_search(mut self, search: impl Into<String>) -> Self {
124 self.search = search.into();
125 self
126 }
127
128 #[must_use]
129 pub fn with_filter(mut self, filter: SessionListFilter) -> Self {
130 self.filter = filter;
131 self
132 }
133
134 #[must_use]
135 pub fn with_sort(mut self, sort: SessionSortMode) -> Self {
136 self.sort = sort;
137 self
138 }
139 }
140
141 /// One durable session, projected for display.
142 ///
143 /// Field names deliberately mirror `ThreadSummary` in
144 /// [`crate::runtime_api`] (`id`, `title`, `preview`, `model`, `mode`,
145 /// `workspace`, `archived`, `updated_at`) so the embedded dashboard can render
146 /// a saved session and a live thread with the same row code, and so a reader
147 /// comparing the two payloads sees one vocabulary rather than two.
148 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
149 pub struct SessionSummary {
150 pub id: String,
151 pub title: String,
152 /// Bounded preview text. See the module docs: this is the session title,
153 /// never a fabricated "last message" the metadata does not record.
154 pub preview: String,
155 pub model: String,
156 pub mode: String,
157 pub workspace: PathBuf,
158 pub archived: bool,
159 pub message_count: usize,
160 pub total_tokens: u64,
161 pub updated_at: DateTime<Utc>,
162 pub created_at: DateTime<Utc>,
163 /// Set when this session was created by `/fork`, so lineage is visible
164 /// without opening the session.
165 pub parent_session_id: Option<String>,
166 /// True when this row is the session the calling surface currently has
167 /// loaded. Computed by the caller passing its active session id; never
168 /// inferred from disk state.
169 pub is_current: bool,
170 }
171
172 /// Longest preview/title a projected row carries.
173 const MAX_SUMMARY_TEXT: usize = 140;
174
175 impl SessionSummary {
176 fn from_metadata(metadata: &SessionMetadata, current_session_id: Option<&str>) -> Self {
177 let title = bounded(&metadata.title, MAX_SUMMARY_TEXT);
178 Self {
179 preview: title.clone(),
180 id: metadata.id.clone(),
181 title,
182 model: metadata.model.clone(),
183 mode: metadata.mode.clone().unwrap_or_else(|| "agent".to_string()),
184 workspace: metadata.workspace.clone(),
185 archived: metadata.archived,
186 message_count: metadata.message_count,
187 total_tokens: metadata.total_tokens,
188 updated_at: metadata.updated_at,
189 created_at: metadata.created_at,
190 parent_session_id: metadata.parent_session_id.clone(),
191 is_current: current_session_id.is_some_and(|id| id == metadata.id),
192 }
193 }
194 }
195
196 fn bounded(text: &str, max_chars: usize) -> String {
197 let trimmed = text.trim();
198 if trimmed.is_empty() {
199 return "Untitled session".to_string();
200 }
201 if trimmed.chars().count() <= max_chars {
202 return trimmed.to_string();
203 }
204 let kept: String = trimmed.chars().take(max_chars.saturating_sub(1)).collect();
205 format!("{kept}…")
206 }
207
208 /// Filter, sort, and bound a metadata list — the single selection seam.
209 ///
210 /// Every browse surface funnels through this: the TUI session picker's
211 /// filtered list, the sidebar rail, and both `/v1/sessions` routes. Returning
212 /// borrowed metadata (rather than only [`SessionSummary`]) is what lets the
213 /// picker use it — the picker renders from `SessionMetadata`, and giving it a
214 /// summary-only API is exactly what pushed it into keeping a private
215 /// filter/sort in the first place.
216 ///
217 /// `sessions` is whatever [`crate::session_manager::SessionManager::list_sessions`]
218 /// returned; this function never touches the filesystem.
219 #[must_use]
220 pub fn select_sessions<'a>(
221 sessions: &'a [SessionMetadata],
222 query: &SessionQuery,
223 ) -> Vec<&'a SessionMetadata> {
224 let mut matched: Vec<&SessionMetadata> = sessions
225 .iter()
226 .filter(|session| query.filter.admits(session.archived))
227 .filter(|session| matches_workspace_scope(session, query.workspace_scope.as_deref()))
228 .filter(|session| session_matches_query(&query.search, session))
229 .collect();
230
231 match query.sort {
232 // Ties break on id in every mode so a listing is stable across
233 // processes rather than inheriting directory-read order. Two surfaces
234 // that sort "the same way" but tie-break differently are two backends.
235 SessionSortMode::Recent => matched.sort_by(|a, b| {
236 b.updated_at
237 .cmp(&a.updated_at)
238 .then_with(|| a.id.cmp(&b.id))
239 }),
240 SessionSortMode::Name => {
241 matched.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)))
242 }
243 SessionSortMode::Size => matched.sort_by(|a, b| {
244 b.message_count
245 .cmp(&a.message_count)
246 .then_with(|| b.updated_at.cmp(&a.updated_at))
247 .then_with(|| a.id.cmp(&b.id))
248 }),
249 }
250
251 matched.truncate(query.limit.min(MAX_PROJECTED_SESSIONS));
252 matched
253 }
254
255 /// [`select_sessions`], projected into display rows.
256 #[must_use]
257 pub fn project_sessions(
258 sessions: &[SessionMetadata],
259 query: &SessionQuery,
260 current_session_id: Option<&str>,
261 ) -> Vec<SessionSummary> {
262 select_sessions(sessions, query)
263 .into_iter()
264 .map(|metadata| SessionSummary::from_metadata(metadata, current_session_id))
265 .collect()
266 }
267
268 /// Does this session belong to `scope`?
269 ///
270 /// `None` means the caller opted out of scoping. Matching reuses
271 /// [`workspace_scope_matches`], so a worktree and its repository root are
272 /// treated the same way the resume path already treats them — the rail cannot
273 /// disagree with what `--continue` would pick.
274 #[must_use]
275 pub fn matches_workspace_scope(session: &SessionMetadata, scope: Option<&Path>) -> bool {
276 match scope {
277 None => true,
278 Some(scope) => workspace_scope_matches(&session.workspace, scope),
279 }
280 }
281
282 /// Fuzzy match over title, id, and workspace.
283 ///
284 /// Case-insensitive substring first, then subsequence. This is the session
285 /// picker's historical `fuzzy_match` behaviour, lifted here verbatim so the
286 /// picker, the rail, and `GET /v1/sessions?search=` cannot disagree about
287 /// what "matches". An empty or whitespace-only query matches everything.
288 #[must_use]
289 pub fn session_matches_query(query: &str, session: &SessionMetadata) -> bool {
290 let query = query.trim().to_ascii_lowercase();
291 if query.is_empty() {
292 return true;
293 }
294 let haystack = format!(
295 "{} {} {}",
296 session.title,
297 session.id,
298 session.workspace.display()
299 )
300 .to_ascii_lowercase();
301 if haystack.contains(&query) {
302 return true;
303 }
304 is_subsequence(&query, &haystack)
305 }
306
307 fn is_subsequence(needle: &str, haystack: &str) -> bool {
308 let mut chars = needle.chars();
309 let mut current = match chars.next() {
310 Some(c) => c,
311 None => return true,
312 };
313 for ch in haystack.chars() {
314 if ch == current {
315 match chars.next() {
316 Some(next) => current = next,
317 None => return true,
318 }
319 }
320 }
321 false
322 }
323
324 #[cfg(test)]
325 mod tests {
326 use super::*;
327 use chrono::Duration;
328
329 fn metadata(id: &str, title: &str, workspace: &str, minutes_ago: i64) -> SessionMetadata {
330 let ts = Utc::now() - Duration::minutes(minutes_ago);
331 SessionMetadata {
332 id: id.to_string(),
333 title: title.to_string(),
334 created_at: ts,
335 updated_at: ts,
336 message_count: title.len(),
337 total_tokens: 0,
338 model: "deepseek-chat".to_string(),
339 model_provider: "deepseek".to_string(),
340 model_provider_id: None,
341 workspace: PathBuf::from(workspace),
342 mode: Some("agent".to_string()),
343 cost: Default::default(),
344 parent_session_id: None,
345 forked_from_message_count: None,
346 cumulative_turn_secs: 0,
347 archived: false,
348 }
349 }
350
351 #[test]
352 fn recent_sort_puts_newest_first_and_marks_the_current_row() {
353 let sessions = vec![
354 metadata("old", "Older work", "/repo", 120),
355 metadata("new", "Newer work", "/repo", 5),
356 ];
357 let rows = project_sessions(&sessions, &SessionQuery::default(), Some("old"));
358
359 assert_eq!(
360 rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
361 vec!["new", "old"]
362 );
363 assert!(!rows[0].is_current);
364 assert!(rows[1].is_current);
365 }
366
367 #[test]
368 fn archived_sessions_are_hidden_until_explicitly_requested() {
369 let mut archived = metadata("gone", "Archived work", "/repo", 1);
370 archived.archived = true;
371 let sessions = vec![archived, metadata("live", "Live work", "/repo", 2)];
372
373 let active = project_sessions(&sessions, &SessionQuery::default(), None);
374 assert_eq!(
375 active.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
376 vec!["live"]
377 );
378
379 let all = project_sessions(
380 &sessions,
381 &SessionQuery::default().with_filter(SessionListFilter::IncludeArchived),
382 None,
383 );
384 assert_eq!(all.len(), 2);
385
386 let only = project_sessions(
387 &sessions,
388 &SessionQuery::default().with_filter(SessionListFilter::ArchivedOnly),
389 None,
390 );
391 assert_eq!(
392 only.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
393 vec!["gone"]
394 );
395 }
396
397 #[test]
398 fn workspace_scope_excludes_other_projects_and_none_opts_out() {
399 let sessions = vec![
400 metadata("here", "Here", "/repo-a", 1),
401 metadata("there", "There", "/repo-b", 2),
402 ];
403 let scoped = project_sessions(
404 &sessions,
405 &SessionQuery::default().scoped_to(Path::new("/repo-a")),
406 None,
407 );
408 assert_eq!(
409 scoped.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
410 vec!["here"]
411 );
412
413 let unscoped = project_sessions(&sessions, &SessionQuery::default(), None);
414 assert_eq!(unscoped.len(), 2);
415 }
416
417 #[test]
418 fn search_matches_substring_and_subsequence_over_title_id_and_workspace() {
419 let sessions = vec![metadata("abc123", "Whale migration notes", "/repo-a", 1)];
420
421 for query in ["whale", "WHALE", "abc1", "repo-a", "wmn"] {
422 let rows =
423 project_sessions(&sessions, &SessionQuery::default().with_search(query), None);
424 assert_eq!(rows.len(), 1, "query {query} should match");
425 }
426
427 let rows = project_sessions(
428 &sessions,
429 &SessionQuery::default().with_search("zzzz"),
430 None,
431 );
432 assert!(rows.is_empty());
433 }
434
435 #[test]
436 fn sort_modes_are_deterministic_across_equal_keys() {
437 let mut a = metadata("aaa", "Same title", "/repo", 1);
438 let mut b = metadata("bbb", "Same title", "/repo", 1);
439 a.message_count = 4;
440 b.message_count = 4;
441 let sessions = vec![b, a];
442
443 let by_name = project_sessions(
444 &sessions,
445 &SessionQuery::default().with_sort(SessionSortMode::Name),
446 None,
447 );
448 assert_eq!(
449 by_name.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
450 vec!["aaa", "bbb"]
451 );
452 }
453
454 #[test]
455 fn size_sort_orders_by_message_count_descending() {
456 let mut small = metadata("small", "Small", "/repo", 1);
457 let mut large = metadata("large", "Large", "/repo", 2);
458 small.message_count = 2;
459 large.message_count = 40;
460
461 let rows = project_sessions(
462 &[small, large],
463 &SessionQuery::default().with_sort(SessionSortMode::Size),
464 None,
465 );
466 assert_eq!(
467 rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
468 vec!["large", "small"]
469 );
470 }
471
472 #[test]
473 fn limit_is_bounded_by_the_projection_cap() {
474 let sessions: Vec<SessionMetadata> = (0..20)
475 .map(|i| metadata(&format!("s{i}"), &format!("Session {i}"), "/repo", i))
476 .collect();
477
478 let rows = project_sessions(&sessions, &SessionQuery::default().with_limit(5), None);
479 assert_eq!(rows.len(), 5);
480
481 let capped = project_sessions(
482 &sessions,
483 &SessionQuery::default().with_limit(usize::MAX),
484 None,
485 );
486 assert_eq!(capped.len(), 20);
487 }
488
489 #[test]
490 fn preview_is_the_title_and_never_a_fabricated_last_message() {
491 let sessions = vec![metadata("s", "Refactor the lane registry", "/repo", 1)];
492 let rows = project_sessions(&sessions, &SessionQuery::default(), None);
493 assert_eq!(rows[0].preview, "Refactor the lane registry");
494 assert_eq!(rows[0].preview, rows[0].title);
495 }
496
497 #[test]
498 fn long_titles_are_bounded_and_blank_titles_get_a_stable_label() {
499 let long = "x".repeat(400);
500 let sessions = vec![
501 metadata("s", &long, "/repo", 1),
502 metadata("b", " ", "/repo", 2),
503 ];
504 let rows = project_sessions(&sessions, &SessionQuery::default(), None);
505
506 let long_row = rows.iter().find(|r| r.id == "s").expect("long row");
507 assert_eq!(long_row.title.chars().count(), MAX_SUMMARY_TEXT);
508 assert!(long_row.title.ends_with('…'));
509
510 let blank_row = rows.iter().find(|r| r.id == "b").expect("blank row");
511 assert_eq!(blank_row.title, "Untitled session");
512 }
513
514 #[test]
515 fn every_wire_alias_parses_and_unknown_values_fall_back_to_recent() {
516 for value in ["recent", "", " ", "nonsense"] {
517 assert_eq!(
518 SessionSortMode::from_str_or_recent(value),
519 SessionSortMode::Recent
520 );
521 }
522 for value in ["name", "Title", " ALPHA "] {
523 assert_eq!(
524 SessionSortMode::from_str_or_recent(value),
525 SessionSortMode::Name
526 );
527 }
528 for value in ["size", "messages", "length"] {
529 assert_eq!(
530 SessionSortMode::from_str_or_recent(value),
531 SessionSortMode::Size
532 );
533 }
534 }
535
536 #[test]
537 fn the_sort_cycle_visits_every_mode_and_returns_to_the_start() {
538 let mut mode = SessionSortMode::Recent;
539 let mut seen = vec![mode];
540 for _ in 0..3 {
541 mode = mode.next();
542 seen.push(mode);
543 }
544 assert_eq!(
545 seen,
546 vec![
547 SessionSortMode::Recent,
548 SessionSortMode::Name,
549 SessionSortMode::Size,
550 SessionSortMode::Recent
551 ]
552 );
553 }
554
555 #[test]
556 fn list_filter_resolves_the_same_query_pair_as_threads() {
557 assert_eq!(
558 SessionListFilter::from_query(None, None),
559 SessionListFilter::ActiveOnly
560 );
561 assert_eq!(
562 SessionListFilter::from_query(Some(true), None),
563 SessionListFilter::IncludeArchived
564 );
565 assert_eq!(
566 SessionListFilter::from_query(Some(true), Some(true)),
567 SessionListFilter::ArchivedOnly
568 );
569 assert_eq!(
570 SessionListFilter::from_query(None, Some(true)),
571 SessionListFilter::ArchivedOnly
572 );
573 }
574 }
575
575 lines RUST