1use crate::desktop::{DesktopSessionItem, DesktopSessionMessage};
2use crate::lifecycle_store::LedgerEntry;
3use serde::Deserialize;
4use serde_json::Value;
5use std::collections::{BTreeMap, BTreeSet};
6use std::fs;
7use std::io::{BufRead, BufReader};
8use std::path::PathBuf;
9use walkdir::WalkDir;
10
11#[derive(Debug, Deserialize)]
12#[serde(rename_all = "camelCase")]
13struct ClaudeSessionsIndexFile {
14 entries: Vec<ClaudeSessionIndexEntry>,
15}
16
17#[derive(Debug, Deserialize)]
18#[serde(rename_all = "camelCase")]
19struct ClaudeSessionIndexEntry {
20 session_id: String,
21 full_path: String,
22 summary: Option<String>,
23 first_prompt: Option<String>,
24 message_count: Option<usize>,
25 created: Option<String>,
26 modified: Option<String>,
27 project_path: Option<String>,
28}
29
30#[derive(Debug, Deserialize)]
31struct CodexSessionIndexEntry {
32 id: String,
33 thread_name: Option<String>,
34 updated_at: String,
35}
36
37#[derive(Default)]
38struct CodexSessionMeta {
39 cwd: Option<String>,
40 prompt_preview: Option<String>,
41 updated_at: Option<String>,
42}
43
44pub struct ProviderSessionMessages {
45 pub messages: Vec<DesktopSessionMessage>,
46 pub total_messages: usize,
47 pub has_more_messages: bool,
48}
49
50const SESSION_MESSAGE_MAX_CHARS: usize = 2400;
51const BARE_FILE_HEAD_LINES: usize = 20;
53
54pub fn raw_session_id(session_id: &str) -> String {
55 session_id
56 .split_once(':')
57 .map(|(_, raw)| raw.to_string())
58 .unwrap_or_else(|| session_id.to_string())
59}
60
61pub fn build_memory_session_items(entries: &[LedgerEntry]) -> Vec<DesktopSessionItem> {
62 #[derive(Default)]
63 struct Aggregate {
64 last_recorded_at: String,
65 record_count: usize,
66 pending_review_count: usize,
67 wakeup_ready_count: usize,
68 titles: BTreeSet<String>,
69 memory_types: BTreeSet<String>,
70 }
71
72 let mut grouped: BTreeMap<String, Aggregate> = BTreeMap::new();
73 for entry in entries {
74 for session_id in entry_session_refs(entry) {
75 let aggregate = grouped.entry(session_id).or_default();
76 if entry.recorded_at > aggregate.last_recorded_at {
77 aggregate.last_recorded_at = entry.recorded_at.clone();
78 }
79 aggregate.record_count += 1;
80 if entry.record.requires_review() {
81 aggregate.pending_review_count += 1;
82 }
83 if entry.record.can_be_returned_in_wakeup() {
84 aggregate.wakeup_ready_count += 1;
85 }
86 aggregate.titles.insert(entry.record.title.clone());
87 aggregate
88 .memory_types
89 .insert(entry.record.memory_type.clone());
90 }
91 }
92
93 let mut sessions: Vec<DesktopSessionItem> = grouped
94 .into_iter()
95 .map(|(session_id, aggregate)| DesktopSessionItem {
96 provider: "spool".to_string(),
97 session_id: format!("spool:{session_id}"),
98 title: aggregate
99 .titles
100 .iter()
101 .next()
102 .cloned()
103 .unwrap_or_else(|| session_id.clone()),
104 summary: Some("来自 spool memory 记录的会话聚合".to_string()),
105 prompt_preview: aggregate.titles.iter().next().cloned(),
106 cwd: None,
107 source_path: None,
108 project_path: None,
109 updated_at: aggregate.last_recorded_at.clone(),
110 record_count: aggregate.record_count,
111 pending_review_count: aggregate.pending_review_count,
112 wakeup_ready_count: aggregate.wakeup_ready_count,
113 titles: aggregate.titles.into_iter().take(4).collect(),
114 memory_types: aggregate.memory_types.into_iter().collect(),
115 })
116 .collect();
117
118 sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
119 sessions
120}
121
122pub fn load_provider_sessions(filter: Option<&str>) -> anyhow::Result<Vec<DesktopSessionItem>> {
126 let mut sessions = Vec::new();
127 if filter.is_none() || filter == Some("claude") {
128 sessions.extend(load_claude_sessions()?);
129 }
130 if filter.is_none() || filter == Some("codex") {
131 sessions.extend(load_codex_sessions()?);
132 }
133 if filter.is_none() || filter == Some("gemini") {
134 sessions.extend(load_gemini_sessions()?);
135 }
136 sessions.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
137 Ok(sessions)
138}
139
140pub fn load_provider_messages(
144 session: &DesktopSessionItem,
145 offset: usize,
146 limit: usize,
147) -> anyhow::Result<ProviderSessionMessages> {
148 match session.provider.as_str() {
149 "claude" => load_claude_messages(session, offset, limit),
150 "codex" => load_codex_messages(session, offset, limit),
151 _ => Ok(ProviderSessionMessages {
152 messages: Vec::new(),
153 total_messages: 0,
154 has_more_messages: false,
155 }),
156 }
157}
158
159pub fn entry_session_refs(entry: &LedgerEntry) -> BTreeSet<String> {
160 let mut refs = BTreeSet::new();
161 collect_session_ref(entry.record.origin.source_ref.as_str(), &mut refs);
162 for evidence in &entry.metadata.evidence_refs {
163 collect_session_ref(evidence.as_str(), &mut refs);
164 }
165 refs
166}
167
168fn home_dir() -> Option<PathBuf> {
169 crate::support::home_dir()
170}
171
172fn load_claude_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
175 let Some(home) = home_dir() else {
176 return Ok(Vec::new());
177 };
178 let projects_root = home.join(".claude/projects");
179 if !projects_root.exists() {
180 return Ok(Vec::new());
181 }
182
183 let mut sessions = Vec::new();
184 let mut indexed_paths: BTreeSet<String> = BTreeSet::new();
185
186 for entry in WalkDir::new(&projects_root).min_depth(1).max_depth(2) {
188 let entry = match entry {
189 Ok(entry) => entry,
190 Err(_) => continue,
191 };
192 if entry.file_name() != "sessions-index.json" {
193 continue;
194 }
195 let parsed: ClaudeSessionsIndexFile =
196 match serde_json::from_str(&fs::read_to_string(entry.path())?) {
197 Ok(value) => value,
198 Err(_) => continue,
199 };
200 for item in parsed.entries {
201 if fs::metadata(&item.full_path).is_err() {
202 continue;
203 }
204 indexed_paths.insert(item.full_path.clone());
205 let updated_at = item
206 .modified
207 .clone()
208 .or(item.created.clone())
209 .unwrap_or_else(|| "unknown".to_string());
210 let title = item
211 .summary
212 .clone()
213 .or(item.first_prompt.clone())
214 .unwrap_or_else(|| item.session_id.clone());
215 sessions.push(DesktopSessionItem {
216 provider: "claude".to_string(),
217 session_id: format!("claude:{}", item.session_id),
218 title,
219 summary: item.summary.clone().or(item.first_prompt.clone()),
220 prompt_preview: item.first_prompt.clone(),
221 cwd: item.project_path.clone(),
222 source_path: Some(item.full_path.clone()),
223 project_path: item.project_path,
224 updated_at,
225 record_count: item.message_count.unwrap_or(0),
226 pending_review_count: 0,
227 wakeup_ready_count: 0,
228 titles: Vec::new(),
229 memory_types: Vec::new(),
230 });
231 }
232 }
233
234 sessions.extend(load_claude_bare_sessions(&projects_root, &indexed_paths)?);
236
237 Ok(sessions)
238}
239
240fn load_claude_bare_sessions(
243 projects_root: &std::path::Path,
244 indexed_paths: &BTreeSet<String>,
245) -> anyhow::Result<Vec<DesktopSessionItem>> {
246 let mut sessions = Vec::new();
247
248 for entry in WalkDir::new(projects_root)
249 .min_depth(2)
250 .max_depth(2)
251 .into_iter()
252 .filter_map(Result::ok)
253 {
254 if !entry.file_type().is_file() {
255 continue;
256 }
257 let path = entry.path();
258 if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
259 continue;
260 }
261 let path_str = path.display().to_string();
263 if indexed_paths.contains(&path_str) {
264 continue;
265 }
266 if path
268 .file_name()
269 .map(|n| n == "sessions-index.json")
270 .unwrap_or(false)
271 {
272 continue;
273 }
274
275 let session_id = path
276 .file_stem()
277 .and_then(|s| s.to_str())
278 .unwrap_or("unknown")
279 .to_string();
280
281 let project_path = path
284 .parent()
285 .and_then(|p| p.file_name())
286 .and_then(|n| n.to_str())
287 .map(decode_claude_project_slug);
288
289 let meta = extract_claude_bare_meta(path);
290
291 let title = meta.title.unwrap_or_else(|| {
292 meta.first_prompt
293 .clone()
294 .unwrap_or_else(|| session_id.clone())
295 });
296 let updated_at = meta.last_timestamp.unwrap_or_else(|| {
297 fs::metadata(path)
299 .and_then(|m| m.modified())
300 .ok()
301 .and_then(|t| {
302 let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
303 Some(format_unix_timestamp(duration.as_secs()))
304 })
305 .unwrap_or_else(|| "unknown".to_string())
306 });
307
308 sessions.push(DesktopSessionItem {
309 provider: "claude".to_string(),
310 session_id: format!("claude:{}", session_id),
311 title,
312 summary: meta.first_prompt.clone(),
313 prompt_preview: meta.first_prompt,
314 cwd: meta.cwd.or(project_path.clone()),
315 source_path: Some(path_str),
316 project_path,
317 updated_at,
318 record_count: 0,
319 pending_review_count: 0,
320 wakeup_ready_count: 0,
321 titles: Vec::new(),
322 memory_types: Vec::new(),
323 });
324 }
325
326 Ok(sessions)
327}
328
329#[derive(Default)]
331struct ClaudeBareFileMeta {
332 title: Option<String>,
333 first_prompt: Option<String>,
334 cwd: Option<String>,
335 last_timestamp: Option<String>,
336}
337
338fn extract_claude_bare_meta(path: &std::path::Path) -> ClaudeBareFileMeta {
340 let mut meta = ClaudeBareFileMeta::default();
341 let file = match fs::File::open(path) {
342 Ok(f) => f,
343 Err(_) => return meta,
344 };
345 let reader = BufReader::new(file);
346
347 for (idx, line) in reader.lines().enumerate() {
348 if idx >= BARE_FILE_HEAD_LINES {
349 break;
350 }
351 let line = match line {
352 Ok(l) => l,
353 Err(_) => break,
354 };
355 if line.trim().is_empty() {
356 continue;
357 }
358 let value: Value = match serde_json::from_str(&line) {
359 Ok(v) => v,
360 Err(_) => continue,
361 };
362
363 if let Some(ts) = value.get("timestamp").and_then(Value::as_str) {
365 meta.last_timestamp = Some(ts.to_string());
366 }
367
368 match value.get("type").and_then(Value::as_str) {
369 Some("ai-title") if meta.title.is_none() => {
370 meta.title = value
371 .get("aiTitle")
372 .and_then(Value::as_str)
373 .map(truncate_for_preview);
374 }
375 Some("user") => {
376 if meta.first_prompt.is_none() {
377 meta.first_prompt =
378 extract_content_text(&value).map(|s| truncate_for_preview(&s));
379 }
380 if meta.cwd.is_none() {
381 meta.cwd = value
382 .get("cwd")
383 .and_then(Value::as_str)
384 .map(ToString::to_string);
385 }
386 }
387 _ => {}
388 }
389
390 if meta.title.is_some() && meta.first_prompt.is_some() && meta.cwd.is_some() {
391 break;
392 }
393 }
394
395 meta
396}
397
398fn decode_claude_project_slug(slug: &str) -> String {
401 if slug.starts_with('-') {
402 slug.replacen('-', "/", 1).replace('-', "/")
403 } else {
404 slug.replace('-', "/")
405 }
406}
407
408fn load_codex_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
413 let Some(home) = home_dir() else {
414 return Ok(Vec::new());
415 };
416 let sessions_root = home.join(".codex/sessions");
417
418 let index_lookup = load_codex_index_lookup(&home);
420
421 if !sessions_root.exists() {
423 return Ok(Vec::new());
424 }
425
426 let mut sessions = Vec::new();
427 for entry in WalkDir::new(&sessions_root)
428 .into_iter()
429 .filter_map(Result::ok)
430 {
431 if !entry.file_type().is_file() {
432 continue;
433 }
434 let path = entry.path();
435 if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
436 continue;
437 }
438 let Some(name) = path.file_stem().and_then(|name| name.to_str()) else {
439 continue;
440 };
441 let id = extract_codex_uuid(name);
444 let Some(id) = id else {
445 continue;
446 };
447
448 let path_str = path.display().to_string();
449 let meta = load_codex_session_meta(&path_str);
450
451 let index_entry = index_lookup.get(id);
453 let thread_name = index_entry.and_then(|e| e.thread_name.clone());
454 let index_updated_at = index_entry.map(|e| e.updated_at.clone());
455
456 let updated_at = meta.updated_at.or(index_updated_at).unwrap_or_else(|| {
457 fs::metadata(path)
458 .and_then(|m| m.modified())
459 .ok()
460 .and_then(|t| {
461 let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
462 Some(format_unix_timestamp(duration.as_secs()))
463 })
464 .unwrap_or_else(|| "unknown".to_string())
465 });
466
467 let title = meta
468 .prompt_preview
469 .clone()
470 .or(thread_name.clone())
471 .unwrap_or_else(|| id.to_string());
472
473 sessions.push(DesktopSessionItem {
474 provider: "codex".to_string(),
475 session_id: format!("codex:{}", id),
476 title,
477 summary: thread_name.or(meta.prompt_preview.clone()),
478 prompt_preview: meta.prompt_preview,
479 cwd: meta.cwd,
480 source_path: Some(path_str),
481 project_path: None,
482 updated_at,
483 record_count: 0,
484 pending_review_count: 0,
485 wakeup_ready_count: 0,
486 titles: Vec::new(),
487 memory_types: Vec::new(),
488 });
489 }
490
491 Ok(sessions)
492}
493
494fn extract_codex_uuid(name: &str) -> Option<&str> {
498 if name.len() < 36 {
499 return None;
500 }
501 let candidate = &name[name.len() - 36..];
502 let parts: Vec<&str> = candidate.split('-').collect();
504 if parts.len() != 5 {
505 return None;
506 }
507 let expected_lens = [8, 4, 4, 4, 12];
508 for (part, &expected) in parts.iter().zip(&expected_lens) {
509 if part.len() != expected || !part.chars().all(|c| c.is_ascii_hexdigit()) {
510 return None;
511 }
512 }
513 Some(candidate)
514}
515
516fn load_codex_index_lookup(home: &std::path::Path) -> BTreeMap<String, CodexSessionIndexEntry> {
518 let index_path = home.join(".codex/session_index.jsonl");
519 let mut lookup = BTreeMap::new();
520 let content = match fs::read_to_string(index_path) {
521 Ok(c) => c,
522 Err(_) => return lookup,
523 };
524 for line in content.lines().filter(|l| !l.trim().is_empty()) {
525 if let Ok(entry) = serde_json::from_str::<CodexSessionIndexEntry>(line) {
526 lookup.insert(entry.id.clone(), entry);
527 }
528 }
529 lookup
530}
531
532fn load_codex_session_meta(path: &str) -> CodexSessionMeta {
533 let mut meta = CodexSessionMeta::default();
534 let file = match fs::File::open(path) {
535 Ok(f) => f,
536 Err(_) => return meta,
537 };
538 let reader = BufReader::new(file);
539
540 for line in reader.lines() {
541 let line = match line {
542 Ok(l) => l,
543 Err(_) => break,
544 };
545 if line.trim().is_empty() {
546 continue;
547 }
548 let value: Value = match serde_json::from_str(&line) {
549 Ok(value) => value,
550 Err(_) => continue,
551 };
552
553 if let Some(ts) = value.get("timestamp").and_then(Value::as_str) {
555 meta.updated_at = Some(ts.to_string());
556 }
557
558 match value.get("type").and_then(Value::as_str) {
559 Some("session_meta") => {
560 meta.cwd = value
561 .get("payload")
562 .and_then(|payload| payload.get("cwd"))
563 .and_then(Value::as_str)
564 .map(ToString::to_string);
565 }
566 Some("response_item") => {
567 let payload = value.get("payload");
568 let role = payload
569 .and_then(|payload| payload.get("role"))
570 .and_then(Value::as_str);
571 if role == Some("user") && meta.prompt_preview.is_none() {
572 meta.prompt_preview = payload
573 .and_then(|payload| payload.get("content"))
574 .and_then(Value::as_array)
575 .and_then(|items| {
576 items.iter().find_map(|item| {
577 item.get("text")
578 .and_then(Value::as_str)
579 .map(truncate_for_preview)
580 })
581 });
582 }
583 }
584 _ => {}
585 }
586
587 if meta.cwd.is_some() && meta.prompt_preview.is_some() {
588 break;
589 }
590 }
591
592 meta
593}
594
595fn load_gemini_sessions() -> anyhow::Result<Vec<DesktopSessionItem>> {
598 let Some(home) = home_dir() else {
599 return Ok(Vec::new());
600 };
601 let history_root = home.join(".gemini/history");
602 if !history_root.exists() {
603 return Ok(Vec::new());
604 }
605
606 let mut sessions = Vec::new();
607 for entry in WalkDir::new(&history_root)
608 .min_depth(1)
609 .max_depth(3)
610 .into_iter()
611 .filter_map(Result::ok)
612 {
613 if !entry.file_type().is_file() {
614 continue;
615 }
616 let path = entry.path();
617 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
618 if ext != "json" && ext != "jsonl" {
619 continue;
620 }
621 let session_id = path
622 .file_stem()
623 .and_then(|s| s.to_str())
624 .unwrap_or("unknown")
625 .to_string();
626
627 let updated_at = fs::metadata(path)
628 .and_then(|m| m.modified())
629 .ok()
630 .and_then(|t| {
631 let duration = t.duration_since(std::time::UNIX_EPOCH).ok()?;
632 Some(format_unix_timestamp(duration.as_secs()))
633 })
634 .unwrap_or_else(|| "unknown".to_string());
635
636 sessions.push(DesktopSessionItem {
637 provider: "gemini".to_string(),
638 session_id: format!("gemini:{}", session_id),
639 title: session_id.clone(),
640 summary: None,
641 prompt_preview: None,
642 cwd: None,
643 source_path: Some(path.display().to_string()),
644 project_path: None,
645 updated_at,
646 record_count: 0,
647 pending_review_count: 0,
648 wakeup_ready_count: 0,
649 titles: Vec::new(),
650 memory_types: Vec::new(),
651 });
652 }
653
654 Ok(sessions)
655}
656
657fn load_claude_messages(
660 session: &DesktopSessionItem,
661 offset: usize,
662 limit: usize,
663) -> anyhow::Result<ProviderSessionMessages> {
664 let Some(path) = session.source_path.as_deref() else {
665 return Ok(ProviderSessionMessages {
666 messages: Vec::new(),
667 total_messages: 0,
668 has_more_messages: false,
669 });
670 };
671 let mut messages = Vec::new();
672 for line in fs::read_to_string(path)?
673 .lines()
674 .filter(|line| !line.trim().is_empty())
675 {
676 let value: Value = match serde_json::from_str(line) {
677 Ok(value) => value,
678 Err(_) => continue,
679 };
680
681 let msg_type = value.get("type").and_then(Value::as_str);
683 match msg_type {
684 Some("user") | Some("assistant") => {
685 let role = msg_type.unwrap();
686 let content_text = extract_content_text(&value).unwrap_or_default();
687 if content_text.is_empty() {
688 continue;
689 }
690 messages.push(DesktopSessionMessage {
691 role: role.to_string(),
692 timestamp: value
693 .get("timestamp")
694 .and_then(Value::as_str)
695 .unwrap_or("unknown")
696 .to_string(),
697 content: truncate_for_detail(&content_text),
698 truncated: is_content_truncated(&content_text),
699 });
700 }
701 _ => {
702 let role = value
704 .get("message")
705 .and_then(|message| message.get("role"))
706 .and_then(Value::as_str);
707 let content = value
708 .get("message")
709 .and_then(|message| message.get("content"))
710 .and_then(Value::as_str);
711 if let (Some(role), Some(content)) = (role, content) {
712 messages.push(DesktopSessionMessage {
713 role: role.to_string(),
714 timestamp: value
715 .get("timestamp")
716 .and_then(Value::as_str)
717 .unwrap_or("unknown")
718 .to_string(),
719 content: truncate_for_detail(content),
720 truncated: is_content_truncated(content),
721 });
722 }
723 }
724 }
725 }
726 Ok(paginate_messages(messages, offset, limit))
727}
728
729fn load_codex_messages(
730 session: &DesktopSessionItem,
731 offset: usize,
732 limit: usize,
733) -> anyhow::Result<ProviderSessionMessages> {
734 let Some(path) = session.source_path.as_deref() else {
735 return Ok(ProviderSessionMessages {
736 messages: Vec::new(),
737 total_messages: 0,
738 has_more_messages: false,
739 });
740 };
741 let mut messages = Vec::new();
742 for line in fs::read_to_string(path)?
743 .lines()
744 .filter(|line| !line.trim().is_empty())
745 {
746 let value: Value = match serde_json::from_str(line) {
747 Ok(value) => value,
748 Err(_) => continue,
749 };
750 let message_payload = value
751 .get("payload")
752 .filter(|_| value.get("type").and_then(Value::as_str) == Some("response_item"));
753 let Some(payload) = message_payload else {
754 continue;
755 };
756 if payload.get("type").and_then(Value::as_str) != Some("message") {
757 continue;
758 }
759 let Some(role) = payload.get("role").and_then(Value::as_str) else {
760 continue;
761 };
762 let text = payload
763 .get("content")
764 .and_then(Value::as_array)
765 .and_then(|items| {
766 items.iter().find_map(|item| {
767 item.get("text")
768 .and_then(Value::as_str)
769 .map(ToString::to_string)
770 })
771 });
772 if let Some(text) = text {
773 messages.push(DesktopSessionMessage {
774 role: role.to_string(),
775 timestamp: value
776 .get("timestamp")
777 .and_then(Value::as_str)
778 .unwrap_or("unknown")
779 .to_string(),
780 content: truncate_for_detail(&text),
781 truncated: is_content_truncated(&text),
782 });
783 }
784 }
785 Ok(paginate_messages(messages, offset, limit))
786}
787
788fn collect_session_ref(value: &str, refs: &mut BTreeSet<String>) {
791 if value.starts_with("session:") {
792 refs.insert(value.to_string());
793 }
794}
795
796fn extract_content_text(value: &Value) -> Option<String> {
799 let message = value.get("message")?;
800 let content = message.get("content")?;
801
802 if let Some(arr) = content.as_array() {
804 let texts: Vec<&str> = arr
805 .iter()
806 .filter_map(|item| item.get("text").and_then(Value::as_str))
807 .collect();
808 if texts.is_empty() {
809 return None;
810 }
811 return Some(texts.join("\n"));
812 }
813
814 content.as_str().map(ToString::to_string)
816}
817
818fn truncate_for_preview(value: &str) -> String {
819 let trimmed = value.trim();
820 if trimmed.chars().count() <= 360 {
821 return trimmed.to_string();
822 }
823 trimmed.chars().take(360).collect::<String>() + "..."
824}
825
826fn truncate_for_detail(value: &str) -> String {
827 let trimmed = value.trim();
828 if trimmed.chars().count() <= SESSION_MESSAGE_MAX_CHARS {
829 return trimmed.to_string();
830 }
831 trimmed
832 .chars()
833 .take(SESSION_MESSAGE_MAX_CHARS)
834 .collect::<String>()
835 + "\n\n...[truncated]"
836}
837
838fn is_content_truncated(value: &str) -> bool {
839 value.trim().chars().count() > SESSION_MESSAGE_MAX_CHARS
840}
841
842fn paginate_messages(
844 messages: Vec<DesktopSessionMessage>,
845 offset: usize,
846 limit: usize,
847) -> ProviderSessionMessages {
848 let total_messages = messages.len();
849 let effective_limit = if limit == 0 { total_messages } else { limit };
850 let start = offset.min(total_messages);
851 let end = (start + effective_limit).min(total_messages);
852 let paged: Vec<DesktopSessionMessage> =
853 messages.into_iter().skip(start).take(end - start).collect();
854 let has_more_messages = end < total_messages;
855 ProviderSessionMessages {
856 messages: paged,
857 total_messages,
858 has_more_messages,
859 }
860}
861
862fn format_unix_timestamp(secs: u64) -> String {
863 let days_since_epoch = secs / 86400;
865 let time_of_day = secs % 86400;
866 let hours = time_of_day / 3600;
867 let minutes = (time_of_day % 3600) / 60;
868 let seconds = time_of_day % 60;
869
870 let mut remaining_days = days_since_epoch as i64;
872 let mut year = 1970i64;
873 loop {
874 let days_in_year = if is_leap_year(year) { 366 } else { 365 };
875 if remaining_days < days_in_year {
876 break;
877 }
878 remaining_days -= days_in_year;
879 year += 1;
880 }
881 let days_in_months: [i64; 12] = if is_leap_year(year) {
882 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
883 } else {
884 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
885 };
886 let mut month = 1u32;
887 for &days_in_month in &days_in_months {
888 if remaining_days < days_in_month {
889 break;
890 }
891 remaining_days -= days_in_month;
892 month += 1;
893 }
894 let day = remaining_days + 1;
895
896 format!(
897 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
898 year, month, day, hours, minutes, seconds
899 )
900}
901
902fn is_leap_year(year: i64) -> bool {
903 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
904}
905
906#[cfg(test)]
909mod tests {
910 use super::*;
911 use std::fs;
912 use tempfile::tempdir;
913
914 fn test_session(provider: &str, path: &std::path::Path) -> DesktopSessionItem {
915 DesktopSessionItem {
916 provider: provider.to_string(),
917 session_id: format!("{provider}:demo"),
918 title: "demo".to_string(),
919 summary: None,
920 prompt_preview: None,
921 cwd: Some("/tmp/demo".to_string()),
922 source_path: Some(path.display().to_string()),
923 project_path: Some("/tmp/demo".to_string()),
924 updated_at: "2026-04-16T12:00:00Z".to_string(),
925 record_count: 0,
926 pending_review_count: 0,
927 wakeup_ready_count: 0,
928 titles: Vec::new(),
929 memory_types: Vec::new(),
930 }
931 }
932
933 #[test]
934 fn claude_message_loader_should_paginate_with_offset_and_limit() {
935 let temp = tempdir().unwrap();
936 let path = temp.path().join("claude-session.jsonl");
937 let mut lines = Vec::new();
938 for index in 0..30 {
939 lines.push(format!(
940 "{{\"timestamp\":\"2026-04-16T12:{index:02}:00Z\",\"message\":{{\"role\":\"user\",\"content\":\"message {index} {}\"}}}}",
941 "x".repeat(32)
942 ));
943 }
944 fs::write(&path, lines.join("\n")).unwrap();
945
946 let response = load_claude_messages(&test_session("claude", &path), 0, 0).unwrap();
948 assert_eq!(response.total_messages, 30);
949 assert!(!response.has_more_messages);
950 assert_eq!(response.messages.len(), 30);
951
952 let response = load_claude_messages(&test_session("claude", &path), 0, 10).unwrap();
954 assert_eq!(response.total_messages, 30);
955 assert!(response.has_more_messages);
956 assert_eq!(response.messages.len(), 10);
957 assert_eq!(
958 response.messages.first().unwrap().content,
959 format!("message 0 {}", "x".repeat(32))
960 );
961
962 let response = load_claude_messages(&test_session("claude", &path), 25, 10).unwrap();
964 assert_eq!(response.total_messages, 30);
965 assert!(!response.has_more_messages);
966 assert_eq!(response.messages.len(), 5);
967 assert_eq!(
968 response.messages.first().unwrap().content,
969 format!("message 25 {}", "x".repeat(32))
970 );
971 }
972
973 #[test]
974 fn claude_message_loader_should_handle_new_format() {
975 let temp = tempdir().unwrap();
976 let path = temp.path().join("claude-new.jsonl");
977 let lines = [
978 r#"{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"content":[{"type":"text","text":"hello world"}]},"cwd":"/tmp","sessionId":"abc123"}"#,
979 r#"{"type":"assistant","timestamp":"2026-05-01T10:01:00Z","message":{"content":[{"type":"text","text":"hi there"}]},"sessionId":"abc123"}"#,
980 r#"{"type":"ai-title","aiTitle":"Test Session","sessionId":"abc123"}"#,
981 ];
982 fs::write(&path, lines.join("\n")).unwrap();
983
984 let response = load_claude_messages(&test_session("claude", &path), 0, 0).unwrap();
985 assert_eq!(response.total_messages, 2);
986 assert_eq!(response.messages[0].role, "user");
987 assert_eq!(response.messages[0].content, "hello world");
988 assert_eq!(response.messages[1].role, "assistant");
989 assert_eq!(response.messages[1].content, "hi there");
990 }
991
992 #[test]
993 fn codex_message_loader_should_mark_truncated_detail_content() {
994 let temp = tempdir().unwrap();
995 let path = temp.path().join("codex-session.jsonl");
996 let long_text = "y".repeat(3000);
997 fs::write(
998 &path,
999 format!(
1000 "{{\"timestamp\":\"2026-04-16T12:00:00Z\",\"type\":\"response_item\",\"payload\":{{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{{\"text\":\"{}\"}}]}}}}",
1001 long_text
1002 ),
1003 )
1004 .unwrap();
1005
1006 let response = load_codex_messages(&test_session("codex", &path), 0, 0).unwrap();
1007 assert_eq!(response.total_messages, 1);
1008 assert!(!response.has_more_messages);
1009 assert_eq!(response.messages.len(), 1);
1010 assert!(response.messages[0].truncated);
1011 assert!(response.messages[0].content.ends_with("...[truncated]"));
1012 }
1013
1014 #[test]
1015 fn extract_claude_bare_meta_should_parse_new_format() {
1016 let temp = tempdir().unwrap();
1017 let path = temp.path().join("session.jsonl");
1018 let lines = [
1019 r#"{"type":"ai-title","aiTitle":"My Session Title","sessionId":"abc"}"#,
1020 r#"{"type":"user","timestamp":"2026-05-01T10:00:00Z","message":{"content":[{"type":"text","text":"first prompt here"}]},"cwd":"/home/user/project","sessionId":"abc"}"#,
1021 ];
1022 fs::write(&path, lines.join("\n")).unwrap();
1023
1024 let meta = extract_claude_bare_meta(&path);
1025 assert_eq!(meta.title.as_deref(), Some("My Session Title"));
1026 assert_eq!(meta.first_prompt.as_deref(), Some("first prompt here"));
1027 assert_eq!(meta.cwd.as_deref(), Some("/home/user/project"));
1028 assert_eq!(meta.last_timestamp.as_deref(), Some("2026-05-01T10:00:00Z"));
1029 }
1030
1031 #[test]
1032 fn decode_claude_project_slug_should_restore_path() {
1033 assert_eq!(
1034 decode_claude_project_slug("-Users-long-Work-spool"),
1035 "/Users/long/Work/spool"
1036 );
1037 assert_eq!(
1038 decode_claude_project_slug("home-user-project"),
1039 "home/user/project"
1040 );
1041 }
1042
1043 #[test]
1044 fn paginate_messages_should_handle_edge_cases() {
1045 let msgs: Vec<DesktopSessionMessage> = (0..5)
1046 .map(|i| DesktopSessionMessage {
1047 role: "user".to_string(),
1048 timestamp: format!("t{i}"),
1049 content: format!("msg{i}"),
1050 truncated: false,
1051 })
1052 .collect();
1053
1054 let result = paginate_messages(msgs.clone(), 10, 5);
1056 assert_eq!(result.messages.len(), 0);
1057 assert!(!result.has_more_messages);
1058 assert_eq!(result.total_messages, 5);
1059
1060 let result = paginate_messages(msgs.clone(), 0, 0);
1062 assert_eq!(result.messages.len(), 5);
1063 assert!(!result.has_more_messages);
1064
1065 let result = paginate_messages(msgs, 3, 10);
1067 assert_eq!(result.messages.len(), 2);
1068 assert!(!result.has_more_messages);
1069 }
1070
1071 #[test]
1072 fn codex_session_scan_should_find_files_without_index() {
1073 let temp = tempdir().unwrap();
1074 let sessions_dir = temp.path().join("sessions/2026/05/01");
1075 fs::create_dir_all(&sessions_dir).unwrap();
1076
1077 let uuid = "019e0698-6647-7681-8fe3-bafa985c83df";
1078 let filename = format!("rollout-2026-05-01T10-00-00-{uuid}.jsonl");
1079 let session_path = sessions_dir.join(&filename);
1080 fs::write(
1081 &session_path,
1082 r#"{"type":"session_meta","payload":{"cwd":"/tmp/test"}}
1083{"type":"response_item","timestamp":"2026-05-01T10:00:00Z","payload":{"type":"message","role":"user","content":[{"text":"hello codex"}]}}"#,
1084 )
1085 .unwrap();
1086
1087 let meta = load_codex_session_meta(&session_path.display().to_string());
1088 assert_eq!(meta.cwd.as_deref(), Some("/tmp/test"));
1089 assert_eq!(meta.prompt_preview.as_deref(), Some("hello codex"));
1090 }
1091
1092 #[test]
1093 fn extract_codex_uuid_should_parse_real_filenames() {
1094 assert_eq!(
1096 extract_codex_uuid("rollout-2026-03-26T15-18-28-019d2902-48c6-71f1-a107-6cb56512de80"),
1097 Some("019d2902-48c6-71f1-a107-6cb56512de80")
1098 );
1099 assert_eq!(
1100 extract_codex_uuid("rollout-2026-05-08T15-58-31-019e0698-6647-7681-8fe3-bafa985c83df"),
1101 Some("019e0698-6647-7681-8fe3-bafa985c83df")
1102 );
1103 assert_eq!(extract_codex_uuid("short"), None);
1105 assert_eq!(
1107 extract_codex_uuid("rollout-2026-05-08T15-58-31-not-a-valid-uuid-at-all-here"),
1108 None
1109 );
1110 }
1111}