Skip to main content

taskers_domain/
model.rs

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