返回 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 /// Exclude empty auto-created sessions (zero messages under the default
93 /// title) — the same definition the launch list and `--continue` apply
94 /// (#6014). Off by default so API listings stay complete; browse
95 /// surfaces that exist to pick *resumable* work set it.
96 pub hide_empty_auto_created: bool,
97 /// Hard row cap, clamped to [`MAX_PROJECTED_SESSIONS`].
98 pub limit: usize,
99 }
100
101 impl Default for SessionQuery {
102 fn default() -> Self {
103 Self {
104 filter: SessionListFilter::ActiveOnly,
105 sort: SessionSortMode::Recent,
106 search: String::new(),
107 workspace_scope: None,
108 hide_empty_auto_created: false,
109 limit: MAX_PROJECTED_SESSIONS,
110 }
111 }
112 }
113
114 impl SessionQuery {
115 /// Scope the query to one workspace.
116 #[must_use]
117 pub fn scoped_to(mut self, workspace: &Path) -> Self {
118 self.workspace_scope = Some(workspace.to_path_buf());
119 self
120 }
121
122 #[must_use]
123 pub fn with_limit(mut self, limit: usize) -> Self {
124 self.limit = limit;
125 self
126 }
127
128 #[must_use]
129 pub fn with_search(mut self, search: impl Into<String>) -> Self {
130 self.search = search.into();
131 self
132 }
133
134 #[must_use]
135 pub fn with_filter(mut self, filter: SessionListFilter) -> Self {
136 self.filter = filter;
137 self
138 }
139
140 #[must_use]
141 pub fn with_sort(mut self, sort: SessionSortMode) -> Self {
142 self.sort = sort;
143 self
144 }
145
146 /// Exclude empty auto-created sessions from the listing (#6014).
147 #[must_use]
148 pub fn without_empty_auto_created(mut self) -> Self {
149 self.hide_empty_auto_created = true;
150 self
151 }
152 }
153
154 /// One durable session, projected for display.
155 ///
156 /// Field names deliberately mirror `ThreadSummary` in
157 /// [`crate::runtime_api`] (`id`, `title`, `preview`, `model`, `mode`,
158 /// `workspace`, `archived`, `updated_at`) so the embedded dashboard can render
159 /// a saved session and a live thread with the same row code, and so a reader
160 /// comparing the two payloads sees one vocabulary rather than two.
161 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162 pub struct SessionSummary {
163 pub id: String,
164 pub title: String,
165 /// Bounded preview text. See the module docs: this is the session title,
166 /// never a fabricated "last message" the metadata does not record.
167 pub preview: String,
168 pub model: String,
169 pub mode: String,
170 pub workspace: PathBuf,
171 pub archived: bool,
172 pub message_count: usize,
173 pub total_tokens: u64,
174 pub updated_at: DateTime<Utc>,
175 pub created_at: DateTime<Utc>,
176 /// Set when this session was created by `/fork`, so lineage is visible
177 /// without opening the session.
178 pub parent_session_id: Option<String>,
179 /// True when this row is the session the calling surface currently has
180 /// loaded. Computed by the caller passing its active session id; never
181 /// inferred from disk state.
182 pub is_current: bool,
183 }
184
185 /// Longest preview/title a projected row carries.
186 const MAX_SUMMARY_TEXT: usize = 140;
187
188 impl SessionSummary {
189 fn from_metadata(metadata: &SessionMetadata, current_session_id: Option<&str>) -> Self {
190 let title = bounded(&metadata.title, MAX_SUMMARY_TEXT);
191 Self {
192 preview: title.clone(),
193 id: metadata.id.clone(),
194 title,
195 model: metadata.model.clone(),
196 mode: metadata.mode.clone().unwrap_or_else(|| "agent".to_string()),
197 workspace: metadata.workspace.clone(),
198 archived: metadata.archived,
199 message_count: metadata.message_count,
200 total_tokens: metadata.total_tokens,
201 updated_at: metadata.updated_at,
202 created_at: metadata.created_at,
203 parent_session_id: metadata.parent_session_id.clone(),
204 is_current: current_session_id.is_some_and(|id| id == metadata.id),
205 }
206 }
207 }
208
209 fn bounded(text: &str, max_chars: usize) -> String {
210 let trimmed = text.trim();
211 if trimmed.is_empty() {
212 return "Untitled session".to_string();
213 }
214 if trimmed.chars().count() <= max_chars {
215 return trimmed.to_string();
216 }
217 let kept: String = trimmed.chars().take(max_chars.saturating_sub(1)).collect();
218 format!("{kept}…")
219 }
220
221 /// Filter, sort, and bound a metadata list — the single selection seam.
222 ///
223 /// Every browse surface funnels through this: the TUI session picker's
224 /// filtered list, the sidebar rail, and both `/v1/sessions` routes. Returning
225 /// borrowed metadata (rather than only [`SessionSummary`]) is what lets the
226 /// picker use it — the picker renders from `SessionMetadata`, and giving it a
227 /// summary-only API is exactly what pushed it into keeping a private
228 /// filter/sort in the first place.
229 ///
230 /// `sessions` is whatever [`crate::session_manager::SessionManager::list_sessions`]
231 /// returned; this function never touches the filesystem.
232 #[must_use]
233 pub fn select_sessions<'a>(
234 sessions: &'a [SessionMetadata],
235 query: &SessionQuery,
236 ) -> Vec<&'a SessionMetadata> {
237 let mut matched: Vec<&SessionMetadata> = sessions
238 .iter()
239 .filter(|session| query.filter.admits(session.archived))
240 .filter(|session| {
241 !query.hide_empty_auto_created
242 || !crate::session_manager::is_empty_auto_created_session(session)
243 })
244 .filter(|session| matches_workspace_scope(session, query.workspace_scope.as_deref()))
245 .filter(|session| session_matches_query(&query.search, session))
246 .collect();
247
248 match query.sort {
249 // Ties break on id in every mode so a listing is stable across
250 // processes rather than inheriting directory-read order. Two surfaces
251 // that sort "the same way" but tie-break differently are two backends.
252 SessionSortMode::Recent => matched.sort_by(|a, b| {
253 b.updated_at
254 .cmp(&a.updated_at)
255 .then_with(|| a.id.cmp(&b.id))
256 }),
257 SessionSortMode::Name => {
258 matched.sort_by(|a, b| a.title.cmp(&b.title).then_with(|| a.id.cmp(&b.id)))
259 }
260 SessionSortMode::Size => matched.sort_by(|a, b| {
261 b.message_count
262 .cmp(&a.message_count)
263 .then_with(|| b.updated_at.cmp(&a.updated_at))
264 .then_with(|| a.id.cmp(&b.id))
265 }),
266 }
267
268 matched.truncate(query.limit.min(MAX_PROJECTED_SESSIONS));
269 matched
270 }
271
272 /// [`select_sessions`], projected into display rows.
273 #[must_use]
274 pub fn project_sessions(
275 sessions: &[SessionMetadata],
276 query: &SessionQuery,
277 current_session_id: Option<&str>,
278 ) -> Vec<SessionSummary> {
279 select_sessions(sessions, query)
280 .into_iter()
281 .map(|metadata| SessionSummary::from_metadata(metadata, current_session_id))
282 .collect()
283 }
284
285 /// Does this session belong to `scope`?
286 ///
287 /// `None` means the caller opted out of scoping. Matching reuses
288 /// [`workspace_scope_matches`], so a worktree and its repository root are
289 /// treated the same way the resume path already treats them — the rail cannot
290 /// disagree with what `--continue` would pick.
291 #[must_use]
292 pub fn matches_workspace_scope(session: &SessionMetadata, scope: Option<&Path>) -> bool {
293 match scope {
294 None => true,
295 Some(scope) => workspace_scope_matches(&session.workspace, scope),
296 }
297 }
298
299 /// Fuzzy match over title, id, and workspace.
300 ///
301 /// Case-insensitive substring first, then subsequence. This is the session
302 /// picker's historical `fuzzy_match` behaviour, lifted here verbatim so the
303 /// picker, the rail, and `GET /v1/sessions?search=` cannot disagree about
304 /// what "matches". An empty or whitespace-only query matches everything.
305 #[must_use]
306 pub fn session_matches_query(query: &str, session: &SessionMetadata) -> bool {
307 let query = query.trim().to_ascii_lowercase();
308 if query.is_empty() {
309 return true;
310 }
311 let haystack = format!(
312 "{} {} {}",
313 session.title,
314 session.id,
315 session.workspace.display()
316 )
317 .to_ascii_lowercase();
318 if haystack.contains(&query) {
319 return true;
320 }
321 is_subsequence(&query, &haystack)
322 }
323
324 fn is_subsequence(needle: &str, haystack: &str) -> bool {
325 let mut chars = needle.chars();
326 let mut current = match chars.next() {
327 Some(c) => c,
328 None => return true,
329 };
330 for ch in haystack.chars() {
331 if ch == current {
332 match chars.next() {
333 Some(next) => current = next,
334 None => return true,
335 }
336 }
337 }
338 false
339 }
340
341 #[cfg(test)]
342 mod tests {
343 use super::*;
344 use chrono::Duration;
345
346 fn metadata(id: &str, title: &str, workspace: &str, minutes_ago: i64) -> SessionMetadata {
347 let ts = Utc::now() - Duration::minutes(minutes_ago);
348 SessionMetadata {
349 id: id.to_string(),
350 title: title.to_string(),
351 created_at: ts,
352 updated_at: ts,
353 message_count: title.len(),
354 total_tokens: 0,
355 model: "deepseek-chat".to_string(),
356 model_provider: "deepseek".to_string(),
357 model_provider_id: None,
358 workspace: PathBuf::from(workspace),
359 mode: Some("agent".to_string()),
360 cost: Default::default(),
361 parent_session_id: None,
362 forked_from_message_count: None,
363 runtime_store: None,
364 cumulative_turn_secs: 0,
365 archived: false,
366 spawn_depth: 0,
367 }
368 }
369
370 #[test]
371 fn recent_sort_puts_newest_first_and_marks_the_current_row() {
372 let sessions = vec![
373 metadata("old", "Older work", "/repo", 120),
374 metadata("new", "Newer work", "/repo", 5),
375 ];
376 let rows = project_sessions(&sessions, &SessionQuery::default(), Some("old"));
377
378 assert_eq!(
379 rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
380 vec!["new", "old"]
381 );
382 assert!(!rows[0].is_current);
383 assert!(rows[1].is_current);
384 }
385
386 #[test]
387 fn archived_sessions_are_hidden_until_explicitly_requested() {
388 let mut archived = metadata("gone", "Archived work", "/repo", 1);
389 archived.archived = true;
390 let sessions = vec![archived, metadata("live", "Live work", "/repo", 2)];
391
392 let active = project_sessions(&sessions, &SessionQuery::default(), None);
393 assert_eq!(
394 active.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
395 vec!["live"]
396 );
397
398 let all = project_sessions(
399 &sessions,
400 &SessionQuery::default().with_filter(SessionListFilter::IncludeArchived),
401 None,
402 );
403 assert_eq!(all.len(), 2);
404
405 let only = project_sessions(
406 &sessions,
407 &SessionQuery::default().with_filter(SessionListFilter::ArchivedOnly),
408 None,
409 );
410 assert_eq!(
411 only.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
412 vec!["gone"]
413 );
414 }
415
416 #[test]
417 fn workspace_scope_excludes_other_projects_and_none_opts_out() {
418 let sessions = vec![
419 metadata("here", "Here", "/repo-a", 1),
420 metadata("there", "There", "/repo-b", 2),
421 ];
422 let scoped = project_sessions(
423 &sessions,
424 &SessionQuery::default().scoped_to(Path::new("/repo-a")),
425 None,
426 );
427 assert_eq!(
428 scoped.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
429 vec!["here"]
430 );
431
432 let unscoped = project_sessions(&sessions, &SessionQuery::default(), None);
433 assert_eq!(unscoped.len(), 2);
434 }
435
436 #[test]
437 fn search_matches_substring_and_subsequence_over_title_id_and_workspace() {
438 let sessions = vec![metadata("abc123", "Whale migration notes", "/repo-a", 1)];
439
440 for query in ["whale", "WHALE", "abc1", "repo-a", "wmn"] {
441 let rows =
442 project_sessions(&sessions, &SessionQuery::default().with_search(query), None);
443 assert_eq!(rows.len(), 1, "query {query} should match");
444 }
445
446 let rows = project_sessions(
447 &sessions,
448 &SessionQuery::default().with_search("zzzz"),
449 None,
450 );
451 assert!(rows.is_empty());
452 }
453
454 #[test]
455 fn sort_modes_are_deterministic_across_equal_keys() {
456 let mut a = metadata("aaa", "Same title", "/repo", 1);
457 let mut b = metadata("bbb", "Same title", "/repo", 1);
458 a.message_count = 4;
459 b.message_count = 4;
460 let sessions = vec![b, a];
461
462 let by_name = project_sessions(
463 &sessions,
464 &SessionQuery::default().with_sort(SessionSortMode::Name),
465 None,
466 );
467 assert_eq!(
468 by_name.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
469 vec!["aaa", "bbb"]
470 );
471 }
472
473 #[test]
474 fn size_sort_orders_by_message_count_descending() {
475 let mut small = metadata("small", "Small", "/repo", 1);
476 let mut large = metadata("large", "Large", "/repo", 2);
477 small.message_count = 2;
478 large.message_count = 40;
479
480 let rows = project_sessions(
481 &[small, large],
482 &SessionQuery::default().with_sort(SessionSortMode::Size),
483 None,
484 );
485 assert_eq!(
486 rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
487 vec!["large", "small"]
488 );
489 }
490
491 #[test]
492 fn limit_is_bounded_by_the_projection_cap() {
493 let sessions: Vec<SessionMetadata> = (0..20)
494 .map(|i| metadata(&format!("s{i}"), &format!("Session {i}"), "/repo", i))
495 .collect();
496
497 let rows = project_sessions(&sessions, &SessionQuery::default().with_limit(5), None);
498 assert_eq!(rows.len(), 5);
499
500 let capped = project_sessions(
501 &sessions,
502 &SessionQuery::default().with_limit(usize::MAX),
503 None,
504 );
505 assert_eq!(capped.len(), 20);
506 }
507
508 #[test]
509 fn preview_is_the_title_and_never_a_fabricated_last_message() {
510 let sessions = vec![metadata("s", "Refactor the lane registry", "/repo", 1)];
511 let rows = project_sessions(&sessions, &SessionQuery::default(), None);
512 assert_eq!(rows[0].preview, "Refactor the lane registry");
513 assert_eq!(rows[0].preview, rows[0].title);
514 }
515
516 #[test]
517 fn long_titles_are_bounded_and_blank_titles_get_a_stable_label() {
518 let long = "x".repeat(400);
519 let sessions = vec![
520 metadata("s", &long, "/repo", 1),
521 metadata("b", " ", "/repo", 2),
522 ];
523 let rows = project_sessions(&sessions, &SessionQuery::default(), None);
524
525 let long_row = rows.iter().find(|r| r.id == "s").expect("long row");
526 assert_eq!(long_row.title.chars().count(), MAX_SUMMARY_TEXT);
527 assert!(long_row.title.ends_with('…'));
528
529 let blank_row = rows.iter().find(|r| r.id == "b").expect("blank row");
530 assert_eq!(blank_row.title, "Untitled session");
531 }
532
533 #[test]
534 fn every_wire_alias_parses_and_unknown_values_fall_back_to_recent() {
535 for value in ["recent", "", " ", "nonsense"] {
536 assert_eq!(
537 SessionSortMode::from_str_or_recent(value),
538 SessionSortMode::Recent
539 );
540 }
541 for value in ["name", "Title", " ALPHA "] {
542 assert_eq!(
543 SessionSortMode::from_str_or_recent(value),
544 SessionSortMode::Name
545 );
546 }
547 for value in ["size", "messages", "length"] {
548 assert_eq!(
549 SessionSortMode::from_str_or_recent(value),
550 SessionSortMode::Size
551 );
552 }
553 }
554
555 #[test]
556 fn the_sort_cycle_visits_every_mode_and_returns_to_the_start() {
557 let mut mode = SessionSortMode::Recent;
558 let mut seen = vec![mode];
559 for _ in 0..3 {
560 mode = mode.next();
561 seen.push(mode);
562 }
563 assert_eq!(
564 seen,
565 vec![
566 SessionSortMode::Recent,
567 SessionSortMode::Name,
568 SessionSortMode::Size,
569 SessionSortMode::Recent
570 ]
571 );
572 }
573
574 #[test]
575 fn list_filter_resolves_the_same_query_pair_as_threads() {
576 assert_eq!(
577 SessionListFilter::from_query(None, None),
578 SessionListFilter::ActiveOnly
579 );
580 assert_eq!(
581 SessionListFilter::from_query(Some(true), None),
582 SessionListFilter::IncludeArchived
583 );
584 assert_eq!(
585 SessionListFilter::from_query(Some(true), Some(true)),
586 SessionListFilter::ArchivedOnly
587 );
588 assert_eq!(
589 SessionListFilter::from_query(None, Some(true)),
590 SessionListFilter::ArchivedOnly
591 );
592 }
593 }
594
594 lines RUST