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