Skip to main content

taskers_domain/
model.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Deserializer, Serialize};
5use thiserror::Error;
6use time::{Duration, OffsetDateTime};
7
8use crate::{
9    AttentionState, Direction, LayoutNode, PaneId, SessionId, SignalEvent, SignalKind, SplitAxis,
10    SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId, WorkspaceWindowId,
11};
12
13pub const SESSION_SCHEMA_VERSION: u32 = 4;
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 column {0} was not found")]
28    MissingWorkspaceColumn(WorkspaceColumnId),
29    #[error("workspace window {0} was not found")]
30    MissingWorkspaceWindow(WorkspaceWindowId),
31    #[error("pane {0} was not found")]
32    MissingPane(PaneId),
33    #[error("surface {0} was not found")]
34    MissingSurface(SurfaceId),
35    #[error("workspace {workspace_id} does not contain pane {pane_id}")]
36    PaneNotInWorkspace {
37        workspace_id: WorkspaceId,
38        pane_id: PaneId,
39    },
40    #[error("workspace {workspace_id} pane {pane_id} does not contain surface {surface_id}")]
41    SurfaceNotInPane {
42        workspace_id: WorkspaceId,
43        pane_id: PaneId,
44        surface_id: SurfaceId,
45    },
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum PaneKind {
51    Terminal,
52    Browser,
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
56pub struct PaneMetadata {
57    pub title: Option<String>,
58    pub cwd: Option<String>,
59    pub repo_name: Option<String>,
60    pub git_branch: Option<String>,
61    pub ports: Vec<u16>,
62    pub agent_kind: Option<String>,
63    #[serde(default)]
64    pub agent_active: bool,
65    pub last_signal_at: Option<OffsetDateTime>,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PaneMetadataPatch {
70    pub title: Option<String>,
71    pub cwd: Option<String>,
72    pub repo_name: Option<String>,
73    pub git_branch: Option<String>,
74    pub ports: Option<Vec<u16>>,
75    pub agent_kind: Option<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct SurfaceRecord {
80    pub id: SurfaceId,
81    pub kind: PaneKind,
82    pub metadata: PaneMetadata,
83    pub attention: AttentionState,
84    pub session_id: SessionId,
85    pub command: Option<Vec<String>>,
86}
87
88impl SurfaceRecord {
89    pub fn new(kind: PaneKind) -> Self {
90        Self {
91            id: SurfaceId::new(),
92            kind,
93            metadata: PaneMetadata::default(),
94            attention: AttentionState::Normal,
95            session_id: SessionId::new(),
96            command: None,
97        }
98    }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct PaneRecord {
103    pub id: PaneId,
104    pub surfaces: IndexMap<SurfaceId, SurfaceRecord>,
105    pub active_surface: SurfaceId,
106}
107
108impl PaneRecord {
109    pub fn new(kind: PaneKind) -> Self {
110        let surface = SurfaceRecord::new(kind);
111        let active_surface = surface.id;
112        let mut surfaces = IndexMap::new();
113        surfaces.insert(active_surface, surface);
114        Self {
115            id: PaneId::new(),
116            surfaces,
117            active_surface,
118        }
119    }
120
121    pub fn active_surface(&self) -> Option<&SurfaceRecord> {
122        self.surfaces.get(&self.active_surface)
123    }
124
125    pub fn active_surface_mut(&mut self) -> Option<&mut SurfaceRecord> {
126        self.surfaces.get_mut(&self.active_surface)
127    }
128
129    pub fn active_metadata(&self) -> Option<&PaneMetadata> {
130        self.active_surface().map(|surface| &surface.metadata)
131    }
132
133    pub fn active_metadata_mut(&mut self) -> Option<&mut PaneMetadata> {
134        self.active_surface_mut()
135            .map(|surface| &mut surface.metadata)
136    }
137
138    pub fn active_kind(&self) -> Option<PaneKind> {
139        self.active_surface().map(|surface| surface.kind.clone())
140    }
141
142    pub fn active_attention(&self) -> AttentionState {
143        self.active_surface()
144            .map(|surface| surface.attention)
145            .unwrap_or(AttentionState::Normal)
146    }
147
148    pub fn active_session_id(&self) -> Option<SessionId> {
149        self.active_surface().map(|surface| surface.session_id)
150    }
151
152    pub fn active_command(&self) -> Option<&[String]> {
153        self.active_surface()
154            .and_then(|surface| surface.command.as_deref())
155    }
156
157    pub fn highest_attention(&self) -> AttentionState {
158        self.surfaces
159            .values()
160            .map(|surface| surface.attention)
161            .max_by_key(|attention| attention.rank())
162            .unwrap_or(AttentionState::Normal)
163    }
164
165    pub fn surface_ids(&self) -> impl Iterator<Item = SurfaceId> + '_ {
166        self.surfaces.keys().copied()
167    }
168
169    fn insert_surface(&mut self, surface: SurfaceRecord) {
170        self.active_surface = surface.id;
171        self.surfaces.insert(surface.id, surface);
172    }
173
174    fn focus_surface(&mut self, surface_id: SurfaceId) -> bool {
175        if self.surfaces.contains_key(&surface_id) {
176            self.active_surface = surface_id;
177            true
178        } else {
179            false
180        }
181    }
182
183    fn move_surface(&mut self, surface_id: SurfaceId, to_index: usize) -> bool {
184        let Some(from_index) = self.surfaces.get_index_of(&surface_id) else {
185            return false;
186        };
187
188        let last_index = self.surfaces.len().saturating_sub(1);
189        let target_index = to_index.min(last_index);
190        if from_index == target_index {
191            return true;
192        }
193
194        self.surfaces.move_index(from_index, target_index);
195        true
196    }
197
198    fn normalize(&mut self) {
199        if self.surfaces.is_empty() {
200            let replacement = SurfaceRecord::new(PaneKind::Terminal);
201            self.active_surface = replacement.id;
202            self.surfaces.insert(replacement.id, replacement);
203            return;
204        }
205
206        if !self.surfaces.contains_key(&self.active_surface) {
207            self.active_surface = self
208                .surfaces
209                .first()
210                .map(|(surface_id, _)| *surface_id)
211                .expect("pane has at least one surface");
212        }
213    }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct NotificationItem {
218    pub pane_id: PaneId,
219    pub surface_id: SurfaceId,
220    #[serde(default = "default_notification_kind")]
221    pub kind: SignalKind,
222    pub state: AttentionState,
223    pub message: String,
224    pub created_at: OffsetDateTime,
225    pub cleared_at: Option<OffsetDateTime>,
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct ActivityItem {
230    pub workspace_id: WorkspaceId,
231    pub workspace_window_id: Option<WorkspaceWindowId>,
232    pub pane_id: PaneId,
233    pub surface_id: SurfaceId,
234    pub kind: SignalKind,
235    pub state: AttentionState,
236    pub message: String,
237    pub created_at: OffsetDateTime,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
241#[serde(rename_all = "snake_case")]
242pub enum WorkspaceAgentState {
243    Working,
244    Waiting,
245    Inactive,
246}
247
248impl WorkspaceAgentState {
249    pub fn label(self) -> &'static str {
250        match self {
251            Self::Working => "Working",
252            Self::Waiting => "Waiting",
253            Self::Inactive => "Inactive",
254        }
255    }
256
257    fn sort_rank(self) -> u8 {
258        match self {
259            Self::Waiting => 0,
260            Self::Working => 1,
261            Self::Inactive => 2,
262        }
263    }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267pub struct WorkspaceAgentSummary {
268    pub workspace_window_id: WorkspaceWindowId,
269    pub pane_id: PaneId,
270    pub surface_id: SurfaceId,
271    pub agent_kind: String,
272    pub title: Option<String>,
273    pub state: WorkspaceAgentState,
274    pub last_signal_at: Option<OffsetDateTime>,
275}
276
277fn default_notification_kind() -> SignalKind {
278    SignalKind::Notification
279}
280
281#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
282pub struct WorkspaceViewport {
283    #[serde(default)]
284    pub x: i32,
285    #[serde(default)]
286    pub y: i32,
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290pub struct WindowFrame {
291    pub x: i32,
292    pub y: i32,
293    pub width: i32,
294    pub height: i32,
295}
296
297impl WindowFrame {
298    pub fn root() -> Self {
299        Self {
300            x: 0,
301            y: 0,
302            width: DEFAULT_WORKSPACE_WINDOW_WIDTH,
303            height: DEFAULT_WORKSPACE_WINDOW_HEIGHT,
304        }
305    }
306
307    pub fn right(self) -> i32 {
308        self.x + self.width
309    }
310
311    pub fn bottom(self) -> i32 {
312        self.y + self.height
313    }
314
315    pub fn center_x(self) -> i32 {
316        self.x + (self.width / 2)
317    }
318
319    pub fn center_y(self) -> i32 {
320        self.y + (self.height / 2)
321    }
322
323    pub fn shifted(self, direction: Direction) -> Self {
324        match direction {
325            Direction::Left => Self {
326                x: self.x - self.width - DEFAULT_WORKSPACE_WINDOW_GAP,
327                ..self
328            },
329            Direction::Right => Self {
330                x: self.x + self.width + DEFAULT_WORKSPACE_WINDOW_GAP,
331                ..self
332            },
333            Direction::Up => Self {
334                y: self.y - self.height - DEFAULT_WORKSPACE_WINDOW_GAP,
335                ..self
336            },
337            Direction::Down => Self {
338                y: self.y + self.height + DEFAULT_WORKSPACE_WINDOW_GAP,
339                ..self
340            },
341        }
342    }
343
344    pub fn resize_by_direction(&mut self, direction: Direction, amount: i32) {
345        match direction {
346            Direction::Left => {
347                self.width = (self.width - amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
348            }
349            Direction::Right => {
350                self.width = (self.width + amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
351            }
352            Direction::Up => {
353                self.height = (self.height - amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
354            }
355            Direction::Down => {
356                self.height = (self.height + amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
357            }
358        }
359    }
360
361    pub fn clamp(&mut self) {
362        self.width = self.width.max(MIN_WORKSPACE_WINDOW_WIDTH);
363        self.height = self.height.max(MIN_WORKSPACE_WINDOW_HEIGHT);
364    }
365}
366
367#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
368pub struct WorkspaceWindowRecord {
369    pub id: WorkspaceWindowId,
370    pub height: i32,
371    pub layout: LayoutNode,
372    pub active_pane: PaneId,
373}
374
375impl WorkspaceWindowRecord {
376    fn new(pane_id: PaneId) -> Self {
377        Self {
378            id: WorkspaceWindowId::new(),
379            height: DEFAULT_WORKSPACE_WINDOW_HEIGHT,
380            layout: LayoutNode::leaf(pane_id),
381            active_pane: pane_id,
382        }
383    }
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387pub struct WorkspaceColumnRecord {
388    pub id: WorkspaceColumnId,
389    pub width: i32,
390    pub window_order: Vec<WorkspaceWindowId>,
391    pub active_window: WorkspaceWindowId,
392}
393
394impl WorkspaceColumnRecord {
395    fn new(window_id: WorkspaceWindowId) -> Self {
396        Self {
397            id: WorkspaceColumnId::new(),
398            width: DEFAULT_WORKSPACE_WINDOW_WIDTH,
399            window_order: vec![window_id],
400            active_window: window_id,
401        }
402    }
403
404    fn normalize(&mut self, windows: &IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>) {
405        self.width = self.width.max(MIN_WORKSPACE_WINDOW_WIDTH);
406        self.window_order
407            .retain(|window_id| windows.contains_key(window_id));
408        if !self.window_order.contains(&self.active_window)
409            && let Some(window_id) = self.window_order.first()
410        {
411            self.active_window = *window_id;
412        }
413    }
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
417pub struct Workspace {
418    pub id: WorkspaceId,
419    pub label: String,
420    pub columns: IndexMap<WorkspaceColumnId, WorkspaceColumnRecord>,
421    pub windows: IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>,
422    pub active_window: WorkspaceWindowId,
423    pub panes: IndexMap<PaneId, PaneRecord>,
424    pub active_pane: PaneId,
425    #[serde(default)]
426    pub viewport: WorkspaceViewport,
427    pub notifications: Vec<NotificationItem>,
428}
429
430impl<'de> Deserialize<'de> for Workspace {
431    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
432    where
433        D: Deserializer<'de>,
434    {
435        Ok(CurrentWorkspaceSerde::deserialize(deserializer)?.into_workspace())
436    }
437}
438
439impl Workspace {
440    pub fn bootstrap(label: impl Into<String>) -> Self {
441        let first_pane = PaneRecord::new(PaneKind::Terminal);
442        let active_pane = first_pane.id;
443        let mut panes = IndexMap::new();
444        panes.insert(active_pane, first_pane);
445        let first_window = WorkspaceWindowRecord::new(active_pane);
446        let active_window = first_window.id;
447        let mut windows = IndexMap::new();
448        windows.insert(active_window, first_window);
449        let first_column = WorkspaceColumnRecord::new(active_window);
450        let mut columns = IndexMap::new();
451        columns.insert(first_column.id, first_column);
452
453        Self {
454            id: WorkspaceId::new(),
455            label: label.into(),
456            columns,
457            windows,
458            active_window,
459            panes,
460            active_pane,
461            viewport: WorkspaceViewport::default(),
462            notifications: Vec::new(),
463        }
464    }
465
466    pub fn active_window_record(&self) -> Option<&WorkspaceWindowRecord> {
467        self.windows.get(&self.active_window)
468    }
469
470    pub fn active_window_record_mut(&mut self) -> Option<&mut WorkspaceWindowRecord> {
471        self.windows.get_mut(&self.active_window)
472    }
473
474    pub fn column_for_window(&self, window_id: WorkspaceWindowId) -> Option<WorkspaceColumnId> {
475        self.columns.iter().find_map(|(column_id, column)| {
476            column
477                .window_order
478                .contains(&window_id)
479                .then_some(*column_id)
480        })
481    }
482
483    pub fn active_column_id(&self) -> Option<WorkspaceColumnId> {
484        self.column_for_window(self.active_window)
485    }
486
487    fn position_for_window(
488        &self,
489        window_id: WorkspaceWindowId,
490    ) -> Option<(WorkspaceColumnId, usize, usize)> {
491        self.columns
492            .iter()
493            .enumerate()
494            .find_map(|(column_index, (column_id, column))| {
495                column
496                    .window_order
497                    .iter()
498                    .position(|candidate| *candidate == window_id)
499                    .map(|window_index| (*column_id, column_index, window_index))
500            })
501    }
502
503    pub fn window_for_pane(&self, pane_id: PaneId) -> Option<WorkspaceWindowId> {
504        self.windows
505            .iter()
506            .find_map(|(window_id, window)| window.layout.contains(pane_id).then_some(*window_id))
507    }
508
509    fn sync_active_from_window(&mut self, window_id: WorkspaceWindowId) {
510        if let Some(window) = self.windows.get(&window_id) {
511            self.active_window = window_id;
512            self.active_pane = window.active_pane;
513            if let Some(column_id) = self.column_for_window(window_id)
514                && let Some(column) = self.columns.get_mut(&column_id)
515            {
516                column.active_window = window_id;
517            }
518        }
519    }
520
521    fn focus_window(&mut self, window_id: WorkspaceWindowId) {
522        self.sync_active_from_window(window_id);
523    }
524
525    fn focus_pane(&mut self, pane_id: PaneId) -> bool {
526        let Some(window_id) = self.window_for_pane(pane_id) else {
527            return false;
528        };
529        if let Some(window) = self.windows.get_mut(&window_id) {
530            window.active_pane = pane_id;
531        }
532        self.sync_active_from_window(window_id);
533        true
534    }
535
536    fn focus_surface(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool {
537        let Some(pane) = self.panes.get_mut(&pane_id) else {
538            return false;
539        };
540        if !pane.focus_surface(surface_id) {
541            return false;
542        }
543        self.focus_pane(pane_id)
544    }
545
546    fn acknowledge_pane_notifications(&mut self, pane_id: PaneId) {
547        let now = OffsetDateTime::now_utc();
548        for notification in &mut self.notifications {
549            if notification.pane_id == pane_id && notification.cleared_at.is_none() {
550                notification.cleared_at = Some(now);
551            }
552        }
553    }
554
555    fn acknowledge_surface_notifications(&mut self, pane_id: PaneId, surface_id: SurfaceId) {
556        let now = OffsetDateTime::now_utc();
557        for notification in &mut self.notifications {
558            if notification.pane_id == pane_id
559                && notification.surface_id == surface_id
560                && notification.cleared_at.is_none()
561            {
562                notification.cleared_at = Some(now);
563            }
564        }
565    }
566
567    fn top_level_neighbor(
568        &self,
569        source_window_id: WorkspaceWindowId,
570        direction: Direction,
571    ) -> Option<WorkspaceWindowId> {
572        let (_, column_index, window_index) = self.position_for_window(source_window_id)?;
573        match direction {
574            Direction::Left => column_index
575                .checked_sub(1)
576                .and_then(|index| self.columns.get_index(index))
577                .map(|(_, column)| column.active_window),
578            Direction::Right => self
579                .columns
580                .get_index(column_index + 1)
581                .map(|(_, column)| column.active_window),
582            Direction::Up => self
583                .columns
584                .get_index(column_index)
585                .and_then(|(_, column)| {
586                    window_index
587                        .checked_sub(1)
588                        .and_then(|index| column.window_order.get(index))
589                })
590                .copied(),
591            Direction::Down => self
592                .columns
593                .get_index(column_index)
594                .and_then(|(_, column)| column.window_order.get(window_index + 1))
595                .copied(),
596        }
597    }
598
599    fn fallback_window_after_close(
600        &self,
601        source_column_index: usize,
602        source_window_index: usize,
603        same_column_survived: bool,
604    ) -> Option<WorkspaceWindowId> {
605        if same_column_survived
606            && let Some((_, column)) = self.columns.get_index(source_column_index)
607        {
608            if let Some(window_id) = column.window_order.get(source_window_index) {
609                return Some(*window_id);
610            }
611            if let Some(window_id) = source_window_index
612                .checked_sub(1)
613                .and_then(|index| column.window_order.get(index))
614            {
615                return Some(*window_id);
616            }
617        }
618
619        let right_column_index = if same_column_survived {
620            source_column_index + 1
621        } else {
622            source_column_index
623        };
624        if let Some((_, column)) = self.columns.get_index(right_column_index)
625            && let Some(window_id) = column.window_order.first()
626        {
627            return Some(*window_id);
628        }
629
630        source_column_index
631            .checked_sub(1)
632            .and_then(|index| self.columns.get_index(index))
633            .and_then(|(_, column)| column.window_order.first())
634            .copied()
635    }
636
637    fn insert_column_at(&mut self, index: usize, column: WorkspaceColumnRecord) {
638        let insert_index = index.min(self.columns.len());
639        let mut next = IndexMap::with_capacity(self.columns.len() + 1);
640        let mut pending = Some(column);
641        for (current_index, (column_id, current_column)) in
642            std::mem::take(&mut self.columns).into_iter().enumerate()
643        {
644            if current_index == insert_index
645                && let Some(column) = pending.take()
646            {
647                next.insert(column.id, column);
648            }
649            next.insert(column_id, current_column);
650        }
651        if let Some(column) = pending.take() {
652            next.insert(column.id, column);
653        }
654        self.columns = next;
655    }
656
657    fn append_missing_windows_to_columns(&mut self) {
658        let assigned = self
659            .columns
660            .values()
661            .flat_map(|column| column.window_order.iter().copied())
662            .collect::<BTreeSet<_>>();
663        for window_id in self.windows.keys().copied().collect::<Vec<_>>() {
664            if assigned.contains(&window_id) {
665                continue;
666            }
667            let column = WorkspaceColumnRecord::new(window_id);
668            self.columns.insert(column.id, column);
669        }
670    }
671
672    fn normalize(&mut self) {
673        if self.panes.is_empty() {
674            let id = self.id;
675            let label = self.label.clone();
676            *self = Self::bootstrap(label);
677            self.id = id;
678            return;
679        }
680
681        for pane in self.panes.values_mut() {
682            pane.normalize();
683        }
684
685        if self.windows.is_empty() {
686            let fallback_pane = self
687                .panes
688                .first()
689                .map(|(pane_id, _)| *pane_id)
690                .expect("workspace has at least one pane");
691            let fallback_window = WorkspaceWindowRecord::new(fallback_pane);
692            self.active_window = fallback_window.id;
693            self.active_pane = fallback_pane;
694            self.windows.insert(fallback_window.id, fallback_window);
695        }
696
697        for window in self.windows.values_mut() {
698            window.height = window.height.max(MIN_WORKSPACE_WINDOW_HEIGHT);
699            if !window.layout.contains(window.active_pane) {
700                window.active_pane = window
701                    .layout
702                    .leaves()
703                    .into_iter()
704                    .find(|pane_id| self.panes.contains_key(pane_id))
705                    .or_else(|| self.panes.first().map(|(pane_id, _)| *pane_id))
706                    .expect("workspace has at least one pane");
707            }
708        }
709
710        for column in self.columns.values_mut() {
711            column.normalize(&self.windows);
712        }
713
714        let mut assigned = BTreeSet::new();
715        for column in self.columns.values_mut() {
716            column
717                .window_order
718                .retain(|window_id| assigned.insert(*window_id));
719            if !column.window_order.contains(&column.active_window)
720                && let Some(window_id) = column.window_order.first()
721            {
722                column.active_window = *window_id;
723            }
724        }
725        self.columns
726            .retain(|_, column| !column.window_order.is_empty());
727        self.append_missing_windows_to_columns();
728
729        if self.columns.is_empty() {
730            let fallback_window_id = self
731                .windows
732                .first()
733                .map(|(window_id, _)| *window_id)
734                .expect("workspace has at least one window");
735            let column = WorkspaceColumnRecord::new(fallback_window_id);
736            self.columns.insert(column.id, column);
737        }
738
739        if !self.windows.contains_key(&self.active_window) {
740            self.active_window = self
741                .columns
742                .first()
743                .map(|(_, column)| column.active_window)
744                .expect("workspace has at least one column");
745        }
746        if !self
747            .windows
748            .get(&self.active_window)
749            .is_some_and(|window| window.layout.contains(self.active_pane))
750        {
751            self.active_pane = self
752                .windows
753                .get(&self.active_window)
754                .map(|window| window.active_pane)
755                .expect("active window exists");
756        }
757        self.sync_active_from_window(self.active_window);
758    }
759
760    pub fn repo_hint(&self) -> Option<&str> {
761        self.panes.values().find_map(|pane| {
762            pane.active_metadata()
763                .and_then(|metadata| metadata.repo_name.as_deref())
764        })
765    }
766
767    pub fn attention_counts(&self) -> BTreeMap<AttentionState, usize> {
768        let mut counts = BTreeMap::new();
769        for pane in self.panes.values() {
770            for surface in pane.surfaces.values() {
771                *counts.entry(surface.attention).or_insert(0) += 1;
772            }
773        }
774        counts
775    }
776
777    pub fn active_surface_id(&self) -> Option<SurfaceId> {
778        self.panes
779            .get(&self.active_pane)
780            .map(|pane| pane.active_surface)
781    }
782
783    pub fn agent_summaries(&self, now: OffsetDateTime) -> Vec<WorkspaceAgentSummary> {
784        let mut summaries = self
785            .panes
786            .iter()
787            .flat_map(|(pane_id, pane)| {
788                let workspace_window_id = self.window_for_pane(*pane_id);
789                pane.surfaces.values().filter_map(move |surface| {
790                    let workspace_window_id = workspace_window_id?;
791                    let state = workspace_agent_state(surface, now)?;
792                    let agent_kind = surface.metadata.agent_kind.clone()?;
793                    Some(WorkspaceAgentSummary {
794                        workspace_window_id,
795                        pane_id: *pane_id,
796                        surface_id: surface.id,
797                        agent_kind,
798                        title: surface
799                            .metadata
800                            .title
801                            .as_deref()
802                            .map(str::trim)
803                            .filter(|title| !title.is_empty())
804                            .map(str::to_owned),
805                        state,
806                        last_signal_at: surface.metadata.last_signal_at,
807                    })
808                })
809            })
810            .collect::<Vec<_>>();
811
812        summaries.sort_by(|left, right| {
813            left.state
814                .sort_rank()
815                .cmp(&right.state.sort_rank())
816                .then_with(|| right.last_signal_at.cmp(&left.last_signal_at))
817                .then_with(|| left.agent_kind.cmp(&right.agent_kind))
818                .then_with(|| left.pane_id.cmp(&right.pane_id))
819                .then_with(|| left.surface_id.cmp(&right.surface_id))
820        });
821
822        summaries
823    }
824}
825
826#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
827pub struct WorkspaceSummary {
828    pub workspace_id: WorkspaceId,
829    pub label: String,
830    pub active_pane: PaneId,
831    pub repo_hint: Option<String>,
832    pub agent_summaries: Vec<WorkspaceAgentSummary>,
833    pub counts_by_attention: BTreeMap<AttentionState, usize>,
834    pub highest_attention: AttentionState,
835    pub display_attention: AttentionState,
836    pub unread_count: usize,
837    pub latest_notification: Option<String>,
838}
839
840#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
841pub struct WindowRecord {
842    pub id: WindowId,
843    pub workspace_order: Vec<WorkspaceId>,
844    pub active_workspace: WorkspaceId,
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
848pub struct AppModel {
849    pub active_window: WindowId,
850    pub windows: IndexMap<WindowId, WindowRecord>,
851    pub workspaces: IndexMap<WorkspaceId, Workspace>,
852}
853
854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
855pub struct PersistedSession {
856    pub schema_version: u32,
857    pub captured_at: OffsetDateTime,
858    pub model: AppModel,
859}
860
861impl AppModel {
862    pub fn new(label: impl Into<String>) -> Self {
863        let window_id = WindowId::new();
864        let workspace = Workspace::bootstrap(label);
865        let workspace_id = workspace.id;
866
867        let mut windows = IndexMap::new();
868        windows.insert(
869            window_id,
870            WindowRecord {
871                id: window_id,
872                workspace_order: vec![workspace_id],
873                active_workspace: workspace_id,
874            },
875        );
876
877        let mut workspaces = IndexMap::new();
878        workspaces.insert(workspace_id, workspace);
879
880        Self {
881            active_window: window_id,
882            windows,
883            workspaces,
884        }
885    }
886
887    pub fn demo() -> Self {
888        let mut model = Self::new("Repo A");
889        let primary_workspace = model.active_workspace_id().unwrap_or_else(WorkspaceId::new);
890        let first_pane = model
891            .active_workspace()
892            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
893            .unwrap_or_else(PaneId::new);
894
895        let _ = model.update_pane_metadata(
896            first_pane,
897            PaneMetadataPatch {
898                title: Some("Codex".into()),
899                cwd: Some("/home/notes/Projects/taskers".into()),
900                repo_name: Some("taskers".into()),
901                git_branch: Some("main".into()),
902                ports: Some(vec![3000]),
903                agent_kind: Some("codex".into()),
904            },
905        );
906        let _ = model.apply_signal(
907            primary_workspace,
908            first_pane,
909            SignalEvent::new(
910                "demo",
911                SignalKind::WaitingInput,
912                Some("Waiting for review on workspace bootstrap".into()),
913            ),
914        );
915
916        let second_window_pane = model
917            .create_workspace_window(primary_workspace, Direction::Right)
918            .unwrap_or(first_pane);
919        let _ = model.update_pane_metadata(
920            second_window_pane,
921            PaneMetadataPatch {
922                title: Some("Claude".into()),
923                cwd: Some("/home/notes/Projects/taskers".into()),
924                repo_name: Some("taskers".into()),
925                git_branch: Some("feature/bootstrap".into()),
926                ports: Some(vec![]),
927                agent_kind: Some("claude".into()),
928            },
929        );
930        let split_pane = model
931            .split_pane(
932                primary_workspace,
933                Some(second_window_pane),
934                SplitAxis::Vertical,
935            )
936            .unwrap_or(second_window_pane);
937        let _ = model.apply_signal(
938            primary_workspace,
939            split_pane,
940            SignalEvent::new(
941                "demo",
942                SignalKind::Progress,
943                Some("Running long task".into()),
944            ),
945        );
946
947        let second_workspace = model.create_workspace("Docs");
948        let second_pane = model
949            .workspaces
950            .get(&second_workspace)
951            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
952            .unwrap_or_else(PaneId::new);
953        let _ = model.update_pane_metadata(
954            second_pane,
955            PaneMetadataPatch {
956                title: Some("OpenCode".into()),
957                cwd: Some("/home/notes/Documents".into()),
958                repo_name: Some("notes".into()),
959                git_branch: Some("docs".into()),
960                ports: Some(vec![8080, 8081]),
961                agent_kind: Some("opencode".into()),
962            },
963        );
964        let _ = model.apply_signal(
965            second_workspace,
966            second_pane,
967            SignalEvent::new(
968                "demo",
969                SignalKind::Completed,
970                Some("Draft completed, ready for merge".into()),
971            ),
972        );
973        let _ = model.switch_workspace(model.active_window, second_workspace);
974
975        model
976    }
977
978    pub fn active_window(&self) -> Option<&WindowRecord> {
979        self.windows.get(&self.active_window)
980    }
981
982    pub fn active_workspace_id(&self) -> Option<WorkspaceId> {
983        self.active_window().map(|window| window.active_workspace)
984    }
985
986    pub fn active_workspace(&self) -> Option<&Workspace> {
987        self.active_workspace_id()
988            .and_then(|workspace_id| self.workspaces.get(&workspace_id))
989    }
990
991    pub fn create_workspace(&mut self, label: impl Into<String>) -> WorkspaceId {
992        let workspace = Workspace::bootstrap(label);
993        let workspace_id = workspace.id;
994        self.workspaces.insert(workspace_id, workspace);
995        if let Some(window) = self.windows.get_mut(&self.active_window) {
996            window.workspace_order.push(workspace_id);
997            window.active_workspace = workspace_id;
998        }
999        workspace_id
1000    }
1001
1002    pub fn rename_workspace(
1003        &mut self,
1004        workspace_id: WorkspaceId,
1005        label: impl Into<String>,
1006    ) -> Result<(), DomainError> {
1007        let workspace = self
1008            .workspaces
1009            .get_mut(&workspace_id)
1010            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1011        workspace.label = label.into();
1012        Ok(())
1013    }
1014
1015    pub fn switch_workspace(
1016        &mut self,
1017        window_id: WindowId,
1018        workspace_id: WorkspaceId,
1019    ) -> Result<(), DomainError> {
1020        let window = self
1021            .windows
1022            .get_mut(&window_id)
1023            .ok_or(DomainError::MissingWindow(window_id))?;
1024        if !window.workspace_order.contains(&workspace_id) {
1025            return Err(DomainError::MissingWorkspace(workspace_id));
1026        }
1027        window.active_workspace = workspace_id;
1028        Ok(())
1029    }
1030
1031    pub fn create_workspace_window(
1032        &mut self,
1033        workspace_id: WorkspaceId,
1034        direction: Direction,
1035    ) -> Result<PaneId, DomainError> {
1036        let workspace = self
1037            .workspaces
1038            .get_mut(&workspace_id)
1039            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1040        let new_pane = PaneRecord::new(PaneKind::Terminal);
1041        let new_pane_id = new_pane.id;
1042        workspace.panes.insert(new_pane_id, new_pane);
1043
1044        let new_window = WorkspaceWindowRecord::new(new_pane_id);
1045        let new_window_id = new_window.id;
1046        workspace.windows.insert(new_window_id, new_window);
1047
1048        let (source_column_id, source_column_index, source_window_index) = workspace
1049            .position_for_window(workspace.active_window)
1050            .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?;
1051
1052        match direction {
1053            Direction::Left | Direction::Right => {
1054                let new_column = WorkspaceColumnRecord::new(new_window_id);
1055                let insert_index = if matches!(direction, Direction::Left) {
1056                    source_column_index
1057                } else {
1058                    source_column_index + 1
1059                };
1060                workspace.insert_column_at(insert_index, new_column);
1061            }
1062            Direction::Up | Direction::Down => {
1063                let column = workspace
1064                    .columns
1065                    .get_mut(&source_column_id)
1066                    .expect("active column should exist");
1067                let insert_index = if matches!(direction, Direction::Up) {
1068                    source_window_index
1069                } else {
1070                    source_window_index + 1
1071                };
1072                column.window_order.insert(insert_index, new_window_id);
1073                column.active_window = new_window_id;
1074            }
1075        }
1076
1077        workspace.sync_active_from_window(new_window_id);
1078
1079        Ok(new_pane_id)
1080    }
1081
1082    pub fn split_pane(
1083        &mut self,
1084        workspace_id: WorkspaceId,
1085        target_pane: Option<PaneId>,
1086        axis: SplitAxis,
1087    ) -> Result<PaneId, DomainError> {
1088        let workspace = self
1089            .workspaces
1090            .get_mut(&workspace_id)
1091            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1092
1093        let target = target_pane.unwrap_or(workspace.active_pane);
1094        if !workspace.panes.contains_key(&target) {
1095            return Err(DomainError::PaneNotInWorkspace {
1096                workspace_id,
1097                pane_id: target,
1098            });
1099        }
1100
1101        let window_id = workspace
1102            .window_for_pane(target)
1103            .ok_or(DomainError::MissingPane(target))?;
1104        let new_pane = PaneRecord::new(PaneKind::Terminal);
1105        let new_pane_id = new_pane.id;
1106        workspace.panes.insert(new_pane_id, new_pane);
1107
1108        if let Some(window) = workspace.windows.get_mut(&window_id) {
1109            window.layout.split_leaf(target, axis, new_pane_id, 500);
1110            window.active_pane = new_pane_id;
1111        }
1112        workspace.sync_active_from_window(window_id);
1113
1114        Ok(new_pane_id)
1115    }
1116
1117    pub fn focus_workspace_window(
1118        &mut self,
1119        workspace_id: WorkspaceId,
1120        workspace_window_id: WorkspaceWindowId,
1121    ) -> Result<(), DomainError> {
1122        let workspace = self
1123            .workspaces
1124            .get_mut(&workspace_id)
1125            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1126        if !workspace.windows.contains_key(&workspace_window_id) {
1127            return Err(DomainError::MissingWorkspaceWindow(workspace_window_id));
1128        }
1129        workspace.focus_window(workspace_window_id);
1130        Ok(())
1131    }
1132
1133    pub fn focus_pane(
1134        &mut self,
1135        workspace_id: WorkspaceId,
1136        pane_id: PaneId,
1137    ) -> Result<(), DomainError> {
1138        let workspace = self
1139            .workspaces
1140            .get_mut(&workspace_id)
1141            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1142
1143        if !workspace.panes.contains_key(&pane_id) {
1144            return Err(DomainError::PaneNotInWorkspace {
1145                workspace_id,
1146                pane_id,
1147            });
1148        }
1149
1150        workspace.focus_pane(pane_id);
1151        Ok(())
1152    }
1153
1154    pub fn acknowledge_pane_notifications(
1155        &mut self,
1156        workspace_id: WorkspaceId,
1157        pane_id: PaneId,
1158    ) -> Result<(), DomainError> {
1159        let workspace = self
1160            .workspaces
1161            .get_mut(&workspace_id)
1162            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1163        if !workspace.panes.contains_key(&pane_id) {
1164            return Err(DomainError::PaneNotInWorkspace {
1165                workspace_id,
1166                pane_id,
1167            });
1168        }
1169        workspace.acknowledge_pane_notifications(pane_id);
1170        Ok(())
1171    }
1172
1173    pub fn mark_surface_completed(
1174        &mut self,
1175        workspace_id: WorkspaceId,
1176        pane_id: PaneId,
1177        surface_id: SurfaceId,
1178    ) -> Result<(), DomainError> {
1179        let workspace = self
1180            .workspaces
1181            .get_mut(&workspace_id)
1182            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1183        let pane = workspace
1184            .panes
1185            .get_mut(&pane_id)
1186            .ok_or(DomainError::PaneNotInWorkspace {
1187                workspace_id,
1188                pane_id,
1189            })?;
1190        let surface = pane
1191            .surfaces
1192            .get_mut(&surface_id)
1193            .ok_or(DomainError::SurfaceNotInPane {
1194                workspace_id,
1195                pane_id,
1196                surface_id,
1197            })?;
1198
1199        surface.attention = AttentionState::Completed;
1200        surface.metadata.agent_active = false;
1201        surface.metadata.last_signal_at = Some(OffsetDateTime::now_utc());
1202        workspace.acknowledge_surface_notifications(pane_id, surface_id);
1203        Ok(())
1204    }
1205
1206    pub fn focus_pane_direction(
1207        &mut self,
1208        workspace_id: WorkspaceId,
1209        direction: Direction,
1210    ) -> Result<(), DomainError> {
1211        let workspace = self
1212            .workspaces
1213            .get_mut(&workspace_id)
1214            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1215        let active_window_id = workspace.active_window;
1216
1217        if let Some(next_window_id) = workspace.top_level_neighbor(active_window_id, direction) {
1218            workspace.focus_window(next_window_id);
1219            return Ok(());
1220        }
1221
1222        let next_pane = workspace
1223            .windows
1224            .get(&active_window_id)
1225            .and_then(|window| window.layout.focus_neighbor(window.active_pane, direction));
1226        if let Some(next_pane) = next_pane {
1227            if let Some(window) = workspace.windows.get_mut(&active_window_id) {
1228                window.active_pane = next_pane;
1229            }
1230            workspace.sync_active_from_window(active_window_id);
1231        }
1232
1233        Ok(())
1234    }
1235
1236    pub fn resize_active_window(
1237        &mut self,
1238        workspace_id: WorkspaceId,
1239        direction: Direction,
1240        amount: i32,
1241    ) -> Result<(), DomainError> {
1242        let workspace = self
1243            .workspaces
1244            .get_mut(&workspace_id)
1245            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1246        let active_window = workspace.active_window;
1247        let (column_id, _, _) = workspace
1248            .position_for_window(active_window)
1249            .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
1250        match direction {
1251            Direction::Left => {
1252                let column = workspace
1253                    .columns
1254                    .get_mut(&column_id)
1255                    .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
1256                column.width = (column.width - amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
1257            }
1258            Direction::Right => {
1259                let column = workspace
1260                    .columns
1261                    .get_mut(&column_id)
1262                    .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
1263                column.width = (column.width + amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
1264            }
1265            Direction::Up => {
1266                let window = workspace
1267                    .active_window_record_mut()
1268                    .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
1269                window.height = (window.height - amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
1270            }
1271            Direction::Down => {
1272                let window = workspace
1273                    .active_window_record_mut()
1274                    .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
1275                window.height = (window.height + amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
1276            }
1277        }
1278        Ok(())
1279    }
1280
1281    pub fn resize_active_pane_split(
1282        &mut self,
1283        workspace_id: WorkspaceId,
1284        direction: Direction,
1285        amount: i32,
1286    ) -> Result<(), DomainError> {
1287        let workspace = self
1288            .workspaces
1289            .get_mut(&workspace_id)
1290            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1291        let active_window_id = workspace.active_window;
1292        let active_pane = workspace.active_pane;
1293        let window = workspace
1294            .windows
1295            .get_mut(&active_window_id)
1296            .ok_or(DomainError::MissingWorkspaceWindow(active_window_id))?;
1297        window.layout.resize_leaf(active_pane, direction, amount);
1298        Ok(())
1299    }
1300
1301    pub fn set_workspace_column_width(
1302        &mut self,
1303        workspace_id: WorkspaceId,
1304        workspace_column_id: WorkspaceColumnId,
1305        width: i32,
1306    ) -> Result<(), DomainError> {
1307        let workspace = self
1308            .workspaces
1309            .get_mut(&workspace_id)
1310            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1311        let column = workspace
1312            .columns
1313            .get_mut(&workspace_column_id)
1314            .ok_or(DomainError::MissingWorkspaceColumn(workspace_column_id))?;
1315        column.width = width.max(MIN_WORKSPACE_WINDOW_WIDTH);
1316        Ok(())
1317    }
1318
1319    pub fn set_workspace_window_height(
1320        &mut self,
1321        workspace_id: WorkspaceId,
1322        workspace_window_id: WorkspaceWindowId,
1323        height: i32,
1324    ) -> Result<(), DomainError> {
1325        let workspace = self
1326            .workspaces
1327            .get_mut(&workspace_id)
1328            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1329        let window = workspace
1330            .windows
1331            .get_mut(&workspace_window_id)
1332            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
1333        window.height = height.max(MIN_WORKSPACE_WINDOW_HEIGHT);
1334        Ok(())
1335    }
1336
1337    pub fn set_window_split_ratio(
1338        &mut self,
1339        workspace_id: WorkspaceId,
1340        workspace_window_id: WorkspaceWindowId,
1341        path: &[bool],
1342        ratio: u16,
1343    ) -> Result<(), DomainError> {
1344        let workspace = self
1345            .workspaces
1346            .get_mut(&workspace_id)
1347            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1348        let window = workspace
1349            .windows
1350            .get_mut(&workspace_window_id)
1351            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
1352        window.layout.set_ratio_at_path(path, ratio);
1353        Ok(())
1354    }
1355
1356    pub fn set_workspace_viewport(
1357        &mut self,
1358        workspace_id: WorkspaceId,
1359        viewport: WorkspaceViewport,
1360    ) -> Result<(), DomainError> {
1361        let workspace = self
1362            .workspaces
1363            .get_mut(&workspace_id)
1364            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1365        workspace.viewport = viewport;
1366        Ok(())
1367    }
1368
1369    pub fn update_pane_metadata(
1370        &mut self,
1371        pane_id: PaneId,
1372        patch: PaneMetadataPatch,
1373    ) -> Result<(), DomainError> {
1374        let surface_id = self
1375            .workspaces
1376            .values()
1377            .find_map(|workspace| {
1378                workspace
1379                    .panes
1380                    .get(&pane_id)
1381                    .map(|pane| pane.active_surface)
1382            })
1383            .ok_or(DomainError::MissingPane(pane_id))?;
1384        self.update_surface_metadata(surface_id, patch)
1385    }
1386
1387    pub fn update_surface_metadata(
1388        &mut self,
1389        surface_id: SurfaceId,
1390        patch: PaneMetadataPatch,
1391    ) -> Result<(), DomainError> {
1392        let pane = self
1393            .workspaces
1394            .values_mut()
1395            .find_map(|workspace| {
1396                workspace
1397                    .panes
1398                    .values_mut()
1399                    .find(|pane| pane.surfaces.contains_key(&surface_id))
1400            })
1401            .ok_or(DomainError::MissingSurface(surface_id))?;
1402        let surface = pane
1403            .surfaces
1404            .get_mut(&surface_id)
1405            .ok_or(DomainError::MissingSurface(surface_id))?;
1406
1407        if patch.title.is_some() {
1408            surface.metadata.title = patch.title;
1409        }
1410        if patch.cwd.is_some() {
1411            surface.metadata.cwd = patch.cwd;
1412        }
1413        if patch.repo_name.is_some() {
1414            surface.metadata.repo_name = patch.repo_name;
1415        }
1416        if patch.git_branch.is_some() {
1417            surface.metadata.git_branch = patch.git_branch;
1418        }
1419        if let Some(ports) = patch.ports {
1420            surface.metadata.ports = ports;
1421        }
1422        if patch.agent_kind.is_some() {
1423            surface.metadata.agent_kind = patch.agent_kind;
1424        }
1425
1426        Ok(())
1427    }
1428
1429    pub fn apply_signal(
1430        &mut self,
1431        workspace_id: WorkspaceId,
1432        pane_id: PaneId,
1433        event: SignalEvent,
1434    ) -> Result<(), DomainError> {
1435        let surface_id = self
1436            .workspaces
1437            .get(&workspace_id)
1438            .and_then(|workspace| workspace.panes.get(&pane_id))
1439            .map(|pane| pane.active_surface)
1440            .ok_or(DomainError::PaneNotInWorkspace {
1441                workspace_id,
1442                pane_id,
1443            })?;
1444        self.apply_surface_signal(workspace_id, pane_id, surface_id, event)
1445    }
1446
1447    pub fn apply_surface_signal(
1448        &mut self,
1449        workspace_id: WorkspaceId,
1450        pane_id: PaneId,
1451        surface_id: SurfaceId,
1452        event: SignalEvent,
1453    ) -> Result<(), DomainError> {
1454        let workspace = self
1455            .workspaces
1456            .get_mut(&workspace_id)
1457            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1458        let pane = workspace
1459            .panes
1460            .get_mut(&pane_id)
1461            .ok_or(DomainError::PaneNotInWorkspace {
1462                workspace_id,
1463                pane_id,
1464            })?;
1465        let surface = pane
1466            .surfaces
1467            .get_mut(&surface_id)
1468            .ok_or(DomainError::SurfaceNotInPane {
1469                workspace_id,
1470                pane_id,
1471                surface_id,
1472            })?;
1473
1474        let metadata_reported_inactive = event
1475            .metadata
1476            .as_ref()
1477            .and_then(|metadata| metadata.agent_active)
1478            .is_some_and(|active| !active);
1479        let (surface_attention, should_acknowledge_surface_notifications) = {
1480            let mut acknowledged_inactive_resolution = false;
1481            if let Some(metadata) = event.metadata {
1482                surface.metadata.title = metadata.title;
1483                surface.metadata.cwd = metadata.cwd;
1484                surface.metadata.repo_name = metadata.repo_name;
1485                surface.metadata.git_branch = metadata.git_branch;
1486                surface.metadata.ports = metadata.ports;
1487                surface.metadata.agent_kind = metadata.agent_kind;
1488                if let Some(agent_active) = metadata.agent_active {
1489                    surface.metadata.agent_active = agent_active;
1490                }
1491            }
1492            if !matches!(event.kind, SignalKind::Metadata) {
1493                surface.metadata.last_signal_at = Some(event.timestamp);
1494                surface.attention = map_signal_to_attention(&event.kind);
1495                if let Some(agent_active) = signal_agent_active(&event.kind) {
1496                    surface.metadata.agent_active = agent_active;
1497                }
1498            } else if metadata_reported_inactive
1499                && matches!(
1500                    surface.attention,
1501                    AttentionState::Busy | AttentionState::WaitingInput
1502                )
1503            {
1504                surface.attention = AttentionState::Completed;
1505                surface.metadata.last_signal_at = Some(event.timestamp);
1506                acknowledged_inactive_resolution = true;
1507            }
1508
1509            (surface.attention, acknowledged_inactive_resolution)
1510        };
1511
1512        if should_acknowledge_surface_notifications {
1513            workspace.acknowledge_surface_notifications(pane_id, surface_id);
1514        }
1515
1516        if signal_creates_notification(&event.kind)
1517            && let Some(message) = event.message
1518        {
1519            workspace.notifications.push(NotificationItem {
1520                pane_id,
1521                surface_id,
1522                kind: event.kind,
1523                state: surface_attention,
1524                message,
1525                created_at: event.timestamp,
1526                cleared_at: None,
1527            });
1528        }
1529
1530        Ok(())
1531    }
1532
1533    pub fn create_surface(
1534        &mut self,
1535        workspace_id: WorkspaceId,
1536        pane_id: PaneId,
1537        kind: PaneKind,
1538    ) -> Result<SurfaceId, DomainError> {
1539        let workspace = self
1540            .workspaces
1541            .get_mut(&workspace_id)
1542            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1543        let pane = workspace
1544            .panes
1545            .get_mut(&pane_id)
1546            .ok_or(DomainError::PaneNotInWorkspace {
1547                workspace_id,
1548                pane_id,
1549            })?;
1550        let surface = SurfaceRecord::new(kind);
1551        let surface_id = surface.id;
1552        pane.insert_surface(surface);
1553        workspace.focus_pane(pane_id);
1554        Ok(surface_id)
1555    }
1556
1557    pub fn focus_surface(
1558        &mut self,
1559        workspace_id: WorkspaceId,
1560        pane_id: PaneId,
1561        surface_id: SurfaceId,
1562    ) -> Result<(), DomainError> {
1563        let workspace = self
1564            .workspaces
1565            .get_mut(&workspace_id)
1566            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1567        if !workspace.focus_surface(pane_id, surface_id) {
1568            return Err(DomainError::SurfaceNotInPane {
1569                workspace_id,
1570                pane_id,
1571                surface_id,
1572            });
1573        }
1574        Ok(())
1575    }
1576
1577    pub fn close_surface(
1578        &mut self,
1579        workspace_id: WorkspaceId,
1580        pane_id: PaneId,
1581        surface_id: SurfaceId,
1582    ) -> Result<(), DomainError> {
1583        let close_entire_pane = self
1584            .workspaces
1585            .get(&workspace_id)
1586            .and_then(|workspace| workspace.panes.get(&pane_id))
1587            .ok_or(DomainError::PaneNotInWorkspace {
1588                workspace_id,
1589                pane_id,
1590            })?
1591            .surfaces
1592            .len()
1593            <= 1;
1594
1595        if close_entire_pane {
1596            return self.close_pane(workspace_id, pane_id);
1597        }
1598
1599        let workspace = self
1600            .workspaces
1601            .get_mut(&workspace_id)
1602            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1603        let pane = workspace
1604            .panes
1605            .get_mut(&pane_id)
1606            .ok_or(DomainError::PaneNotInWorkspace {
1607                workspace_id,
1608                pane_id,
1609            })?;
1610        if pane.surfaces.shift_remove(&surface_id).is_none() {
1611            return Err(DomainError::SurfaceNotInPane {
1612                workspace_id,
1613                pane_id,
1614                surface_id,
1615            });
1616        }
1617        pane.normalize();
1618        workspace
1619            .notifications
1620            .retain(|item| item.surface_id != surface_id);
1621        if workspace.active_pane == pane_id {
1622            workspace.acknowledge_pane_notifications(pane_id);
1623        }
1624        Ok(())
1625    }
1626
1627    pub fn move_surface(
1628        &mut self,
1629        workspace_id: WorkspaceId,
1630        pane_id: PaneId,
1631        surface_id: SurfaceId,
1632        to_index: usize,
1633    ) -> Result<(), DomainError> {
1634        let workspace = self
1635            .workspaces
1636            .get_mut(&workspace_id)
1637            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1638        let pane = workspace
1639            .panes
1640            .get_mut(&pane_id)
1641            .ok_or(DomainError::PaneNotInWorkspace {
1642                workspace_id,
1643                pane_id,
1644            })?;
1645        if !pane.move_surface(surface_id, to_index) {
1646            return Err(DomainError::SurfaceNotInPane {
1647                workspace_id,
1648                pane_id,
1649                surface_id,
1650            });
1651        }
1652        Ok(())
1653    }
1654
1655    pub fn close_pane(
1656        &mut self,
1657        workspace_id: WorkspaceId,
1658        pane_id: PaneId,
1659    ) -> Result<(), DomainError> {
1660        {
1661            let workspace = self
1662                .workspaces
1663                .get(&workspace_id)
1664                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1665
1666            if !workspace.panes.contains_key(&pane_id) {
1667                return Err(DomainError::PaneNotInWorkspace {
1668                    workspace_id,
1669                    pane_id,
1670                });
1671            }
1672
1673            if workspace.panes.len() <= 1 {
1674                return self.close_workspace(workspace_id);
1675            }
1676        }
1677
1678        let workspace = self
1679            .workspaces
1680            .get_mut(&workspace_id)
1681            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1682        let window_id = workspace
1683            .window_for_pane(pane_id)
1684            .ok_or(DomainError::MissingPane(pane_id))?;
1685        let (column_id, column_index, window_index) = workspace
1686            .position_for_window(window_id)
1687            .ok_or(DomainError::MissingWorkspaceWindow(window_id))?;
1688
1689        let window_leaf_count = workspace
1690            .windows
1691            .get(&window_id)
1692            .map(|window| window.layout.leaves().len())
1693            .unwrap_or_default();
1694        if window_leaf_count <= 1 && workspace.windows.len() > 1 {
1695            let column = workspace
1696                .columns
1697                .get_mut(&column_id)
1698                .expect("window column should exist");
1699            column.window_order.remove(window_index);
1700            let same_column_survived = !column.window_order.is_empty();
1701            if same_column_survived {
1702                if !column.window_order.contains(&column.active_window) {
1703                    let replacement_index = window_index.min(column.window_order.len() - 1);
1704                    column.active_window = column.window_order[replacement_index];
1705                }
1706            } else {
1707                workspace.columns.shift_remove(&column_id);
1708            }
1709
1710            workspace.windows.shift_remove(&window_id);
1711            workspace.panes.shift_remove(&pane_id);
1712            workspace
1713                .notifications
1714                .retain(|item| item.pane_id != pane_id);
1715            if let Some(next_window_id) = workspace.fallback_window_after_close(
1716                column_index,
1717                window_index,
1718                same_column_survived,
1719            ) {
1720                workspace.sync_active_from_window(next_window_id);
1721            }
1722            return Ok(());
1723        }
1724
1725        if let Some(window) = workspace.windows.get_mut(&window_id) {
1726            let fallback_focus = close_layout_pane(window, pane_id)
1727                .or_else(|| window.layout.leaves().into_iter().next())
1728                .expect("window should retain at least one pane");
1729            window.active_pane = fallback_focus;
1730        }
1731        workspace.panes.shift_remove(&pane_id);
1732        workspace
1733            .notifications
1734            .retain(|item| item.pane_id != pane_id);
1735
1736        if workspace.active_window == window_id {
1737            workspace.sync_active_from_window(window_id);
1738        } else if workspace.active_pane == pane_id {
1739            workspace.sync_active_from_window(workspace.active_window);
1740        }
1741
1742        Ok(())
1743    }
1744
1745    pub fn close_workspace(&mut self, workspace_id: WorkspaceId) -> Result<(), DomainError> {
1746        if !self.workspaces.contains_key(&workspace_id) {
1747            return Err(DomainError::MissingWorkspace(workspace_id));
1748        }
1749
1750        if self.workspaces.len() <= 1 {
1751            self.create_workspace("Workspace 1");
1752        }
1753
1754        self.workspaces.shift_remove(&workspace_id);
1755
1756        for window in self.windows.values_mut() {
1757            window.workspace_order.retain(|id| *id != workspace_id);
1758            if window.active_workspace == workspace_id
1759                && let Some(first) = window.workspace_order.first()
1760            {
1761                window.active_workspace = *first;
1762            }
1763        }
1764
1765        Ok(())
1766    }
1767
1768    pub fn workspace_summaries(
1769        &self,
1770        window_id: WindowId,
1771    ) -> Result<Vec<WorkspaceSummary>, DomainError> {
1772        let window = self
1773            .windows
1774            .get(&window_id)
1775            .ok_or(DomainError::MissingWindow(window_id))?;
1776        let now = OffsetDateTime::now_utc();
1777
1778        let summaries = window
1779            .workspace_order
1780            .iter()
1781            .filter_map(|workspace_id| self.workspaces.get(workspace_id))
1782            .map(|workspace| {
1783                let counts = workspace.attention_counts();
1784                let agent_summaries = workspace.agent_summaries(now);
1785                let highest_attention = workspace
1786                    .panes
1787                    .values()
1788                    .map(PaneRecord::highest_attention)
1789                    .max_by_key(|attention| attention.rank())
1790                    .unwrap_or(AttentionState::Normal);
1791                let unread = workspace
1792                    .notifications
1793                    .iter()
1794                    .filter(|notification| notification.cleared_at.is_none())
1795                    .collect::<Vec<_>>();
1796                let unread_attention = unread
1797                    .iter()
1798                    .map(|notification| notification.state)
1799                    .max_by_key(|attention| attention.rank());
1800                let latest_notification = unread
1801                    .iter()
1802                    .max_by_key(|notification| notification.created_at)
1803                    .map(|notification| notification.message.clone());
1804
1805                WorkspaceSummary {
1806                    workspace_id: workspace.id,
1807                    label: workspace.label.clone(),
1808                    active_pane: workspace.active_pane,
1809                    repo_hint: workspace.repo_hint().map(str::to_owned),
1810                    agent_summaries,
1811                    counts_by_attention: counts,
1812                    highest_attention,
1813                    display_attention: unread_attention.unwrap_or(highest_attention),
1814                    unread_count: unread.len(),
1815                    latest_notification,
1816                }
1817            })
1818            .collect();
1819
1820        Ok(summaries)
1821    }
1822
1823    pub fn activity_items(&self) -> Vec<ActivityItem> {
1824        let mut items = self
1825            .workspaces
1826            .values()
1827            .flat_map(|workspace| {
1828                workspace
1829                    .notifications
1830                    .iter()
1831                    .filter(|notification| notification.cleared_at.is_none())
1832                    .map(move |notification| ActivityItem {
1833                        workspace_id: workspace.id,
1834                        workspace_window_id: workspace.window_for_pane(notification.pane_id),
1835                        pane_id: notification.pane_id,
1836                        surface_id: notification.surface_id,
1837                        kind: notification.kind.clone(),
1838                        state: notification.state,
1839                        message: notification.message.clone(),
1840                        created_at: notification.created_at,
1841                    })
1842            })
1843            .collect::<Vec<_>>();
1844
1845        items.sort_by(|left, right| right.created_at.cmp(&left.created_at));
1846        items
1847    }
1848
1849    pub fn snapshot(&self) -> PersistedSession {
1850        PersistedSession {
1851            schema_version: SESSION_SCHEMA_VERSION,
1852            captured_at: OffsetDateTime::now_utc(),
1853            model: self.clone(),
1854        }
1855    }
1856}
1857
1858#[derive(Debug, Deserialize)]
1859struct CurrentWorkspaceSerde {
1860    id: WorkspaceId,
1861    label: String,
1862    columns: IndexMap<WorkspaceColumnId, WorkspaceColumnRecord>,
1863    windows: IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>,
1864    active_window: WorkspaceWindowId,
1865    panes: IndexMap<PaneId, PaneRecord>,
1866    active_pane: PaneId,
1867    #[serde(default)]
1868    viewport: WorkspaceViewport,
1869    #[serde(default)]
1870    notifications: Vec<NotificationItem>,
1871}
1872
1873impl CurrentWorkspaceSerde {
1874    fn into_workspace(self) -> Workspace {
1875        let mut workspace = Workspace {
1876            id: self.id,
1877            label: self.label,
1878            columns: self.columns,
1879            windows: self.windows,
1880            active_window: self.active_window,
1881            panes: self.panes,
1882            active_pane: self.active_pane,
1883            viewport: self.viewport,
1884            notifications: self.notifications,
1885        };
1886        workspace.normalize();
1887        workspace
1888    }
1889}
1890
1891const RECENT_INACTIVE_AGENT_RETENTION: Duration = Duration::minutes(15);
1892
1893fn signal_creates_notification(kind: &SignalKind) -> bool {
1894    matches!(
1895        kind,
1896        SignalKind::Started
1897            | SignalKind::Completed
1898            | SignalKind::WaitingInput
1899            | SignalKind::Error
1900            | SignalKind::Notification
1901    )
1902}
1903
1904fn is_agent_kind(agent_kind: Option<&str>) -> bool {
1905    agent_kind
1906        .map(str::trim)
1907        .is_some_and(|agent| !agent.is_empty() && agent != "shell")
1908}
1909
1910fn recent_inactive_cutoff(now: OffsetDateTime) -> OffsetDateTime {
1911    now - RECENT_INACTIVE_AGENT_RETENTION
1912}
1913
1914fn workspace_agent_state(
1915    surface: &SurfaceRecord,
1916    now: OffsetDateTime,
1917) -> Option<WorkspaceAgentState> {
1918    if !is_agent_kind(surface.metadata.agent_kind.as_deref()) {
1919        return None;
1920    }
1921
1922    if !surface.metadata.agent_active {
1923        return surface
1924            .metadata
1925            .last_signal_at
1926            .filter(|timestamp| *timestamp >= recent_inactive_cutoff(now))
1927            .map(|_| WorkspaceAgentState::Inactive);
1928    }
1929
1930    match surface.attention {
1931        AttentionState::Busy => Some(WorkspaceAgentState::Working),
1932        AttentionState::WaitingInput => Some(WorkspaceAgentState::Waiting),
1933        AttentionState::Completed | AttentionState::Error | AttentionState::Normal => surface
1934            .metadata
1935            .last_signal_at
1936            .filter(|timestamp| *timestamp >= recent_inactive_cutoff(now))
1937            .map(|_| WorkspaceAgentState::Inactive),
1938    }
1939}
1940
1941fn close_layout_pane(window: &mut WorkspaceWindowRecord, pane_id: PaneId) -> Option<PaneId> {
1942    let fallback = [
1943        Direction::Right,
1944        Direction::Down,
1945        Direction::Left,
1946        Direction::Up,
1947    ]
1948    .into_iter()
1949    .find_map(|direction| window.layout.focus_neighbor(pane_id, direction))
1950    .or_else(|| {
1951        window
1952            .layout
1953            .leaves()
1954            .into_iter()
1955            .find(|candidate| *candidate != pane_id)
1956    });
1957    let removed = window.layout.remove_leaf(pane_id);
1958    removed.then_some(fallback).flatten()
1959}
1960
1961fn map_signal_to_attention(kind: &SignalKind) -> AttentionState {
1962    match kind {
1963        SignalKind::Metadata => AttentionState::Normal,
1964        SignalKind::Started | SignalKind::Progress => AttentionState::Busy,
1965        SignalKind::Completed => AttentionState::Completed,
1966        SignalKind::WaitingInput => AttentionState::WaitingInput,
1967        SignalKind::Error => AttentionState::Error,
1968        SignalKind::Notification => AttentionState::WaitingInput,
1969    }
1970}
1971
1972fn signal_agent_active(kind: &SignalKind) -> Option<bool> {
1973    match kind {
1974        SignalKind::Metadata => None,
1975        SignalKind::Started | SignalKind::Progress | SignalKind::WaitingInput => Some(true),
1976        SignalKind::Completed | SignalKind::Error => Some(false),
1977        SignalKind::Notification => None,
1978    }
1979}
1980
1981#[cfg(test)]
1982mod tests {
1983    use serde_json::json;
1984
1985    use super::*;
1986    use crate::SignalPaneMetadata;
1987
1988    #[test]
1989    fn creating_workspace_windows_creates_columns_and_stacks() {
1990        let mut model = AppModel::new("Main");
1991        let workspace_id = model.active_workspace_id().expect("workspace");
1992        let first_window_id = model
1993            .active_workspace()
1994            .map(|workspace| workspace.active_window)
1995            .expect("window");
1996
1997        let right_pane = model
1998            .create_workspace_window(workspace_id, Direction::Right)
1999            .expect("window created");
2000        let stacked_pane = model
2001            .create_workspace_window(workspace_id, Direction::Down)
2002            .expect("stacked window created");
2003        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
2004        let right_column = workspace
2005            .columns
2006            .values()
2007            .find(|column| column.window_order.contains(&workspace.active_window))
2008            .expect("active column");
2009
2010        assert_eq!(workspace.windows.len(), 3);
2011        assert_eq!(workspace.columns.len(), 2);
2012        assert_eq!(workspace.active_pane, stacked_pane);
2013        assert_eq!(right_column.window_order.len(), 2);
2014        assert_ne!(workspace.active_window, first_window_id);
2015        assert!(
2016            workspace
2017                .columns
2018                .values()
2019                .any(|column| column.window_order == vec![first_window_id])
2020        );
2021        let upper_window_id = right_column.window_order[0];
2022        assert_eq!(
2023            workspace
2024                .windows
2025                .get(&upper_window_id)
2026                .expect("window")
2027                .active_pane,
2028            right_pane
2029        );
2030    }
2031
2032    #[test]
2033    fn split_pane_updates_inner_layout_and_focus() {
2034        let mut model = AppModel::new("Main");
2035        let workspace_id = model.active_workspace_id().expect("workspace");
2036        let first_pane = model
2037            .active_workspace()
2038            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
2039            .expect("pane");
2040
2041        let new_pane = model
2042            .split_pane(workspace_id, Some(first_pane), SplitAxis::Vertical)
2043            .expect("split works");
2044        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
2045        let active_window = workspace.active_window_record().expect("window");
2046
2047        assert_eq!(workspace.active_pane, new_pane);
2048        assert_eq!(active_window.layout.leaves(), vec![first_pane, new_pane]);
2049    }
2050
2051    #[test]
2052    fn directional_focus_prefers_top_level_windows_and_restores_inner_focus() {
2053        let mut model = AppModel::new("Main");
2054        let workspace_id = model.active_workspace_id().expect("workspace");
2055        let first_pane = model
2056            .active_workspace()
2057            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
2058            .expect("pane");
2059        let right_window_pane = model
2060            .create_workspace_window(workspace_id, Direction::Right)
2061            .expect("window");
2062        let lower_window_pane = model
2063            .create_workspace_window(workspace_id, Direction::Down)
2064            .expect("window");
2065        let lower_right_pane = model
2066            .split_pane(workspace_id, Some(lower_window_pane), SplitAxis::Vertical)
2067            .expect("split");
2068
2069        model
2070            .focus_pane(workspace_id, lower_window_pane)
2071            .expect("focus lower window");
2072        model
2073            .focus_pane(workspace_id, first_pane)
2074            .expect("focus left window");
2075        model
2076            .focus_pane_direction(workspace_id, Direction::Right)
2077            .expect("move right");
2078
2079        assert_eq!(
2080            model
2081                .workspaces
2082                .get(&workspace_id)
2083                .expect("workspace")
2084                .active_pane,
2085            lower_window_pane
2086        );
2087
2088        model
2089            .focus_pane(workspace_id, lower_right_pane)
2090            .expect("focus lower pane");
2091        model
2092            .focus_pane_direction(workspace_id, Direction::Up)
2093            .expect("move up");
2094        assert_eq!(
2095            model
2096                .workspaces
2097                .get(&workspace_id)
2098                .expect("workspace")
2099                .active_pane,
2100            right_window_pane
2101        );
2102
2103        model
2104            .focus_pane_direction(workspace_id, Direction::Down)
2105            .expect("move down again");
2106        model
2107            .focus_pane_direction(workspace_id, Direction::Left)
2108            .expect("move left");
2109        model
2110            .focus_pane_direction(workspace_id, Direction::Right)
2111            .expect("move right again");
2112
2113        assert_eq!(
2114            model
2115                .workspaces
2116                .get(&workspace_id)
2117                .expect("workspace")
2118                .active_pane,
2119            lower_right_pane
2120        );
2121    }
2122
2123    #[test]
2124    fn closing_last_pane_in_window_removes_window_and_falls_back() {
2125        let mut model = AppModel::new("Main");
2126        let workspace_id = model.active_workspace_id().expect("workspace");
2127        let right_window_pane = model
2128            .create_workspace_window(workspace_id, Direction::Right)
2129            .expect("window");
2130        let lower_window_pane = model
2131            .create_workspace_window(workspace_id, Direction::Down)
2132            .expect("window");
2133
2134        model
2135            .close_pane(workspace_id, lower_window_pane)
2136            .expect("close pane");
2137
2138        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
2139        assert_eq!(workspace.windows.len(), 2);
2140        assert!(!workspace.panes.contains_key(&lower_window_pane));
2141        assert_eq!(workspace.active_pane, right_window_pane);
2142        let right_column = workspace
2143            .columns
2144            .values()
2145            .find(|column| column.window_order.contains(&workspace.active_window))
2146            .expect("right column");
2147        assert_eq!(right_column.window_order.len(), 1);
2148    }
2149
2150    #[test]
2151    fn moving_surface_reorders_pane_without_changing_active_surface() {
2152        let mut model = AppModel::new("Main");
2153        let workspace_id = model.active_workspace_id().expect("workspace");
2154        let pane_id = model.active_workspace().expect("workspace").active_pane;
2155        let first_surface_id = model
2156            .active_workspace()
2157            .and_then(|workspace| workspace.panes.get(&pane_id))
2158            .map(|pane| pane.active_surface)
2159            .expect("surface id");
2160
2161        let second_surface_id = model
2162            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
2163            .expect("second surface");
2164        let third_surface_id = model
2165            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
2166            .expect("third surface");
2167
2168        model
2169            .focus_surface(workspace_id, pane_id, second_surface_id)
2170            .expect("focus second surface");
2171        model
2172            .move_surface(workspace_id, pane_id, second_surface_id, 0)
2173            .expect("move second surface to front");
2174
2175        let pane = model
2176            .active_workspace()
2177            .and_then(|workspace| workspace.panes.get(&pane_id))
2178            .expect("pane");
2179        let order = pane.surface_ids().collect::<Vec<_>>();
2180
2181        assert_eq!(
2182            order,
2183            vec![second_surface_id, first_surface_id, third_surface_id]
2184        );
2185        assert_eq!(pane.active_surface, second_surface_id);
2186    }
2187
2188    #[test]
2189    fn moving_surface_clamps_to_end_of_pane() {
2190        let mut model = AppModel::new("Main");
2191        let workspace_id = model.active_workspace_id().expect("workspace");
2192        let pane_id = model.active_workspace().expect("workspace").active_pane;
2193        let first_surface_id = model
2194            .active_workspace()
2195            .and_then(|workspace| workspace.panes.get(&pane_id))
2196            .map(|pane| pane.active_surface)
2197            .expect("surface id");
2198        let second_surface_id = model
2199            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
2200            .expect("second surface");
2201        let third_surface_id = model
2202            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
2203            .expect("third surface");
2204
2205        model
2206            .move_surface(workspace_id, pane_id, first_surface_id, usize::MAX)
2207            .expect("move first surface to end");
2208
2209        let pane = model
2210            .active_workspace()
2211            .and_then(|workspace| workspace.panes.get(&pane_id))
2212            .expect("pane");
2213        let order = pane.surface_ids().collect::<Vec<_>>();
2214
2215        assert_eq!(
2216            order,
2217            vec![second_surface_id, third_surface_id, first_surface_id]
2218        );
2219    }
2220
2221    #[test]
2222    fn moving_surface_to_current_index_is_a_noop() {
2223        let mut model = AppModel::new("Main");
2224        let workspace_id = model.active_workspace_id().expect("workspace");
2225        let pane_id = model.active_workspace().expect("workspace").active_pane;
2226        let first_surface_id = model
2227            .active_workspace()
2228            .and_then(|workspace| workspace.panes.get(&pane_id))
2229            .map(|pane| pane.active_surface)
2230            .expect("surface id");
2231        let second_surface_id = model
2232            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
2233            .expect("second surface");
2234
2235        model
2236            .move_surface(workspace_id, pane_id, second_surface_id, 1)
2237            .expect("move second surface to current slot");
2238
2239        let pane = model
2240            .active_workspace()
2241            .and_then(|workspace| workspace.panes.get(&pane_id))
2242            .expect("pane");
2243        let order = pane.surface_ids().collect::<Vec<_>>();
2244
2245        assert_eq!(order, vec![first_surface_id, second_surface_id]);
2246        assert_eq!(pane.active_surface, second_surface_id);
2247    }
2248
2249    #[test]
2250    fn closing_surface_after_reorder_removes_the_requested_surface() {
2251        let mut model = AppModel::new("Main");
2252        let workspace_id = model.active_workspace_id().expect("workspace");
2253        let pane_id = model.active_workspace().expect("workspace").active_pane;
2254        let first_surface_id = model
2255            .active_workspace()
2256            .and_then(|workspace| workspace.panes.get(&pane_id))
2257            .map(|pane| pane.active_surface)
2258            .expect("surface id");
2259        let second_surface_id = model
2260            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
2261            .expect("second surface");
2262        let third_surface_id = model
2263            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
2264            .expect("third surface");
2265
2266        model
2267            .move_surface(workspace_id, pane_id, first_surface_id, 2)
2268            .expect("move first surface to end");
2269        model
2270            .close_surface(workspace_id, pane_id, second_surface_id)
2271            .expect("close second surface");
2272
2273        let pane = model
2274            .active_workspace()
2275            .and_then(|workspace| workspace.panes.get(&pane_id))
2276            .expect("pane");
2277        let order = pane.surface_ids().collect::<Vec<_>>();
2278
2279        assert_eq!(order, vec![third_surface_id, first_surface_id]);
2280        assert!(!order.contains(&second_surface_id));
2281    }
2282
2283    #[test]
2284    fn resizing_window_and_split_updates_state() {
2285        let mut model = AppModel::new("Main");
2286        let workspace_id = model.active_workspace_id().expect("workspace");
2287        let first_pane = model
2288            .active_workspace()
2289            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
2290            .expect("pane");
2291        let second_pane = model
2292            .split_pane(workspace_id, Some(first_pane), SplitAxis::Horizontal)
2293            .expect("split");
2294
2295        model
2296            .focus_pane(workspace_id, second_pane)
2297            .expect("focus second pane");
2298        model
2299            .resize_active_pane_split(workspace_id, Direction::Right, 60)
2300            .expect("resize split");
2301        model
2302            .resize_active_window(workspace_id, Direction::Right, 120)
2303            .expect("resize window");
2304        model
2305            .resize_active_window(workspace_id, Direction::Down, 90)
2306            .expect("resize height");
2307
2308        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
2309        let window = workspace.active_window_record().expect("window");
2310        let column = workspace
2311            .active_column_id()
2312            .and_then(|column_id| workspace.columns.get(&column_id))
2313            .expect("column");
2314        let LayoutNode::Split { ratio, .. } = &window.layout else {
2315            panic!("expected split layout");
2316        };
2317        assert_eq!(*ratio, 440);
2318        assert_eq!(column.width, DEFAULT_WORKSPACE_WINDOW_WIDTH + 120);
2319        assert_eq!(window.height, DEFAULT_WORKSPACE_WINDOW_HEIGHT + 90);
2320    }
2321
2322    #[test]
2323    fn clean_break_rejects_legacy_workspace_layouts() {
2324        let workspace_id = WorkspaceId::new();
2325        let window_id = WindowId::new();
2326        let left_pane = PaneRecord::new(PaneKind::Terminal);
2327        let right_pane = PaneRecord::new(PaneKind::Terminal);
2328
2329        let encoded = json!({
2330            "schema_version": 1,
2331            "captured_at": OffsetDateTime::now_utc(),
2332            "model": {
2333                "active_window": window_id,
2334                "windows": {
2335                    window_id.to_string(): {
2336                        "id": window_id,
2337                        "workspace_order": [workspace_id],
2338                        "active_workspace": workspace_id
2339                    }
2340                },
2341                "workspaces": {
2342                    workspace_id.to_string(): {
2343                        "id": workspace_id,
2344                        "label": "Main",
2345                        "layout": {
2346                            "kind": "scrollable_tiling",
2347                            "columns": [
2348                                {"panes": [left_pane.id]},
2349                                {"panes": [right_pane.id]}
2350                            ],
2351                            "viewport": {"x": 64, "y": 24}
2352                        },
2353                        "panes": {
2354                            left_pane.id.to_string(): left_pane,
2355                            right_pane.id.to_string(): right_pane
2356                        },
2357                        "active_pane": right_pane.id,
2358                        "notifications": []
2359                    }
2360                }
2361            }
2362        });
2363
2364        let decoded = serde_json::from_value::<PersistedSession>(encoded);
2365        assert!(decoded.is_err());
2366    }
2367
2368    #[test]
2369    fn signals_flow_into_activity_and_summary() {
2370        let mut model = AppModel::new("Main");
2371        let workspace_id = model.active_workspace_id().expect("workspace");
2372        let pane_id = model
2373            .active_workspace()
2374            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
2375            .expect("pane");
2376
2377        model
2378            .apply_signal(
2379                workspace_id,
2380                pane_id,
2381                SignalEvent::new(
2382                    "test",
2383                    SignalKind::WaitingInput,
2384                    Some("Need approval".into()),
2385                ),
2386            )
2387            .expect("signal applied");
2388
2389        let summaries = model
2390            .workspace_summaries(model.active_window)
2391            .expect("summary available");
2392        let summary = summaries.first().expect("summary");
2393
2394        assert_eq!(summary.highest_attention, AttentionState::WaitingInput);
2395        assert_eq!(
2396            summary
2397                .counts_by_attention
2398                .get(&AttentionState::WaitingInput)
2399                .copied(),
2400            Some(1)
2401        );
2402        assert_eq!(model.activity_items().len(), 1);
2403    }
2404
2405    #[test]
2406    fn persisted_session_roundtrips() {
2407        let model = AppModel::demo();
2408        let snapshot = model.snapshot();
2409        let encoded = serde_json::to_string_pretty(&snapshot).expect("serialize");
2410        let decoded: PersistedSession = serde_json::from_str(&encoded).expect("deserialize");
2411
2412        assert_eq!(decoded.schema_version, SESSION_SCHEMA_VERSION);
2413        assert_eq!(decoded.model.workspaces.len(), model.workspaces.len());
2414    }
2415
2416    #[test]
2417    fn notification_signal_maps_to_waiting_attention() {
2418        let mut model = AppModel::new("Main");
2419        let workspace_id = model.active_workspace_id().expect("workspace");
2420        let pane_id = model.active_workspace().expect("workspace").active_pane;
2421
2422        model
2423            .apply_signal(
2424                workspace_id,
2425                pane_id,
2426                SignalEvent::new(
2427                    "notify:Codex",
2428                    SignalKind::Notification,
2429                    Some("Turn complete".into()),
2430                ),
2431            )
2432            .expect("signal applied");
2433
2434        let surface = model
2435            .active_workspace()
2436            .and_then(|workspace| workspace.panes.get(&pane_id))
2437            .and_then(PaneRecord::active_surface)
2438            .expect("surface");
2439        assert_eq!(surface.attention, AttentionState::WaitingInput);
2440    }
2441
2442    #[test]
2443    fn progress_signals_update_attention_without_creating_activity_items() {
2444        let mut model = AppModel::new("Main");
2445        let workspace_id = model.active_workspace_id().expect("workspace");
2446        let pane_id = model.active_workspace().expect("workspace").active_pane;
2447
2448        model
2449            .update_pane_metadata(
2450                pane_id,
2451                PaneMetadataPatch {
2452                    title: Some("Codex".into()),
2453                    cwd: None,
2454                    repo_name: None,
2455                    git_branch: None,
2456                    ports: None,
2457                    agent_kind: Some("codex".into()),
2458                },
2459            )
2460            .expect("metadata updated");
2461        model
2462            .apply_signal(
2463                workspace_id,
2464                pane_id,
2465                SignalEvent::new("test", SignalKind::Progress, Some("Still working".into())),
2466            )
2467            .expect("signal applied");
2468
2469        let surface = model
2470            .active_workspace()
2471            .and_then(|workspace| workspace.panes.get(&pane_id))
2472            .and_then(PaneRecord::active_surface)
2473            .expect("surface");
2474
2475        assert_eq!(surface.attention, AttentionState::Busy);
2476        assert!(model.activity_items().is_empty());
2477    }
2478
2479    #[test]
2480    fn metadata_signals_do_not_keep_recent_inactive_agents_alive() {
2481        let mut model = AppModel::new("Main");
2482        let workspace_id = model.active_workspace_id().expect("workspace");
2483        let pane_id = model.active_workspace().expect("workspace").active_pane;
2484        let stale_timestamp = OffsetDateTime::now_utc() - Duration::minutes(20);
2485
2486        model
2487            .apply_signal(
2488                workspace_id,
2489                pane_id,
2490                SignalEvent {
2491                    source: "test".into(),
2492                    kind: SignalKind::Completed,
2493                    message: Some("Done".into()),
2494                    metadata: Some(SignalPaneMetadata {
2495                        title: Some("Codex".into()),
2496                        cwd: None,
2497                        repo_name: None,
2498                        git_branch: None,
2499                        ports: Vec::new(),
2500                        agent_kind: Some("codex".into()),
2501                        agent_active: Some(false),
2502                    }),
2503                    timestamp: stale_timestamp,
2504                },
2505            )
2506            .expect("completed signal applied");
2507
2508        model
2509            .apply_signal(
2510                workspace_id,
2511                pane_id,
2512                SignalEvent::with_metadata(
2513                    "test",
2514                    SignalKind::Metadata,
2515                    None,
2516                    Some(SignalPaneMetadata {
2517                        title: Some("codex :: taskers".into()),
2518                        cwd: Some("/tmp".into()),
2519                        repo_name: Some("taskers".into()),
2520                        git_branch: Some("main".into()),
2521                        ports: Vec::new(),
2522                        agent_kind: Some("codex".into()),
2523                        agent_active: Some(false),
2524                    }),
2525                ),
2526            )
2527            .expect("metadata signal applied");
2528
2529        let summaries = model
2530            .workspace_summaries(model.active_window)
2531            .expect("workspace summaries");
2532        assert!(
2533            summaries
2534                .first()
2535                .expect("summary")
2536                .agent_summaries
2537                .is_empty()
2538        );
2539
2540        let surface = model
2541            .active_workspace()
2542            .and_then(|workspace| workspace.panes.get(&pane_id))
2543            .and_then(PaneRecord::active_surface)
2544            .expect("surface");
2545        assert_eq!(surface.metadata.last_signal_at, Some(stale_timestamp));
2546    }
2547
2548    #[test]
2549    fn marking_surface_completed_clears_activity_and_keeps_recent_inactive_status() {
2550        let mut model = AppModel::new("Main");
2551        let workspace_id = model.active_workspace_id().expect("workspace");
2552        let pane_id = model.active_workspace().expect("workspace").active_pane;
2553        let surface_id = model
2554            .active_workspace()
2555            .and_then(|workspace| workspace.panes.get(&pane_id))
2556            .map(|pane| pane.active_surface)
2557            .expect("surface id");
2558
2559        model
2560            .apply_signal(
2561                workspace_id,
2562                pane_id,
2563                SignalEvent::with_metadata(
2564                    "test",
2565                    SignalKind::WaitingInput,
2566                    Some("Need review".into()),
2567                    Some(SignalPaneMetadata {
2568                        title: Some("Codex".into()),
2569                        cwd: None,
2570                        repo_name: None,
2571                        git_branch: None,
2572                        ports: Vec::new(),
2573                        agent_kind: Some("codex".into()),
2574                        agent_active: Some(true),
2575                    }),
2576                ),
2577            )
2578            .expect("waiting signal applied");
2579
2580        assert_eq!(model.activity_items().len(), 1);
2581
2582        model
2583            .mark_surface_completed(workspace_id, pane_id, surface_id)
2584            .expect("mark completed");
2585
2586        let surface = model
2587            .active_workspace()
2588            .and_then(|workspace| workspace.panes.get(&pane_id))
2589            .and_then(PaneRecord::active_surface)
2590            .expect("surface");
2591        assert_eq!(surface.attention, AttentionState::Completed);
2592        assert!(model.activity_items().is_empty());
2593
2594        let summaries = model
2595            .workspace_summaries(model.active_window)
2596            .expect("workspace summaries");
2597        assert_eq!(
2598            summaries
2599                .first()
2600                .and_then(|summary| summary.agent_summaries.first())
2601                .map(|summary| summary.state),
2602            Some(WorkspaceAgentState::Inactive)
2603        );
2604    }
2605
2606    #[test]
2607    fn metadata_inactive_resolves_waiting_agent_state() {
2608        let mut model = AppModel::new("Main");
2609        let workspace_id = model.active_workspace_id().expect("workspace");
2610        let pane_id = model.active_workspace().expect("workspace").active_pane;
2611
2612        model
2613            .apply_signal(
2614                workspace_id,
2615                pane_id,
2616                SignalEvent::with_metadata(
2617                    "test",
2618                    SignalKind::WaitingInput,
2619                    Some("Need input".into()),
2620                    Some(SignalPaneMetadata {
2621                        title: Some("Codex".into()),
2622                        cwd: None,
2623                        repo_name: None,
2624                        git_branch: None,
2625                        ports: Vec::new(),
2626                        agent_kind: Some("codex".into()),
2627                        agent_active: Some(true),
2628                    }),
2629                ),
2630            )
2631            .expect("waiting signal applied");
2632
2633        model
2634            .apply_signal(
2635                workspace_id,
2636                pane_id,
2637                SignalEvent::with_metadata(
2638                    "test",
2639                    SignalKind::Metadata,
2640                    None,
2641                    Some(SignalPaneMetadata {
2642                        title: Some("codex :: taskers".into()),
2643                        cwd: Some("/tmp".into()),
2644                        repo_name: Some("taskers".into()),
2645                        git_branch: Some("main".into()),
2646                        ports: Vec::new(),
2647                        agent_kind: Some("codex".into()),
2648                        agent_active: Some(false),
2649                    }),
2650                ),
2651            )
2652            .expect("metadata signal applied");
2653
2654        let workspace = model.active_workspace().expect("workspace");
2655        let surface = workspace
2656            .panes
2657            .get(&pane_id)
2658            .and_then(PaneRecord::active_surface)
2659            .expect("surface");
2660        assert_eq!(surface.attention, AttentionState::Completed);
2661        assert!(!surface.metadata.agent_active);
2662        assert!(
2663            workspace
2664                .notifications
2665                .iter()
2666                .all(|item| item.cleared_at.is_some())
2667        );
2668
2669        let summaries = model
2670            .workspace_summaries(model.active_window)
2671            .expect("workspace summaries");
2672        assert_eq!(
2673            summaries
2674                .first()
2675                .and_then(|summary| summary.agent_summaries.first())
2676                .map(|summary| summary.state),
2677            Some(WorkspaceAgentState::Inactive)
2678        );
2679    }
2680
2681    #[test]
2682    fn focusing_waiting_agent_does_not_clear_attention_item() {
2683        let mut model = AppModel::new("Main");
2684        let workspace_id = model.active_workspace_id().expect("workspace");
2685        let window_id = model.active_window;
2686        let pane_id = model.active_workspace().expect("workspace").active_pane;
2687        let surface_id = model
2688            .active_workspace()
2689            .and_then(|workspace| workspace.panes.get(&pane_id))
2690            .map(|pane| pane.active_surface)
2691            .expect("surface id");
2692
2693        model
2694            .apply_signal(
2695                workspace_id,
2696                pane_id,
2697                SignalEvent::with_metadata(
2698                    "test",
2699                    SignalKind::WaitingInput,
2700                    Some("Need review".into()),
2701                    Some(SignalPaneMetadata {
2702                        title: Some("Codex".into()),
2703                        cwd: None,
2704                        repo_name: None,
2705                        git_branch: None,
2706                        ports: Vec::new(),
2707                        agent_kind: Some("codex".into()),
2708                        agent_active: Some(true),
2709                    }),
2710                ),
2711            )
2712            .expect("waiting signal applied");
2713
2714        let other_workspace_id = model.create_workspace("Docs");
2715        assert_eq!(model.activity_items().len(), 1);
2716
2717        model
2718            .switch_workspace(window_id, workspace_id)
2719            .expect("switch back to waiting workspace");
2720        model
2721            .focus_surface(workspace_id, pane_id, surface_id)
2722            .expect("focus waiting surface");
2723
2724        let activity_items = model.activity_items();
2725        assert_eq!(activity_items.len(), 1);
2726        assert_eq!(activity_items[0].state, AttentionState::WaitingInput);
2727        assert_eq!(
2728            model
2729                .workspaces
2730                .get(&workspace_id)
2731                .expect("workspace")
2732                .notifications
2733                .iter()
2734                .filter(|item| item.cleared_at.is_none())
2735                .count(),
2736            1
2737        );
2738        assert_eq!(model.active_workspace_id(), Some(workspace_id));
2739        assert_ne!(workspace_id, other_workspace_id);
2740    }
2741}