Skip to main content

wsx_core/
cache.rs

1//! Persistent wsx UI state, local mute flags, and acknowledged outcomes.
2//!
3//! The wsx daemon is authoritative for sessions. Legacy backend/session fields
4//! in older TOML files are ignored by serde and are never imported.
5
6use std::collections::{HashMap, HashSet};
7use std::path::PathBuf;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use crate::{
11    config::global::{atomic_write_private, GroupKey},
12    model::workspace::{FlatEntry, WorkspaceState},
13};
14use serde::{Deserialize, Deserializer, Serialize};
15
16/// Stable cursor identity for projects, worktrees, terminal panes, and routines.
17#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
18pub enum CursorIdentity {
19    Project {
20        path: String,
21    },
22    Worktree {
23        path: String,
24    },
25    Session {
26        worktree_path: String,
27        #[serde(default, skip_serializing_if = "Option::is_none")]
28        terminal_id: Option<String>,
29        /// Legacy identity read once and migrated through the live snapshot.
30        #[serde(default, skip_serializing_if = "Option::is_none")]
31        pane_id: Option<String>,
32    },
33    RoutinesHeader {
34        project_path: String,
35    },
36    Routine {
37        project_path: String,
38        routine_name: String,
39    },
40}
41
42#[derive(Serialize, Default, Clone)]
43pub struct WorkspaceCache {
44    #[serde(default)]
45    pub written_at_unix_ms: Option<u64>,
46    #[serde(default)]
47    pub worktree_expanded: HashMap<String, bool>,
48    #[serde(default)]
49    pub project_expanded: HashMap<String, bool>,
50    /// Latest explicit project interaction, keyed by stable project path.
51    #[serde(default)]
52    pub project_touched_unix_ms: HashMap<String, u64>,
53    #[serde(default)]
54    pub routines_expanded: HashMap<String, bool>,
55    #[serde(default)]
56    pub tree_selected: usize,
57    #[serde(default)]
58    pub cursor_identity: Option<CursorIdentity>,
59    /// Stable wsx terminal IDs muted in this local UI.
60    #[serde(default)]
61    pub muted_terminals: HashSet<String>,
62    /// Provider outcome revisions acknowledged by explicit interaction, keyed by terminal ID.
63    #[serde(default)]
64    pub acknowledged_outcomes: HashMap<String, u64>,
65    /// Agent integrations whose demand-driven setup prompt the user declined.
66    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
67    pub dismissed_integration_prompts: HashSet<crate::integration::IntegrationTarget>,
68    #[serde(skip)]
69    migration_needed: bool,
70}
71
72#[derive(Deserialize, Default)]
73#[serde(default)]
74struct WorkspaceCacheWire {
75    written_at_unix_ms: Option<u64>,
76    worktree_expanded: HashMap<String, bool>,
77    project_expanded: HashMap<String, bool>,
78    project_touched_unix_ms: HashMap<String, u64>,
79    routines_expanded: HashMap<String, bool>,
80    tree_selected: usize,
81    cursor_identity: Option<CursorIdentity>,
82    #[serde(alias = "muted_sessions")]
83    muted_terminals: HashSet<String>,
84    acknowledged_outcomes: HashMap<String, u64>,
85    active_group: Option<toml::Value>,
86    active_groups: Option<toml::Value>,
87    active_tab: Option<toml::Value>,
88    integration_prompt_version: Option<String>,
89    dismissed_integration_prompts: HashSet<crate::integration::IntegrationTarget>,
90}
91
92impl<'de> Deserialize<'de> for WorkspaceCache {
93    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
94    where
95        D: Deserializer<'de>,
96    {
97        let wire = WorkspaceCacheWire::deserialize(deserializer)?;
98        // ^ Group selection is process-local. Reading any historical selector requests a
99        // canonical rewrite that strips it instead of restoring stale UI state.
100        let migration_needed = wire.active_group.is_some()
101            || wire.active_groups.is_some()
102            || wire.active_tab.is_some();
103        Ok(Self {
104            written_at_unix_ms: wire.written_at_unix_ms,
105            worktree_expanded: wire.worktree_expanded,
106            project_expanded: wire.project_expanded,
107            project_touched_unix_ms: wire.project_touched_unix_ms,
108            routines_expanded: wire.routines_expanded,
109            tree_selected: wire.tree_selected,
110            cursor_identity: wire.cursor_identity,
111            muted_terminals: wire.muted_terminals,
112            acknowledged_outcomes: wire.acknowledged_outcomes,
113            dismissed_integration_prompts: wire.dismissed_integration_prompts,
114            migration_needed: migration_needed || wire.integration_prompt_version.is_some(),
115        })
116    }
117}
118
119impl WorkspaceCache {
120    pub fn load() -> anyhow::Result<Self> {
121        Self::load_from_paths(&cache_path(), &legacy_cache_path())
122    }
123
124    fn load_from_paths(
125        canonical: &std::path::Path,
126        legacy: &std::path::Path,
127    ) -> anyhow::Result<Self> {
128        let (content, imported_legacy) = match std::fs::read_to_string(canonical) {
129            Ok(content) => (content, false),
130            Err(_) if !canonical.exists() => match std::fs::read_to_string(legacy) {
131                Ok(content) => (content, true),
132                Err(_) => return Ok(Self::default()),
133            },
134            Err(_) => return Ok(Self::default()),
135        };
136        let mut cache: Self = toml::from_str(&content).unwrap_or_default();
137        if imported_legacy || cache.migration_needed {
138            cache.save_to(canonical, false)?;
139            cache.migration_needed = false;
140        }
141        Ok(cache)
142    }
143
144    pub fn save(&self, sync: bool) -> anyhow::Result<()> {
145        self.save_to(&cache_path(), sync)
146    }
147
148    fn save_to(&self, path: &std::path::Path, sync: bool) -> anyhow::Result<()> {
149        let mut cache = self.clone();
150        cache.written_at_unix_ms = Some(now_unix_ms());
151        let text = toml::to_string(&cache)?;
152        atomic_write_private(path, text.as_bytes(), sync)?;
153        Ok(())
154    }
155}
156
157fn now_unix_ms() -> u64 {
158    SystemTime::now()
159        .duration_since(UNIX_EPOCH)
160        .unwrap_or_default()
161        .as_millis()
162        .try_into()
163        .unwrap_or(u64::MAX)
164}
165
166fn cached_project_touch_unix_ms(
167    cache: &WorkspaceCache,
168    project_key: &str,
169    loaded_at_unix_ms: u64,
170) -> u64 {
171    cache
172        .project_touched_unix_ms
173        .get(project_key)
174        .copied()
175        .or(cache.written_at_unix_ms)
176        .unwrap_or(loaded_at_unix_ms)
177}
178
179fn cache_path() -> PathBuf {
180    dirs::cache_dir()
181        .unwrap_or_else(|| PathBuf::from("/tmp"))
182        .join("wsx")
183        .join("workspace-v2.toml")
184}
185
186fn legacy_cache_path() -> PathBuf {
187    dirs::cache_dir()
188        .unwrap_or_else(|| PathBuf::from("/tmp"))
189        .join("wsx")
190        .join("workspace.toml")
191}
192
193#[derive(Serialize, Deserialize)]
194struct GroupSelection {
195    selected: GroupKey,
196}
197
198fn group_selection_path() -> PathBuf {
199    dirs::cache_dir()
200        .unwrap_or_else(|| PathBuf::from("/tmp"))
201        .join("wsx")
202        .join("group-selection-v1.toml")
203}
204
205pub fn load_group_selection() -> anyhow::Result<Option<GroupKey>> {
206    load_group_selection_from(&group_selection_path())
207}
208
209fn load_group_selection_from(path: &std::path::Path) -> anyhow::Result<Option<GroupKey>> {
210    let content = match std::fs::read_to_string(path) {
211        Ok(content) => content,
212        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
213        Err(error) => return Err(error.into()),
214    };
215    Ok(toml::from_str::<GroupSelection>(&content)
216        .ok()
217        .map(|selection| selection.selected))
218}
219
220pub fn save_group_selection(selected: &GroupKey) -> anyhow::Result<()> {
221    save_group_selection_to(&group_selection_path(), selected)
222}
223
224fn save_group_selection_to(path: &std::path::Path, selected: &GroupKey) -> anyhow::Result<()> {
225    let text = toml::to_string(&GroupSelection {
226        selected: selected.clone(),
227    })?;
228    atomic_write_private(path, text.as_bytes(), true)?;
229    Ok(())
230}
231
232pub type AppliedCache = (
233    usize,
234    Option<CursorIdentity>,
235    HashMap<PathBuf, u64>,
236    HashSet<String>,
237    HashMap<String, u64>,
238    HashSet<crate::integration::IntegrationTarget>,
239);
240
241/// Apply only cached UI and local mute state. Sessions always come from wsxd.
242pub fn apply_cache(workspace: &mut WorkspaceState) -> anyhow::Result<AppliedCache> {
243    let cache = WorkspaceCache::load()?;
244    let mut migrated_muted_terminals = HashSet::new();
245    let mut project_touched_unix_ms = HashMap::new();
246    let loaded_at_unix_ms = now_unix_ms();
247    for project in &mut workspace.projects {
248        let project_key = project.path.to_string_lossy().to_string();
249        project_touched_unix_ms.insert(
250            project.path.clone(),
251            cached_project_touch_unix_ms(&cache, &project_key, loaded_at_unix_ms),
252        );
253        if let Some(expanded) = cache.project_expanded.get(&project_key) {
254            project.expanded = *expanded;
255        }
256        if let Some(expanded) = cache.routines_expanded.get(&project_key) {
257            project.routines_expanded = *expanded;
258        }
259        for worktree in &mut project.worktrees {
260            let key = worktree.path.to_string_lossy().to_string();
261            if let Some(expanded) = cache.worktree_expanded.get(&key) {
262                worktree.expanded = *expanded;
263            }
264            for session in &mut worktree.sessions {
265                session.muted = cache
266                    .muted_terminals
267                    .contains(&session.terminal_id.to_string())
268                    || cache.muted_terminals.contains(&session.pane_id.to_string());
269                if session.muted {
270                    migrated_muted_terminals.insert(session.terminal_id.to_string());
271                }
272                for pane in &mut session.panes {
273                    pane.outcome_acknowledged = pane.agent_status
274                        == crate::runtime::AgentState::Done
275                        && cache
276                            .acknowledged_outcomes
277                            .get(&pane.terminal_id.to_string())
278                            == Some(&pane.revision);
279                }
280                session.outcome_acknowledged = session
281                    .panes
282                    .iter()
283                    .find(|pane| pane.pane_id == session.pane_id)
284                    .is_some_and(|pane| pane.outcome_acknowledged);
285            }
286        }
287    }
288    Ok((
289        cache.tree_selected,
290        cache.cursor_identity,
291        project_touched_unix_ms,
292        migrated_muted_terminals,
293        cache.acknowledged_outcomes,
294        cache.dismissed_integration_prompts,
295    ))
296}
297
298pub fn find_cursor_index(
299    workspace: &WorkspaceState,
300    flat: &[FlatEntry],
301    id: &CursorIdentity,
302) -> Option<usize> {
303    match id {
304        CursorIdentity::Project { path } => flat.iter().position(|entry| {
305            matches!(entry, FlatEntry::Project { idx } if workspace.projects[*idx].path.to_string_lossy() == path.as_str())
306        }),
307        CursorIdentity::Worktree { path } => flat.iter().position(|entry| {
308            matches!(entry, FlatEntry::Worktree { project_idx, worktree_idx } if workspace.projects[*project_idx].worktrees[*worktree_idx].path.to_string_lossy() == path.as_str())
309        }),
310        CursorIdentity::Session {
311            worktree_path,
312            terminal_id,
313            pane_id,
314        } => flat.iter().position(|entry| {
315            let (project_idx, worktree_idx, session_idx, pane_idx) = match entry {
316                FlatEntry::Session { project_idx, worktree_idx, session_idx } => {
317                    (*project_idx, *worktree_idx, *session_idx, None)
318                }
319                FlatEntry::Pane { project_idx, worktree_idx, session_idx, pane_idx } => {
320                    (*project_idx, *worktree_idx, *session_idx, Some(*pane_idx))
321                }
322                _ => return false,
323            };
324            let wt = &workspace.projects[project_idx].worktrees[worktree_idx];
325            let session = &wt.sessions[session_idx];
326            let (terminal, pane) = pane_idx
327                .and_then(|idx| session.panes.get(idx))
328                .map_or((session.terminal_id, session.pane_id), |pane| (pane.terminal_id, pane.pane_id));
329            wt.path.to_string_lossy() == worktree_path.as_str()
330                && terminal_id
331                    .as_ref()
332                    .map(|id| terminal.to_string() == *id)
333                    .or_else(|| pane_id.as_ref().map(|id| pane.to_string() == *id))
334                    .unwrap_or(false)
335        }),
336        CursorIdentity::RoutinesHeader { project_path } => flat.iter().position(|entry| {
337            matches!(entry, FlatEntry::RoutinesHeader { project_idx } if workspace.projects[*project_idx].path.to_string_lossy() == project_path.as_str())
338        }),
339        CursorIdentity::Routine { project_path, routine_name } => flat.iter().position(|entry| {
340            matches!(entry, FlatEntry::Routine { project_idx, routine_idx } if workspace.projects[*project_idx].path.to_string_lossy() == project_path.as_str() && workspace.projects[*project_idx].routines[*routine_idx].routine.name == *routine_name)
341        }),
342    }
343}
344
345pub fn save_cache(
346    workspace: &WorkspaceState,
347    tree_selected: usize,
348    flat: &[FlatEntry],
349    project_touched_unix_ms: &HashMap<PathBuf, u64>,
350    dismissed_integration_prompts: &HashSet<crate::integration::IntegrationTarget>,
351    sync: bool,
352) -> Option<String> {
353    let mut cache = WorkspaceCache {
354        written_at_unix_ms: Some(now_unix_ms()),
355        tree_selected,
356        cursor_identity: resolve_cursor_identity(workspace, flat, tree_selected),
357        dismissed_integration_prompts: dismissed_integration_prompts.clone(),
358        ..Default::default()
359    };
360    for project in &workspace.projects {
361        let project_path = project.path.to_string_lossy().into_owned();
362        cache
363            .project_expanded
364            .insert(project_path.clone(), project.expanded);
365        if let Some(touched_unix_ms) = project_touched_unix_ms.get(&project.path) {
366            cache
367                .project_touched_unix_ms
368                .insert(project_path.clone(), *touched_unix_ms);
369        }
370        cache
371            .routines_expanded
372            .insert(project_path, project.routines_expanded);
373        for worktree in &project.worktrees {
374            cache.worktree_expanded.insert(
375                worktree.path.to_string_lossy().into_owned(),
376                worktree.expanded,
377            );
378            cache.muted_terminals.extend(
379                worktree
380                    .sessions
381                    .iter()
382                    .filter(|s| s.muted)
383                    .map(|s| s.terminal_id.to_string()),
384            );
385            for session in &worktree.sessions {
386                for pane in &session.panes {
387                    if pane.outcome_acknowledged {
388                        cache
389                            .acknowledged_outcomes
390                            .insert(pane.terminal_id.to_string(), pane.revision);
391                    }
392                }
393            }
394        }
395    }
396    cache
397        .save(sync)
398        .err()
399        .map(|e| format!("cache save failed: {e}"))
400}
401
402pub fn resolve_cursor_identity(
403    workspace: &WorkspaceState,
404    flat: &[FlatEntry],
405    idx: usize,
406) -> Option<CursorIdentity> {
407    match flat.get(idx)? {
408        FlatEntry::Project { idx } => Some(CursorIdentity::Project {
409            path: workspace.projects[*idx].path.to_string_lossy().into_owned(),
410        }),
411        FlatEntry::Worktree {
412            project_idx,
413            worktree_idx,
414        } => Some(CursorIdentity::Worktree {
415            path: workspace.projects[*project_idx].worktrees[*worktree_idx]
416                .path
417                .to_string_lossy()
418                .into_owned(),
419        }),
420        FlatEntry::Session {
421            project_idx,
422            worktree_idx,
423            session_idx,
424        } => {
425            let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
426            Some(CursorIdentity::Session {
427                worktree_path: wt.path.to_string_lossy().into_owned(),
428                terminal_id: Some(wt.sessions[*session_idx].terminal_id.to_string()),
429                pane_id: None,
430            })
431        }
432        FlatEntry::Pane {
433            project_idx,
434            worktree_idx,
435            session_idx,
436            pane_idx,
437        } => {
438            let wt = &workspace.projects[*project_idx].worktrees[*worktree_idx];
439            Some(CursorIdentity::Session {
440                worktree_path: wt.path.to_string_lossy().into_owned(),
441                terminal_id: Some(
442                    wt.sessions[*session_idx].panes[*pane_idx]
443                        .terminal_id
444                        .to_string(),
445                ),
446                pane_id: None,
447            })
448        }
449        FlatEntry::RoutinesHeader { project_idx } => Some(CursorIdentity::RoutinesHeader {
450            project_path: workspace.projects[*project_idx]
451                .path
452                .to_string_lossy()
453                .into_owned(),
454        }),
455        FlatEntry::Routine {
456            project_idx,
457            routine_idx,
458        } => Some(CursorIdentity::Routine {
459            project_path: workspace.projects[*project_idx]
460                .path
461                .to_string_lossy()
462                .into_owned(),
463            routine_name: workspace.projects[*project_idx].routines[*routine_idx]
464                .routine
465                .name
466                .clone(),
467        }),
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    #[test]
476    fn legacy_cache_defaults_missing_dismissed_integration_prompts() {
477        let cache: WorkspaceCache = toml::from_str("tree_selected = 2\n").unwrap();
478        assert!(cache.dismissed_integration_prompts.is_empty());
479    }
480
481    #[test]
482    fn dismissed_integration_prompts_round_trip_per_agent() {
483        let cache = WorkspaceCache {
484            dismissed_integration_prompts: [crate::integration::IntegrationTarget::Pi]
485                .into_iter()
486                .collect(),
487            ..Default::default()
488        };
489        let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
490        assert_eq!(
491            decoded.dismissed_integration_prompts,
492            cache.dismissed_integration_prompts
493        );
494    }
495
496    #[test]
497    fn legacy_blanket_dismissal_is_migrated_away() {
498        let cache: WorkspaceCache =
499            toml::from_str("tree_selected = 2\nintegration_prompt_version = \"0.21.0\"\n").unwrap();
500        assert!(cache.dismissed_integration_prompts.is_empty());
501        assert!(cache.migration_needed);
502    }
503
504    #[test]
505    fn expansion_maps_round_trip_by_stable_path() {
506        let cache = WorkspaceCache {
507            project_expanded: HashMap::from([("/projects/app".into(), true)]),
508            project_touched_unix_ms: HashMap::from([("/projects/app".into(), 42)]),
509            worktree_expanded: HashMap::from([("/projects/app/feature".into(), false)]),
510            routines_expanded: HashMap::from([("/projects/app".into(), false)]),
511            ..Default::default()
512        };
513
514        let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
515
516        assert_eq!(decoded.project_expanded, cache.project_expanded);
517        assert_eq!(
518            decoded.project_touched_unix_ms,
519            cache.project_touched_unix_ms
520        );
521        assert_eq!(decoded.worktree_expanded, cache.worktree_expanded);
522        assert_eq!(decoded.routines_expanded, cache.routines_expanded);
523    }
524
525    #[test]
526    fn legacy_cache_defaults_missing_routines_expanded_map() {
527        let cache: WorkspaceCache = toml::from_str(
528            r#"[project_expanded]
529"/projects/app" = true
530
531[worktree_expanded]
532"/projects/app/main" = false
533"#,
534        )
535        .unwrap();
536
537        assert!(cache.routines_expanded.is_empty());
538        assert!(cache.project_touched_unix_ms.is_empty());
539    }
540
541    #[test]
542    fn missing_project_touch_uses_cache_write_time_once() {
543        let legacy = WorkspaceCache {
544            written_at_unix_ms: Some(41),
545            ..Default::default()
546        };
547        assert_eq!(
548            cached_project_touch_unix_ms(&legacy, "/projects/app", 99),
549            41
550        );
551
552        let current = WorkspaceCache {
553            written_at_unix_ms: Some(41),
554            project_touched_unix_ms: HashMap::from([("/projects/app".into(), 42)]),
555            ..Default::default()
556        };
557        assert_eq!(
558            cached_project_touch_unix_ms(&current, "/projects/app", 99),
559            42
560        );
561
562        assert_eq!(
563            cached_project_touch_unix_ms(&WorkspaceCache::default(), "/projects/app", 99),
564            99
565        );
566    }
567
568    #[test]
569    fn acknowledged_outcome_revisions_round_trip() {
570        let cache = WorkspaceCache {
571            acknowledged_outcomes: HashMap::from([("42".into(), 7)]),
572            ..Default::default()
573        };
574
575        let decoded: WorkspaceCache = toml::from_str(&toml::to_string(&cache).unwrap()).unwrap();
576
577        assert_eq!(decoded.acknowledged_outcomes.get("42"), Some(&7));
578    }
579
580    #[test]
581    fn legacy_tmux_and_session_fields_are_ignored() {
582        let cache: WorkspaceCache = toml::from_str(
583            r#"tmux_server_pid = 123
584sessions = { "/tmp/repo" = ["old-tmux-session"] }
585muted_sessions = ["pane-1"]
586"#,
587        )
588        .unwrap();
589        assert_eq!(cache.muted_terminals, HashSet::from(["pane-1".to_string()]));
590    }
591
592    #[test]
593    fn legacy_pane_cursor_identity_deserializes_for_live_migration() {
594        let cache: WorkspaceCache = toml::from_str(
595            r#"[cursor_identity.Session]
596worktree_path = "/repo"
597pane_id = "pane-1"
598"#,
599        )
600        .unwrap();
601        assert_eq!(
602            cache.cursor_identity,
603            Some(CursorIdentity::Session {
604                worktree_path: "/repo".into(),
605                terminal_id: None,
606                pane_id: Some("pane-1".into()),
607            })
608        );
609    }
610
611    #[test]
612    fn historical_active_group_shapes_are_discarded_on_rewrite() {
613        for historical in [
614            "active_group = \"work\"\n",
615            "active_tab = \"work\"\n",
616            "active_groups = [\"work\", \"other\"]\n",
617            "active_groups = []\n",
618        ] {
619            let cache: WorkspaceCache = toml::from_str(historical).unwrap();
620            assert!(cache.migration_needed);
621            let encoded = toml::to_string(&cache).unwrap();
622            assert!(!encoded.contains("active_group"));
623            assert!(!encoded.contains("active_groups"));
624            assert!(!encoded.contains("active_tab"));
625        }
626    }
627
628    #[test]
629    fn group_selection_is_independent_and_malformed_data_defaults_absent() {
630        let unique = SystemTime::now()
631            .duration_since(UNIX_EPOCH)
632            .unwrap()
633            .as_nanos();
634        let directory = std::env::current_dir()
635            .unwrap()
636            .join(".work/group-selection-tests")
637            .join(format!("{}-{unique}", std::process::id()));
638        std::fs::create_dir_all(&directory).unwrap();
639        let path = directory.join("group-selection-v1.toml");
640
641        assert_eq!(load_group_selection_from(&path).unwrap(), None);
642        save_group_selection_to(&path, &GroupKey::Named("work".into())).unwrap();
643        assert_eq!(
644            load_group_selection_from(&path).unwrap(),
645            Some(GroupKey::Named("work".into()))
646        );
647        std::fs::write(&path, "selected = [\n").unwrap();
648        assert_eq!(load_group_selection_from(&path).unwrap(), None);
649        std::fs::remove_dir_all(directory).unwrap();
650    }
651
652    #[test]
653    fn workspace_cache_serialization_never_carries_group_selection() {
654        let encoded = toml::to_string(&WorkspaceCache::default()).unwrap();
655        assert!(!encoded.contains("selected_group"));
656        assert!(!encoded.contains("active_group"));
657    }
658
659    #[test]
660    fn first_v2_cache_load_imports_active_tab_without_rewriting_legacy() {
661        let unique = SystemTime::now()
662            .duration_since(UNIX_EPOCH)
663            .unwrap()
664            .as_nanos();
665        let directory = std::env::current_dir()
666            .unwrap()
667            .join(".work/cache-v2-tests")
668            .join(format!("{}-{unique}", std::process::id()));
669        std::fs::create_dir_all(&directory).unwrap();
670        let canonical = directory.join("workspace-v2.toml");
671        let legacy = directory.join("workspace.toml");
672        let legacy_text = "active_tab = \"personal\"\ntree_selected = 3\n";
673        std::fs::write(&legacy, legacy_text).unwrap();
674
675        let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
676
677        assert_eq!(cache.tree_selected, 3);
678        assert_eq!(std::fs::read_to_string(&legacy).unwrap(), legacy_text);
679        let canonical_text = std::fs::read_to_string(&canonical).unwrap();
680        assert!(!canonical_text.contains("active_group"));
681        assert!(!canonical_text.contains("active_tab"));
682        std::fs::remove_dir_all(directory).unwrap();
683    }
684
685    #[test]
686    fn malformed_v2_cache_wins_without_falling_back_to_legacy() {
687        let unique = SystemTime::now()
688            .duration_since(UNIX_EPOCH)
689            .unwrap()
690            .as_nanos();
691        let directory = std::env::current_dir()
692            .unwrap()
693            .join(".work/cache-v2-tests")
694            .join(format!("malformed-{}-{unique}", std::process::id()));
695        std::fs::create_dir_all(&directory).unwrap();
696        let canonical = directory.join("workspace-v2.toml");
697        let legacy = directory.join("workspace.toml");
698        std::fs::write(&canonical, "active_group = [\n").unwrap();
699        std::fs::write(&legacy, "active_tab = \"personal\"\n").unwrap();
700
701        let cache = WorkspaceCache::load_from_paths(&canonical, &legacy).unwrap();
702
703        assert_eq!(cache.tree_selected, 0);
704        assert_eq!(
705            std::fs::read_to_string(&canonical).unwrap(),
706            "active_group = [\n"
707        );
708        std::fs::remove_dir_all(directory).unwrap();
709    }
710
711    #[test]
712    fn cursor_identity_round_trips_through_stable_terminal_id() {
713        use crate::{
714            model::workspace::{flatten_tree, Project, SessionInfo, WorktreeInfo},
715            runtime::{AgentState, PaneId, SessionId, TerminalId},
716        };
717        let workspace = WorkspaceState {
718            projects: vec![Project {
719                name: "repo".into(),
720                path: "/repo".into(),
721                default_branch: "main".into(),
722                last_agent_active_unix_ms: None,
723                last_terminal_active_unix_ms: None,
724                worktrees: vec![WorktreeInfo {
725                    name: "main".into(),
726                    branch: "main".into(),
727                    path: "/repo".into(),
728                    is_main: true,
729                    alias: None,
730                    sessions: vec![SessionInfo {
731                        session_id: SessionId(1),
732                        pane_id: PaneId(1),
733                        terminal_id: TerminalId(1),
734                        agent: None,
735                        display_name: "agent".into(),
736                        agent_status: AgentState::Working,
737                        revision: 1,
738                        layout: crate::runtime::PaneLayout::Leaf { pane_id: PaneId(1) },
739                        panes: vec![],
740                        muted: false,
741                        outcome_acknowledged: false,
742                    }],
743                    expanded: true,
744                    git_info: None,
745                    fetch_failed: false,
746                    fetch_fail_count: 0,
747                    fetch_fail_reason: None,
748                    last_fetched: None,
749                    git_info_fetched_at: None,
750                }],
751                routines: vec![],
752                routine_revision: 0,
753                routines_expanded: true,
754                config: None,
755                expanded: true,
756                missing: false,
757            }],
758        };
759        let flat = flatten_tree(&workspace);
760        let identity = resolve_cursor_identity(&workspace, &flat, 2).unwrap();
761        assert_eq!(
762            identity,
763            CursorIdentity::Session {
764                worktree_path: "/repo".into(),
765                terminal_id: Some("1".into()),
766                pane_id: None,
767            }
768        );
769        assert_eq!(find_cursor_index(&workspace, &flat, &identity), Some(2));
770    }
771}