Skip to main content

taskers_domain/
model.rs

1use std::collections::BTreeMap;
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Deserializer, Serialize};
5use thiserror::Error;
6use time::OffsetDateTime;
7
8use crate::{
9    AttentionState, Direction, LayoutNode, PaneId, SessionId, SignalEvent, SignalKind, SplitAxis,
10    WindowId, WorkspaceId, WorkspaceWindowId,
11};
12
13pub const SESSION_SCHEMA_VERSION: u32 = 2;
14pub const DEFAULT_WORKSPACE_WINDOW_WIDTH: i32 = 1280;
15pub const DEFAULT_WORKSPACE_WINDOW_HEIGHT: i32 = 860;
16pub const DEFAULT_WORKSPACE_WINDOW_GAP: i32 = 2;
17pub const MIN_WORKSPACE_WINDOW_WIDTH: i32 = 720;
18pub const MIN_WORKSPACE_WINDOW_HEIGHT: i32 = 420;
19pub const KEYBOARD_RESIZE_STEP: i32 = 80;
20
21#[derive(Debug, Error)]
22pub enum DomainError {
23    #[error("window {0} was not found")]
24    MissingWindow(WindowId),
25    #[error("workspace {0} was not found")]
26    MissingWorkspace(WorkspaceId),
27    #[error("workspace window {0} was not found")]
28    MissingWorkspaceWindow(WorkspaceWindowId),
29    #[error("pane {0} was not found")]
30    MissingPane(PaneId),
31    #[error("workspace {workspace_id} does not contain pane {pane_id}")]
32    PaneNotInWorkspace {
33        workspace_id: WorkspaceId,
34        pane_id: PaneId,
35    },
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum PaneKind {
41    Terminal,
42    Browser,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub struct PaneMetadata {
47    pub title: Option<String>,
48    pub cwd: Option<String>,
49    pub repo_name: Option<String>,
50    pub git_branch: Option<String>,
51    pub ports: Vec<u16>,
52    pub agent_kind: Option<String>,
53    pub last_signal_at: Option<OffsetDateTime>,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct PaneMetadataPatch {
58    pub title: Option<String>,
59    pub cwd: Option<String>,
60    pub repo_name: Option<String>,
61    pub git_branch: Option<String>,
62    pub ports: Option<Vec<u16>>,
63    pub agent_kind: Option<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct PaneRecord {
68    pub id: PaneId,
69    pub kind: PaneKind,
70    pub metadata: PaneMetadata,
71    pub attention: AttentionState,
72    pub session_id: SessionId,
73    pub command: Option<Vec<String>>,
74}
75
76impl PaneRecord {
77    pub fn new(kind: PaneKind) -> Self {
78        Self {
79            id: PaneId::new(),
80            kind,
81            metadata: PaneMetadata::default(),
82            attention: AttentionState::Normal,
83            session_id: SessionId::new(),
84            command: None,
85        }
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct NotificationItem {
91    pub pane_id: PaneId,
92    pub state: AttentionState,
93    pub message: String,
94    pub created_at: OffsetDateTime,
95    pub cleared_at: Option<OffsetDateTime>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct ActivityItem {
100    pub workspace_id: WorkspaceId,
101    pub pane_id: PaneId,
102    pub state: AttentionState,
103    pub message: String,
104    pub created_at: OffsetDateTime,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
108pub struct WorkspaceViewport {
109    #[serde(default)]
110    pub x: i32,
111    #[serde(default)]
112    pub y: i32,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116pub struct WindowFrame {
117    pub x: i32,
118    pub y: i32,
119    pub width: i32,
120    pub height: i32,
121}
122
123impl WindowFrame {
124    pub fn root() -> Self {
125        Self {
126            x: 0,
127            y: 0,
128            width: DEFAULT_WORKSPACE_WINDOW_WIDTH,
129            height: DEFAULT_WORKSPACE_WINDOW_HEIGHT,
130        }
131    }
132
133    pub fn right(self) -> i32 {
134        self.x + self.width
135    }
136
137    pub fn bottom(self) -> i32 {
138        self.y + self.height
139    }
140
141    pub fn center_x(self) -> i32 {
142        self.x + (self.width / 2)
143    }
144
145    pub fn center_y(self) -> i32 {
146        self.y + (self.height / 2)
147    }
148
149    pub fn shifted(self, direction: Direction) -> Self {
150        match direction {
151            Direction::Left => Self {
152                x: self.x - self.width - DEFAULT_WORKSPACE_WINDOW_GAP,
153                ..self
154            },
155            Direction::Right => Self {
156                x: self.x + self.width + DEFAULT_WORKSPACE_WINDOW_GAP,
157                ..self
158            },
159            Direction::Up => Self {
160                y: self.y - self.height - DEFAULT_WORKSPACE_WINDOW_GAP,
161                ..self
162            },
163            Direction::Down => Self {
164                y: self.y + self.height + DEFAULT_WORKSPACE_WINDOW_GAP,
165                ..self
166            },
167        }
168    }
169
170    pub fn resize_by_direction(&mut self, direction: Direction, amount: i32) {
171        match direction {
172            Direction::Left => {
173                self.width = (self.width - amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
174            }
175            Direction::Right => {
176                self.width = (self.width + amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
177            }
178            Direction::Up => {
179                self.height = (self.height - amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
180            }
181            Direction::Down => {
182                self.height = (self.height + amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
183            }
184        }
185    }
186
187    pub fn clamp(&mut self) {
188        self.width = self.width.max(MIN_WORKSPACE_WINDOW_WIDTH);
189        self.height = self.height.max(MIN_WORKSPACE_WINDOW_HEIGHT);
190    }
191
192    fn overlaps(self, other: Self) -> bool {
193        self.x < other.right()
194            && self.right() > other.x
195            && self.y < other.bottom()
196            && self.bottom() > other.y
197    }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct WorkspaceWindowRecord {
202    pub id: WorkspaceWindowId,
203    pub frame: WindowFrame,
204    pub layout: LayoutNode,
205    pub active_pane: PaneId,
206}
207
208impl WorkspaceWindowRecord {
209    fn new(frame: WindowFrame, pane_id: PaneId) -> Self {
210        Self {
211            id: WorkspaceWindowId::new(),
212            frame,
213            layout: LayoutNode::leaf(pane_id),
214            active_pane: pane_id,
215        }
216    }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220pub struct Workspace {
221    pub id: WorkspaceId,
222    pub label: String,
223    pub windows: IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>,
224    pub active_window: WorkspaceWindowId,
225    pub panes: IndexMap<PaneId, PaneRecord>,
226    pub active_pane: PaneId,
227    #[serde(default)]
228    pub viewport: WorkspaceViewport,
229    pub notifications: Vec<NotificationItem>,
230}
231
232impl<'de> Deserialize<'de> for Workspace {
233    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
234    where
235        D: Deserializer<'de>,
236    {
237        let workspace = match WorkspaceSerdeCompat::deserialize(deserializer)? {
238            WorkspaceSerdeCompat::Current(current) => current.into_workspace(),
239            WorkspaceSerdeCompat::Legacy(legacy) => legacy.into_workspace(),
240        };
241        Ok(workspace)
242    }
243}
244
245impl Workspace {
246    pub fn bootstrap(label: impl Into<String>) -> Self {
247        let first_pane = PaneRecord::new(PaneKind::Terminal);
248        let active_pane = first_pane.id;
249        let mut panes = IndexMap::new();
250        panes.insert(active_pane, first_pane);
251        let first_window = WorkspaceWindowRecord::new(WindowFrame::root(), active_pane);
252        let active_window = first_window.id;
253        let mut windows = IndexMap::new();
254        windows.insert(active_window, first_window);
255
256        Self {
257            id: WorkspaceId::new(),
258            label: label.into(),
259            windows,
260            active_window,
261            panes,
262            active_pane,
263            viewport: WorkspaceViewport::default(),
264            notifications: Vec::new(),
265        }
266    }
267
268    pub fn active_window_record(&self) -> Option<&WorkspaceWindowRecord> {
269        self.windows.get(&self.active_window)
270    }
271
272    pub fn active_window_record_mut(&mut self) -> Option<&mut WorkspaceWindowRecord> {
273        self.windows.get_mut(&self.active_window)
274    }
275
276    pub fn window_for_pane(&self, pane_id: PaneId) -> Option<WorkspaceWindowId> {
277        self.windows
278            .iter()
279            .find_map(|(window_id, window)| window.layout.contains(pane_id).then_some(*window_id))
280    }
281
282    fn sync_active_from_window(&mut self, window_id: WorkspaceWindowId) {
283        if let Some(window) = self.windows.get(&window_id) {
284            self.active_window = window_id;
285            self.active_pane = window.active_pane;
286        }
287    }
288
289    fn focus_window(&mut self, window_id: WorkspaceWindowId) {
290        if let Some(window) = self.windows.get(&window_id) {
291            self.active_window = window_id;
292            self.active_pane = window.active_pane;
293        }
294    }
295
296    fn focus_pane(&mut self, pane_id: PaneId) -> bool {
297        let Some(window_id) = self.window_for_pane(pane_id) else {
298            return false;
299        };
300        if let Some(window) = self.windows.get_mut(&window_id) {
301            window.active_pane = pane_id;
302        }
303        self.sync_active_from_window(window_id);
304        true
305    }
306
307    fn next_window_frame(&self, source: WindowFrame, direction: Direction) -> WindowFrame {
308        let mut candidate = source.shifted(direction);
309        while self
310            .windows
311            .values()
312            .any(|window| window.frame.overlaps(candidate))
313        {
314            candidate = candidate.shifted(direction);
315        }
316        candidate
317    }
318
319    fn top_level_neighbor(
320        &self,
321        source_window_id: WorkspaceWindowId,
322        direction: Direction,
323    ) -> Option<WorkspaceWindowId> {
324        let source = self.windows.get(&source_window_id)?.frame;
325        self.windows
326            .iter()
327            .filter(|(window_id, _)| **window_id != source_window_id)
328            .filter_map(|(window_id, window)| {
329                let primary = match direction {
330                    Direction::Left => source.center_x() - window.frame.center_x(),
331                    Direction::Right => window.frame.center_x() - source.center_x(),
332                    Direction::Up => source.center_y() - window.frame.center_y(),
333                    Direction::Down => window.frame.center_y() - source.center_y(),
334                };
335                if primary <= 0 {
336                    return None;
337                }
338
339                let secondary = match direction {
340                    Direction::Left | Direction::Right => {
341                        (window.frame.center_y() - source.center_y()).abs()
342                    }
343                    Direction::Up | Direction::Down => {
344                        (window.frame.center_x() - source.center_x()).abs()
345                    }
346                };
347                Some((*window_id, primary, secondary))
348            })
349            .min_by_key(|(_, primary, secondary)| (*primary, *secondary))
350            .map(|(window_id, _, _)| window_id)
351    }
352
353    fn fallback_window_after_close(&self, source: WindowFrame) -> Option<WorkspaceWindowId> {
354        [
355            Direction::Right,
356            Direction::Down,
357            Direction::Left,
358            Direction::Up,
359        ]
360        .into_iter()
361        .find_map(|direction| {
362            self.windows
363                .iter()
364                .filter_map(|(window_id, window)| {
365                    let primary = match direction {
366                        Direction::Left => source.center_x() - window.frame.center_x(),
367                        Direction::Right => window.frame.center_x() - source.center_x(),
368                        Direction::Up => source.center_y() - window.frame.center_y(),
369                        Direction::Down => window.frame.center_y() - source.center_y(),
370                    };
371                    if primary <= 0 {
372                        return None;
373                    }
374                    let secondary = match direction {
375                        Direction::Left | Direction::Right => {
376                            (window.frame.center_y() - source.center_y()).abs()
377                        }
378                        Direction::Up | Direction::Down => {
379                            (window.frame.center_x() - source.center_x()).abs()
380                        }
381                    };
382                    Some((*window_id, primary, secondary))
383                })
384                .min_by_key(|(_, primary, secondary)| (*primary, *secondary))
385                .map(|(window_id, _, _)| window_id)
386        })
387        .or_else(|| self.windows.first().map(|(window_id, _)| *window_id))
388    }
389
390    fn normalize(&mut self) {
391        if self.panes.is_empty() {
392            let id = self.id;
393            let label = self.label.clone();
394            *self = Self::bootstrap(label);
395            self.id = id;
396            return;
397        }
398
399        if self.windows.is_empty() {
400            let fallback_pane = self
401                .panes
402                .first()
403                .map(|(pane_id, _)| *pane_id)
404                .expect("workspace has at least one pane");
405            let fallback_window = WorkspaceWindowRecord::new(WindowFrame::root(), fallback_pane);
406            self.active_window = fallback_window.id;
407            self.active_pane = fallback_pane;
408            self.windows.insert(fallback_window.id, fallback_window);
409        }
410
411        for window in self.windows.values_mut() {
412            if !window.layout.contains(window.active_pane) {
413                window.active_pane = window
414                    .layout
415                    .leaves()
416                    .into_iter()
417                    .find(|pane_id| self.panes.contains_key(pane_id))
418                    .or_else(|| self.panes.first().map(|(pane_id, _)| *pane_id))
419                    .expect("workspace has at least one pane");
420            }
421        }
422
423        if !self.windows.contains_key(&self.active_window) {
424            self.active_window = self
425                .windows
426                .first()
427                .map(|(window_id, _)| *window_id)
428                .expect("workspace has at least one window");
429        }
430        if !self
431            .windows
432            .get(&self.active_window)
433            .is_some_and(|window| window.layout.contains(self.active_pane))
434        {
435            self.active_pane = self
436                .windows
437                .get(&self.active_window)
438                .map(|window| window.active_pane)
439                .expect("active window exists");
440        }
441    }
442
443    pub fn repo_hint(&self) -> Option<&str> {
444        self.panes
445            .values()
446            .find_map(|pane| pane.metadata.repo_name.as_deref())
447    }
448
449    pub fn attention_counts(&self) -> BTreeMap<AttentionState, usize> {
450        let mut counts = BTreeMap::new();
451        for pane in self.panes.values() {
452            *counts.entry(pane.attention).or_insert(0) += 1;
453        }
454        counts
455    }
456}
457
458#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
459pub struct WorkspaceSummary {
460    pub workspace_id: WorkspaceId,
461    pub label: String,
462    pub active_pane: PaneId,
463    pub repo_hint: Option<String>,
464    pub counts_by_attention: BTreeMap<AttentionState, usize>,
465    pub highest_attention: AttentionState,
466}
467
468#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
469pub struct WindowRecord {
470    pub id: WindowId,
471    pub workspace_order: Vec<WorkspaceId>,
472    pub active_workspace: WorkspaceId,
473}
474
475#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
476pub struct AppModel {
477    pub active_window: WindowId,
478    pub windows: IndexMap<WindowId, WindowRecord>,
479    pub workspaces: IndexMap<WorkspaceId, Workspace>,
480}
481
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483pub struct PersistedSession {
484    pub schema_version: u32,
485    pub captured_at: OffsetDateTime,
486    pub model: AppModel,
487}
488
489impl AppModel {
490    pub fn new(label: impl Into<String>) -> Self {
491        let window_id = WindowId::new();
492        let workspace = Workspace::bootstrap(label);
493        let workspace_id = workspace.id;
494
495        let mut windows = IndexMap::new();
496        windows.insert(
497            window_id,
498            WindowRecord {
499                id: window_id,
500                workspace_order: vec![workspace_id],
501                active_workspace: workspace_id,
502            },
503        );
504
505        let mut workspaces = IndexMap::new();
506        workspaces.insert(workspace_id, workspace);
507
508        Self {
509            active_window: window_id,
510            windows,
511            workspaces,
512        }
513    }
514
515    pub fn demo() -> Self {
516        let mut model = Self::new("Repo A");
517        let primary_workspace = model.active_workspace_id().unwrap_or_else(WorkspaceId::new);
518        let first_pane = model
519            .active_workspace()
520            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
521            .unwrap_or_else(PaneId::new);
522
523        let _ = model.update_pane_metadata(
524            first_pane,
525            PaneMetadataPatch {
526                title: Some("Codex".into()),
527                cwd: Some("/home/notes/Projects/taskers".into()),
528                repo_name: Some("taskers".into()),
529                git_branch: Some("main".into()),
530                ports: Some(vec![3000]),
531                agent_kind: Some("codex".into()),
532            },
533        );
534        let _ = model.apply_signal(
535            primary_workspace,
536            first_pane,
537            SignalEvent::new(
538                "demo",
539                SignalKind::WaitingInput,
540                Some("Waiting for review on workspace bootstrap".into()),
541            ),
542        );
543
544        let second_window_pane = model
545            .create_workspace_window(primary_workspace, Direction::Right)
546            .unwrap_or(first_pane);
547        let _ = model.update_pane_metadata(
548            second_window_pane,
549            PaneMetadataPatch {
550                title: Some("Claude".into()),
551                cwd: Some("/home/notes/Projects/taskers".into()),
552                repo_name: Some("taskers".into()),
553                git_branch: Some("feature/bootstrap".into()),
554                ports: Some(vec![]),
555                agent_kind: Some("claude".into()),
556            },
557        );
558        let split_pane = model
559            .split_pane(
560                primary_workspace,
561                Some(second_window_pane),
562                SplitAxis::Vertical,
563            )
564            .unwrap_or(second_window_pane);
565        let _ = model.apply_signal(
566            primary_workspace,
567            split_pane,
568            SignalEvent::new(
569                "demo",
570                SignalKind::Progress,
571                Some("Running long task".into()),
572            ),
573        );
574
575        let second_workspace = model.create_workspace("Docs");
576        let second_pane = model
577            .workspaces
578            .get(&second_workspace)
579            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
580            .unwrap_or_else(PaneId::new);
581        let _ = model.update_pane_metadata(
582            second_pane,
583            PaneMetadataPatch {
584                title: Some("OpenCode".into()),
585                cwd: Some("/home/notes/Documents".into()),
586                repo_name: Some("notes".into()),
587                git_branch: Some("docs".into()),
588                ports: Some(vec![8080, 8081]),
589                agent_kind: Some("opencode".into()),
590            },
591        );
592        let _ = model.apply_signal(
593            second_workspace,
594            second_pane,
595            SignalEvent::new(
596                "demo",
597                SignalKind::Completed,
598                Some("Draft completed, ready for merge".into()),
599            ),
600        );
601        let _ = model.switch_workspace(model.active_window, second_workspace);
602
603        model
604    }
605
606    pub fn active_window(&self) -> Option<&WindowRecord> {
607        self.windows.get(&self.active_window)
608    }
609
610    pub fn active_workspace_id(&self) -> Option<WorkspaceId> {
611        self.active_window().map(|window| window.active_workspace)
612    }
613
614    pub fn active_workspace(&self) -> Option<&Workspace> {
615        self.active_workspace_id()
616            .and_then(|workspace_id| self.workspaces.get(&workspace_id))
617    }
618
619    pub fn create_workspace(&mut self, label: impl Into<String>) -> WorkspaceId {
620        let workspace = Workspace::bootstrap(label);
621        let workspace_id = workspace.id;
622        self.workspaces.insert(workspace_id, workspace);
623        if let Some(window) = self.windows.get_mut(&self.active_window) {
624            window.workspace_order.push(workspace_id);
625            window.active_workspace = workspace_id;
626        }
627        workspace_id
628    }
629
630    pub fn rename_workspace(
631        &mut self,
632        workspace_id: WorkspaceId,
633        label: impl Into<String>,
634    ) -> Result<(), DomainError> {
635        let workspace = self
636            .workspaces
637            .get_mut(&workspace_id)
638            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
639        workspace.label = label.into();
640        Ok(())
641    }
642
643    pub fn switch_workspace(
644        &mut self,
645        window_id: WindowId,
646        workspace_id: WorkspaceId,
647    ) -> Result<(), DomainError> {
648        let window = self
649            .windows
650            .get_mut(&window_id)
651            .ok_or(DomainError::MissingWindow(window_id))?;
652        if !window.workspace_order.contains(&workspace_id) {
653            return Err(DomainError::MissingWorkspace(workspace_id));
654        }
655        window.active_workspace = workspace_id;
656        Ok(())
657    }
658
659    pub fn create_workspace_window(
660        &mut self,
661        workspace_id: WorkspaceId,
662        direction: Direction,
663    ) -> Result<PaneId, DomainError> {
664        let workspace = self
665            .workspaces
666            .get_mut(&workspace_id)
667            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
668
669        let source_frame = workspace
670            .active_window_record()
671            .map(|window| window.frame)
672            .unwrap_or_else(WindowFrame::root);
673        let new_pane = PaneRecord::new(PaneKind::Terminal);
674        let new_pane_id = new_pane.id;
675        workspace.panes.insert(new_pane_id, new_pane);
676
677        let frame = workspace.next_window_frame(source_frame, direction);
678        let new_window = WorkspaceWindowRecord::new(frame, new_pane_id);
679        let new_window_id = new_window.id;
680        workspace.windows.insert(new_window_id, new_window);
681        workspace.sync_active_from_window(new_window_id);
682
683        Ok(new_pane_id)
684    }
685
686    pub fn split_pane(
687        &mut self,
688        workspace_id: WorkspaceId,
689        target_pane: Option<PaneId>,
690        axis: SplitAxis,
691    ) -> Result<PaneId, DomainError> {
692        let workspace = self
693            .workspaces
694            .get_mut(&workspace_id)
695            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
696
697        let target = target_pane.unwrap_or(workspace.active_pane);
698        if !workspace.panes.contains_key(&target) {
699            return Err(DomainError::PaneNotInWorkspace {
700                workspace_id,
701                pane_id: target,
702            });
703        }
704
705        let window_id = workspace
706            .window_for_pane(target)
707            .ok_or(DomainError::MissingPane(target))?;
708        let new_pane = PaneRecord::new(PaneKind::Terminal);
709        let new_pane_id = new_pane.id;
710        workspace.panes.insert(new_pane_id, new_pane);
711
712        if let Some(window) = workspace.windows.get_mut(&window_id) {
713            window.layout.split_leaf(target, axis, new_pane_id, 500);
714            window.active_pane = new_pane_id;
715        }
716        workspace.sync_active_from_window(window_id);
717
718        Ok(new_pane_id)
719    }
720
721    pub fn focus_workspace_window(
722        &mut self,
723        workspace_id: WorkspaceId,
724        workspace_window_id: WorkspaceWindowId,
725    ) -> Result<(), DomainError> {
726        let workspace = self
727            .workspaces
728            .get_mut(&workspace_id)
729            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
730        if !workspace.windows.contains_key(&workspace_window_id) {
731            return Err(DomainError::MissingWorkspaceWindow(workspace_window_id));
732        }
733        workspace.focus_window(workspace_window_id);
734        Ok(())
735    }
736
737    pub fn focus_pane(
738        &mut self,
739        workspace_id: WorkspaceId,
740        pane_id: PaneId,
741    ) -> Result<(), DomainError> {
742        let workspace = self
743            .workspaces
744            .get_mut(&workspace_id)
745            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
746
747        if !workspace.panes.contains_key(&pane_id) {
748            return Err(DomainError::PaneNotInWorkspace {
749                workspace_id,
750                pane_id,
751            });
752        }
753
754        workspace.focus_pane(pane_id);
755        Ok(())
756    }
757
758    pub fn focus_pane_direction(
759        &mut self,
760        workspace_id: WorkspaceId,
761        direction: Direction,
762    ) -> Result<(), DomainError> {
763        let workspace = self
764            .workspaces
765            .get_mut(&workspace_id)
766            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
767        let active_window_id = workspace.active_window;
768
769        if let Some(next_window_id) = workspace.top_level_neighbor(active_window_id, direction) {
770            workspace.focus_window(next_window_id);
771            return Ok(());
772        }
773
774        let next_pane = workspace
775            .windows
776            .get(&active_window_id)
777            .and_then(|window| window.layout.focus_neighbor(window.active_pane, direction));
778        if let Some(next_pane) = next_pane {
779            if let Some(window) = workspace.windows.get_mut(&active_window_id) {
780                window.active_pane = next_pane;
781            }
782            workspace.sync_active_from_window(active_window_id);
783        }
784
785        Ok(())
786    }
787
788    pub fn resize_active_window(
789        &mut self,
790        workspace_id: WorkspaceId,
791        direction: Direction,
792        amount: i32,
793    ) -> Result<(), DomainError> {
794        let workspace = self
795            .workspaces
796            .get_mut(&workspace_id)
797            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
798        let active_window = workspace.active_window;
799        let window = workspace
800            .active_window_record_mut()
801            .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
802        window.frame.resize_by_direction(direction, amount);
803        window.frame.clamp();
804        Ok(())
805    }
806
807    pub fn resize_active_pane_split(
808        &mut self,
809        workspace_id: WorkspaceId,
810        direction: Direction,
811        amount: i32,
812    ) -> Result<(), DomainError> {
813        let workspace = self
814            .workspaces
815            .get_mut(&workspace_id)
816            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
817        let active_window_id = workspace.active_window;
818        let active_pane = workspace.active_pane;
819        let window = workspace
820            .windows
821            .get_mut(&active_window_id)
822            .ok_or(DomainError::MissingWorkspaceWindow(active_window_id))?;
823        window.layout.resize_leaf(active_pane, direction, amount);
824        Ok(())
825    }
826
827    pub fn set_workspace_window_frame(
828        &mut self,
829        workspace_id: WorkspaceId,
830        workspace_window_id: WorkspaceWindowId,
831        mut frame: WindowFrame,
832    ) -> Result<(), DomainError> {
833        let workspace = self
834            .workspaces
835            .get_mut(&workspace_id)
836            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
837        let window = workspace
838            .windows
839            .get_mut(&workspace_window_id)
840            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
841        frame.clamp();
842        window.frame = frame;
843        Ok(())
844    }
845
846    pub fn set_window_split_ratio(
847        &mut self,
848        workspace_id: WorkspaceId,
849        workspace_window_id: WorkspaceWindowId,
850        path: &[bool],
851        ratio: u16,
852    ) -> Result<(), DomainError> {
853        let workspace = self
854            .workspaces
855            .get_mut(&workspace_id)
856            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
857        let window = workspace
858            .windows
859            .get_mut(&workspace_window_id)
860            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
861        window.layout.set_ratio_at_path(path, ratio);
862        Ok(())
863    }
864
865    pub fn set_workspace_viewport(
866        &mut self,
867        workspace_id: WorkspaceId,
868        viewport: WorkspaceViewport,
869    ) -> Result<(), DomainError> {
870        let workspace = self
871            .workspaces
872            .get_mut(&workspace_id)
873            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
874        workspace.viewport = viewport;
875        Ok(())
876    }
877
878    pub fn update_pane_metadata(
879        &mut self,
880        pane_id: PaneId,
881        patch: PaneMetadataPatch,
882    ) -> Result<(), DomainError> {
883        let pane = self
884            .workspaces
885            .values_mut()
886            .find_map(|workspace| workspace.panes.get_mut(&pane_id))
887            .ok_or(DomainError::MissingPane(pane_id))?;
888
889        if patch.title.is_some() {
890            pane.metadata.title = patch.title;
891        }
892        if patch.cwd.is_some() {
893            pane.metadata.cwd = patch.cwd;
894        }
895        if patch.repo_name.is_some() {
896            pane.metadata.repo_name = patch.repo_name;
897        }
898        if patch.git_branch.is_some() {
899            pane.metadata.git_branch = patch.git_branch;
900        }
901        if let Some(ports) = patch.ports {
902            pane.metadata.ports = ports;
903        }
904        if patch.agent_kind.is_some() {
905            pane.metadata.agent_kind = patch.agent_kind;
906        }
907
908        Ok(())
909    }
910
911    pub fn apply_signal(
912        &mut self,
913        workspace_id: WorkspaceId,
914        pane_id: PaneId,
915        event: SignalEvent,
916    ) -> Result<(), DomainError> {
917        let workspace = self
918            .workspaces
919            .get_mut(&workspace_id)
920            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
921        let pane = workspace
922            .panes
923            .get_mut(&pane_id)
924            .ok_or(DomainError::PaneNotInWorkspace {
925                workspace_id,
926                pane_id,
927            })?;
928
929        pane.metadata.last_signal_at = Some(event.timestamp);
930        pane.attention = map_signal_to_attention(&event.kind);
931
932        if let Some(message) = event.message {
933            workspace.notifications.push(NotificationItem {
934                pane_id,
935                state: pane.attention,
936                message,
937                created_at: event.timestamp,
938                cleared_at: None,
939            });
940        }
941
942        Ok(())
943    }
944
945    pub fn close_pane(
946        &mut self,
947        workspace_id: WorkspaceId,
948        pane_id: PaneId,
949    ) -> Result<(), DomainError> {
950        {
951            let workspace = self
952                .workspaces
953                .get(&workspace_id)
954                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
955
956            if !workspace.panes.contains_key(&pane_id) {
957                return Err(DomainError::PaneNotInWorkspace {
958                    workspace_id,
959                    pane_id,
960                });
961            }
962
963            if workspace.panes.len() <= 1 {
964                return self.close_workspace(workspace_id);
965            }
966        }
967
968        let workspace = self
969            .workspaces
970            .get_mut(&workspace_id)
971            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
972        let window_id = workspace
973            .window_for_pane(pane_id)
974            .ok_or(DomainError::MissingPane(pane_id))?;
975
976        let window_leaf_count = workspace
977            .windows
978            .get(&window_id)
979            .map(|window| window.layout.leaves().len())
980            .unwrap_or_default();
981        if window_leaf_count <= 1 && workspace.windows.len() > 1 {
982            let source_frame = workspace
983                .windows
984                .get(&window_id)
985                .map(|window| window.frame)
986                .expect("window exists");
987            workspace.windows.shift_remove(&window_id);
988            workspace.panes.shift_remove(&pane_id);
989            workspace
990                .notifications
991                .retain(|item| item.pane_id != pane_id);
992            if let Some(next_window_id) = workspace.fallback_window_after_close(source_frame) {
993                workspace.sync_active_from_window(next_window_id);
994            }
995            return Ok(());
996        }
997
998        if let Some(window) = workspace.windows.get_mut(&window_id) {
999            let fallback_focus = close_layout_pane(window, pane_id)
1000                .or_else(|| window.layout.leaves().into_iter().next())
1001                .expect("window should retain at least one pane");
1002            window.active_pane = fallback_focus;
1003        }
1004        workspace.panes.shift_remove(&pane_id);
1005        workspace
1006            .notifications
1007            .retain(|item| item.pane_id != pane_id);
1008
1009        if workspace.active_window == window_id {
1010            workspace.sync_active_from_window(window_id);
1011        } else if workspace.active_pane == pane_id {
1012            workspace.sync_active_from_window(workspace.active_window);
1013        }
1014
1015        Ok(())
1016    }
1017
1018    pub fn close_workspace(&mut self, workspace_id: WorkspaceId) -> Result<(), DomainError> {
1019        if !self.workspaces.contains_key(&workspace_id) {
1020            return Err(DomainError::MissingWorkspace(workspace_id));
1021        }
1022
1023        if self.workspaces.len() <= 1 {
1024            self.create_workspace("Workspace 1");
1025        }
1026
1027        self.workspaces.shift_remove(&workspace_id);
1028
1029        for window in self.windows.values_mut() {
1030            window.workspace_order.retain(|id| *id != workspace_id);
1031            if window.active_workspace == workspace_id
1032                && let Some(first) = window.workspace_order.first()
1033            {
1034                window.active_workspace = *first;
1035            }
1036        }
1037
1038        Ok(())
1039    }
1040
1041    pub fn workspace_summaries(
1042        &self,
1043        window_id: WindowId,
1044    ) -> Result<Vec<WorkspaceSummary>, DomainError> {
1045        let window = self
1046            .windows
1047            .get(&window_id)
1048            .ok_or(DomainError::MissingWindow(window_id))?;
1049
1050        let summaries = window
1051            .workspace_order
1052            .iter()
1053            .filter_map(|workspace_id| self.workspaces.get(workspace_id))
1054            .map(|workspace| {
1055                let counts = workspace.attention_counts();
1056                let highest_attention = workspace
1057                    .panes
1058                    .values()
1059                    .map(|pane| pane.attention)
1060                    .max_by_key(|attention| attention.rank())
1061                    .unwrap_or(AttentionState::Normal);
1062
1063                WorkspaceSummary {
1064                    workspace_id: workspace.id,
1065                    label: workspace.label.clone(),
1066                    active_pane: workspace.active_pane,
1067                    repo_hint: workspace.repo_hint().map(str::to_owned),
1068                    counts_by_attention: counts,
1069                    highest_attention,
1070                }
1071            })
1072            .collect();
1073
1074        Ok(summaries)
1075    }
1076
1077    pub fn activity_items(&self) -> Vec<ActivityItem> {
1078        let mut items = self
1079            .workspaces
1080            .values()
1081            .flat_map(|workspace| {
1082                workspace
1083                    .notifications
1084                    .iter()
1085                    .map(move |notification| ActivityItem {
1086                        workspace_id: workspace.id,
1087                        pane_id: notification.pane_id,
1088                        state: notification.state,
1089                        message: notification.message.clone(),
1090                        created_at: notification.created_at,
1091                    })
1092            })
1093            .collect::<Vec<_>>();
1094
1095        items.sort_by(|left, right| right.created_at.cmp(&left.created_at));
1096        items
1097    }
1098
1099    pub fn snapshot(&self) -> PersistedSession {
1100        PersistedSession {
1101            schema_version: SESSION_SCHEMA_VERSION,
1102            captured_at: OffsetDateTime::now_utc(),
1103            model: self.clone(),
1104        }
1105    }
1106}
1107
1108#[derive(Debug, Deserialize)]
1109#[serde(untagged)]
1110enum WorkspaceSerdeCompat {
1111    Current(CurrentWorkspaceSerde),
1112    Legacy(LegacyWorkspaceSerde),
1113}
1114
1115#[derive(Debug, Deserialize)]
1116struct CurrentWorkspaceSerde {
1117    id: WorkspaceId,
1118    label: String,
1119    windows: IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>,
1120    active_window: WorkspaceWindowId,
1121    panes: IndexMap<PaneId, PaneRecord>,
1122    active_pane: PaneId,
1123    #[serde(default)]
1124    viewport: WorkspaceViewport,
1125    #[serde(default)]
1126    notifications: Vec<NotificationItem>,
1127}
1128
1129impl CurrentWorkspaceSerde {
1130    fn into_workspace(self) -> Workspace {
1131        let mut workspace = Workspace {
1132            id: self.id,
1133            label: self.label,
1134            windows: self.windows,
1135            active_window: self.active_window,
1136            panes: self.panes,
1137            active_pane: self.active_pane,
1138            viewport: self.viewport,
1139            notifications: self.notifications,
1140        };
1141        workspace.normalize();
1142        workspace
1143    }
1144}
1145
1146#[derive(Debug, Deserialize)]
1147struct LegacyWorkspaceSerde {
1148    id: WorkspaceId,
1149    label: String,
1150    layout: LegacyWorkspaceLayout,
1151    panes: IndexMap<PaneId, PaneRecord>,
1152    active_pane: PaneId,
1153    #[serde(default)]
1154    notifications: Vec<NotificationItem>,
1155}
1156
1157impl LegacyWorkspaceSerde {
1158    fn into_workspace(self) -> Workspace {
1159        let mut windows = IndexMap::new();
1160        let mut viewport = WorkspaceViewport::default();
1161        let mut active_window = None;
1162        let preferred_active_pane = if self.panes.contains_key(&self.active_pane) {
1163            self.active_pane
1164        } else {
1165            self.panes
1166                .first()
1167                .map(|(pane_id, _)| *pane_id)
1168                .unwrap_or_else(PaneId::new)
1169        };
1170
1171        match self.layout {
1172            LegacyWorkspaceLayout::SplitTree(layout) => {
1173                let active_pane = active_pane_for_layout(&layout, preferred_active_pane);
1174                let window = WorkspaceWindowRecord {
1175                    id: WorkspaceWindowId::new(),
1176                    frame: WindowFrame::root(),
1177                    layout,
1178                    active_pane,
1179                };
1180                active_window = Some(window.id);
1181                windows.insert(window.id, window);
1182            }
1183            LegacyWorkspaceLayout::Scrollable(scrollable) => {
1184                viewport = scrollable.viewport;
1185                for (index, column) in scrollable.columns.into_iter().enumerate() {
1186                    let Some(layout) = layout_from_pane_stack(&column.panes) else {
1187                        continue;
1188                    };
1189                    let active_pane = active_pane_for_layout(&layout, preferred_active_pane);
1190                    let frame = WindowFrame {
1191                        x: index as i32
1192                            * (DEFAULT_WORKSPACE_WINDOW_WIDTH + DEFAULT_WORKSPACE_WINDOW_GAP),
1193                        y: 0,
1194                        width: DEFAULT_WORKSPACE_WINDOW_WIDTH,
1195                        height: DEFAULT_WORKSPACE_WINDOW_HEIGHT,
1196                    };
1197                    let window = WorkspaceWindowRecord {
1198                        id: WorkspaceWindowId::new(),
1199                        frame,
1200                        layout,
1201                        active_pane,
1202                    };
1203                    if window.layout.contains(preferred_active_pane) {
1204                        active_window = Some(window.id);
1205                    }
1206                    windows.insert(window.id, window);
1207                }
1208            }
1209        }
1210
1211        let mut workspace = Workspace {
1212            id: self.id,
1213            label: self.label,
1214            windows,
1215            active_window: active_window.unwrap_or_else(WorkspaceWindowId::new),
1216            panes: self.panes,
1217            active_pane: preferred_active_pane,
1218            viewport,
1219            notifications: self.notifications,
1220        };
1221        workspace.normalize();
1222        workspace
1223    }
1224}
1225
1226#[derive(Debug, Deserialize)]
1227#[serde(untagged)]
1228enum LegacyWorkspaceLayout {
1229    Scrollable(LegacyScrollableLayout),
1230    SplitTree(LayoutNode),
1231}
1232
1233#[derive(Debug, Deserialize)]
1234struct LegacyScrollableLayout {
1235    #[serde(rename = "kind")]
1236    _kind: String,
1237    columns: Vec<LegacyPaneColumn>,
1238    #[serde(default)]
1239    viewport: WorkspaceViewport,
1240}
1241
1242#[derive(Debug, Deserialize)]
1243struct LegacyPaneColumn {
1244    panes: Vec<PaneId>,
1245}
1246
1247fn active_pane_for_layout(layout: &LayoutNode, preferred: PaneId) -> PaneId {
1248    if layout.contains(preferred) {
1249        preferred
1250    } else {
1251        layout
1252            .leaves()
1253            .into_iter()
1254            .next()
1255            .expect("legacy layout should contain at least one pane")
1256    }
1257}
1258
1259fn layout_from_pane_stack(panes: &[PaneId]) -> Option<LayoutNode> {
1260    let (first, rest) = panes.split_first()?;
1261    let mut layout = LayoutNode::leaf(*first);
1262    for pane_id in rest {
1263        layout = LayoutNode::Split {
1264            axis: SplitAxis::Vertical,
1265            ratio: 500,
1266            first: Box::new(layout),
1267            second: Box::new(LayoutNode::leaf(*pane_id)),
1268        };
1269    }
1270    Some(layout)
1271}
1272
1273fn close_layout_pane(window: &mut WorkspaceWindowRecord, pane_id: PaneId) -> Option<PaneId> {
1274    let fallback = [
1275        Direction::Right,
1276        Direction::Down,
1277        Direction::Left,
1278        Direction::Up,
1279    ]
1280    .into_iter()
1281    .find_map(|direction| window.layout.focus_neighbor(pane_id, direction))
1282    .or_else(|| {
1283        window
1284            .layout
1285            .leaves()
1286            .into_iter()
1287            .find(|candidate| *candidate != pane_id)
1288    });
1289    let removed = window.layout.remove_leaf(pane_id);
1290    removed.then_some(fallback).flatten()
1291}
1292
1293fn map_signal_to_attention(kind: &SignalKind) -> AttentionState {
1294    match kind {
1295        SignalKind::Started | SignalKind::Progress => AttentionState::Busy,
1296        SignalKind::Completed => AttentionState::Completed,
1297        SignalKind::WaitingInput => AttentionState::WaitingInput,
1298        SignalKind::Error => AttentionState::Error,
1299        SignalKind::Notification => AttentionState::Busy,
1300    }
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305    use serde_json::json;
1306
1307    use super::*;
1308
1309    #[test]
1310    fn creating_workspace_windows_updates_focus_and_frame() {
1311        let mut model = AppModel::new("Main");
1312        let workspace_id = model.active_workspace_id().expect("workspace");
1313        let first_window = model
1314            .active_workspace()
1315            .and_then(|workspace| workspace.active_window_record().map(|window| window.frame))
1316            .expect("window");
1317
1318        let new_pane = model
1319            .create_workspace_window(workspace_id, Direction::Right)
1320            .expect("window created");
1321        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
1322        let active_window = workspace.active_window_record().expect("active window");
1323
1324        assert_eq!(workspace.windows.len(), 2);
1325        assert_eq!(workspace.active_pane, new_pane);
1326        assert_eq!(
1327            active_window.frame.x,
1328            first_window.x + first_window.width + DEFAULT_WORKSPACE_WINDOW_GAP
1329        );
1330        assert_eq!(active_window.frame.y, first_window.y);
1331    }
1332
1333    #[test]
1334    fn split_pane_updates_inner_layout_and_focus() {
1335        let mut model = AppModel::new("Main");
1336        let workspace_id = model.active_workspace_id().expect("workspace");
1337        let first_pane = model
1338            .active_workspace()
1339            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1340            .expect("pane");
1341
1342        let new_pane = model
1343            .split_pane(workspace_id, Some(first_pane), SplitAxis::Vertical)
1344            .expect("split works");
1345        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
1346        let active_window = workspace.active_window_record().expect("window");
1347
1348        assert_eq!(workspace.active_pane, new_pane);
1349        assert_eq!(active_window.layout.leaves(), vec![first_pane, new_pane]);
1350    }
1351
1352    #[test]
1353    fn directional_focus_prefers_top_level_windows_and_restores_inner_focus() {
1354        let mut model = AppModel::new("Main");
1355        let workspace_id = model.active_workspace_id().expect("workspace");
1356        let first_pane = model
1357            .active_workspace()
1358            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1359            .expect("pane");
1360        let right_window_pane = model
1361            .create_workspace_window(workspace_id, Direction::Right)
1362            .expect("window");
1363        let lower_right_pane = model
1364            .split_pane(workspace_id, Some(right_window_pane), SplitAxis::Vertical)
1365            .expect("split");
1366
1367        model
1368            .focus_pane(workspace_id, right_window_pane)
1369            .expect("focus old pane in right window");
1370        model
1371            .focus_pane(workspace_id, first_pane)
1372            .expect("focus left window");
1373        model
1374            .focus_pane_direction(workspace_id, Direction::Right)
1375            .expect("move right");
1376
1377        assert_eq!(
1378            model
1379                .workspaces
1380                .get(&workspace_id)
1381                .expect("workspace")
1382                .active_pane,
1383            right_window_pane
1384        );
1385
1386        model
1387            .focus_pane(workspace_id, lower_right_pane)
1388            .expect("focus lower pane");
1389        model
1390            .focus_pane_direction(workspace_id, Direction::Left)
1391            .expect("move left");
1392        model
1393            .focus_pane_direction(workspace_id, Direction::Right)
1394            .expect("move right again");
1395
1396        assert_eq!(
1397            model
1398                .workspaces
1399                .get(&workspace_id)
1400                .expect("workspace")
1401                .active_pane,
1402            lower_right_pane
1403        );
1404    }
1405
1406    #[test]
1407    fn closing_last_pane_in_window_removes_window_and_falls_back() {
1408        let mut model = AppModel::new("Main");
1409        let workspace_id = model.active_workspace_id().expect("workspace");
1410        let right_window_pane = model
1411            .create_workspace_window(workspace_id, Direction::Right)
1412            .expect("window");
1413
1414        model
1415            .close_pane(workspace_id, right_window_pane)
1416            .expect("close pane");
1417
1418        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
1419        assert_eq!(workspace.windows.len(), 1);
1420        assert!(!workspace.panes.contains_key(&right_window_pane));
1421        assert_ne!(workspace.active_pane, right_window_pane);
1422    }
1423
1424    #[test]
1425    fn resizing_window_and_split_updates_state() {
1426        let mut model = AppModel::new("Main");
1427        let workspace_id = model.active_workspace_id().expect("workspace");
1428        let first_pane = model
1429            .active_workspace()
1430            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1431            .expect("pane");
1432        let second_pane = model
1433            .split_pane(workspace_id, Some(first_pane), SplitAxis::Horizontal)
1434            .expect("split");
1435
1436        model
1437            .focus_pane(workspace_id, second_pane)
1438            .expect("focus second pane");
1439        model
1440            .resize_active_pane_split(workspace_id, Direction::Right, 60)
1441            .expect("resize split");
1442        model
1443            .resize_active_window(workspace_id, Direction::Right, 120)
1444            .expect("resize window");
1445
1446        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
1447        let window = workspace.active_window_record().expect("window");
1448        let LayoutNode::Split { ratio, .. } = &window.layout else {
1449            panic!("expected split layout");
1450        };
1451        assert_eq!(*ratio, 440);
1452        assert_eq!(window.frame.width, DEFAULT_WORKSPACE_WINDOW_WIDTH + 120);
1453    }
1454
1455    #[test]
1456    fn legacy_scrollable_layouts_deserialize_into_workspace_windows() {
1457        let workspace_id = WorkspaceId::new();
1458        let window_id = WindowId::new();
1459        let left_pane = PaneRecord::new(PaneKind::Terminal);
1460        let right_pane = PaneRecord::new(PaneKind::Terminal);
1461
1462        let encoded = json!({
1463            "schema_version": 1,
1464            "captured_at": OffsetDateTime::now_utc(),
1465            "model": {
1466                "active_window": window_id,
1467                "windows": {
1468                    window_id.to_string(): {
1469                        "id": window_id,
1470                        "workspace_order": [workspace_id],
1471                        "active_workspace": workspace_id
1472                    }
1473                },
1474                "workspaces": {
1475                    workspace_id.to_string(): {
1476                        "id": workspace_id,
1477                        "label": "Main",
1478                        "layout": {
1479                            "kind": "scrollable_tiling",
1480                            "columns": [
1481                                {"panes": [left_pane.id]},
1482                                {"panes": [right_pane.id]}
1483                            ],
1484                            "viewport": {"x": 64, "y": 24}
1485                        },
1486                        "panes": {
1487                            left_pane.id.to_string(): left_pane,
1488                            right_pane.id.to_string(): right_pane
1489                        },
1490                        "active_pane": right_pane.id,
1491                        "notifications": []
1492                    }
1493                }
1494            }
1495        });
1496
1497        let decoded: PersistedSession =
1498            serde_json::from_value(encoded).expect("legacy session should deserialize");
1499        let workspace = decoded
1500            .model
1501            .workspaces
1502            .get(&workspace_id)
1503            .expect("workspace exists");
1504
1505        assert_eq!(workspace.windows.len(), 2);
1506        assert_eq!(workspace.viewport.x, 64);
1507        assert_eq!(workspace.viewport.y, 24);
1508        assert_eq!(workspace.active_pane, right_pane.id);
1509    }
1510
1511    #[test]
1512    fn signals_flow_into_activity_and_summary() {
1513        let mut model = AppModel::new("Main");
1514        let workspace_id = model.active_workspace_id().expect("workspace");
1515        let pane_id = model
1516            .active_workspace()
1517            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1518            .expect("pane");
1519
1520        model
1521            .apply_signal(
1522                workspace_id,
1523                pane_id,
1524                SignalEvent::new(
1525                    "test",
1526                    SignalKind::WaitingInput,
1527                    Some("Need approval".into()),
1528                ),
1529            )
1530            .expect("signal applied");
1531
1532        let summaries = model
1533            .workspace_summaries(model.active_window)
1534            .expect("summary available");
1535        let summary = summaries.first().expect("summary");
1536
1537        assert_eq!(summary.highest_attention, AttentionState::WaitingInput);
1538        assert_eq!(
1539            summary
1540                .counts_by_attention
1541                .get(&AttentionState::WaitingInput)
1542                .copied(),
1543            Some(1)
1544        );
1545        assert_eq!(model.activity_items().len(), 1);
1546    }
1547
1548    #[test]
1549    fn persisted_session_roundtrips() {
1550        let model = AppModel::demo();
1551        let snapshot = model.snapshot();
1552        let encoded = serde_json::to_string_pretty(&snapshot).expect("serialize");
1553        let decoded: PersistedSession = serde_json::from_str(&encoded).expect("deserialize");
1554
1555        assert_eq!(decoded.schema_version, SESSION_SCHEMA_VERSION);
1556        assert_eq!(decoded.model.workspaces.len(), model.workspaces.len());
1557    }
1558}