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::OffsetDateTime;
7
8use crate::{
9    AttentionState, Direction, LayoutNode, NotificationId, PaneId, SessionId, SignalEvent,
10    SignalKind, SignalPaneMetadata, SplitAxis, SurfaceId, WindowId, WorkspaceColumnId, WorkspaceId,
11    WorkspaceWindowId, WorkspaceWindowTabId,
12};
13
14pub const SESSION_SCHEMA_VERSION: u32 = 6;
15pub const DEFAULT_WORKSPACE_WINDOW_WIDTH: i32 = 1280;
16pub const DEFAULT_WORKSPACE_WINDOW_HEIGHT: i32 = 860;
17pub const DEFAULT_WORKSPACE_WINDOW_GAP: i32 = 10;
18pub const MIN_WORKSPACE_WINDOW_WIDTH: i32 = 720;
19pub const MIN_WORKSPACE_WINDOW_HEIGHT: i32 = 420;
20pub const KEYBOARD_RESIZE_STEP: i32 = 80;
21const WORKSPACE_LOG_RETENTION: usize = 200;
22
23fn split_top_level_extent(extent: i32, min_extent: i32) -> (i32, i32) {
24    let extent = extent.max(min_extent);
25    if extent < min_extent * 2 {
26        return (min_extent, min_extent);
27    }
28
29    let retained_extent = (extent + 1) / 2;
30    let new_extent = extent - retained_extent;
31    (retained_extent.max(min_extent), new_extent.max(min_extent))
32}
33
34fn insert_window_relative_to_active(
35    workspace: &mut Workspace,
36    workspace_window_id: WorkspaceWindowId,
37    direction: Direction,
38) -> Result<(), DomainError> {
39    let (source_column_id, source_column_index, source_window_index) = workspace
40        .position_for_window(workspace.active_window)
41        .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?;
42
43    match direction {
44        Direction::Left | Direction::Right => {
45            let source_width = workspace
46                .columns
47                .get(&source_column_id)
48                .map(|column| column.width)
49                .expect("active column should exist");
50            let (retained_width, new_width) =
51                split_top_level_extent(source_width, MIN_WORKSPACE_WINDOW_WIDTH);
52            let column = workspace
53                .columns
54                .get_mut(&source_column_id)
55                .expect("active column should exist");
56            column.width = retained_width;
57
58            let mut new_column = WorkspaceColumnRecord::new(workspace_window_id);
59            new_column.width = new_width;
60            let insert_index = if matches!(direction, Direction::Left) {
61                source_column_index
62            } else {
63                source_column_index + 1
64            };
65            workspace.insert_column_at(insert_index, new_column);
66        }
67        Direction::Up | Direction::Down => {
68            let source_window_height = workspace
69                .windows
70                .get(&workspace.active_window)
71                .map(|window| window.height)
72                .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?;
73            let (retained_height, new_height) =
74                split_top_level_extent(source_window_height, MIN_WORKSPACE_WINDOW_HEIGHT);
75            let source_window = workspace
76                .windows
77                .get_mut(&workspace.active_window)
78                .ok_or(DomainError::MissingWorkspaceWindow(workspace.active_window))?;
79            source_window.height = retained_height;
80            let new_window = workspace
81                .windows
82                .get_mut(&workspace_window_id)
83                .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
84            new_window.height = new_height;
85
86            let column = workspace
87                .columns
88                .get_mut(&source_column_id)
89                .expect("active column should exist");
90            let insert_index = if matches!(direction, Direction::Up) {
91                source_window_index
92            } else {
93                source_window_index + 1
94            };
95            column
96                .window_order
97                .insert(insert_index, workspace_window_id);
98            column.active_window = workspace_window_id;
99        }
100    }
101
102    Ok(())
103}
104
105#[derive(Debug, Error)]
106pub enum DomainError {
107    #[error("window {0} was not found")]
108    MissingWindow(WindowId),
109    #[error("workspace {0} was not found")]
110    MissingWorkspace(WorkspaceId),
111    #[error("workspace column {0} was not found")]
112    MissingWorkspaceColumn(WorkspaceColumnId),
113    #[error("workspace window {0} was not found")]
114    MissingWorkspaceWindow(WorkspaceWindowId),
115    #[error("workspace window tab {0} was not found")]
116    MissingWorkspaceWindowTab(WorkspaceWindowTabId),
117    #[error("pane {0} was not found")]
118    MissingPane(PaneId),
119    #[error("surface {0} was not found")]
120    MissingSurface(SurfaceId),
121    #[error("workspace {workspace_id} does not contain pane {pane_id}")]
122    PaneNotInWorkspace {
123        workspace_id: WorkspaceId,
124        pane_id: PaneId,
125    },
126    #[error("workspace {workspace_id} pane {pane_id} does not contain surface {surface_id}")]
127    SurfaceNotInPane {
128        workspace_id: WorkspaceId,
129        pane_id: PaneId,
130        surface_id: SurfaceId,
131    },
132    #[error("{0}")]
133    InvalidOperation(&'static str),
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum PaneKind {
139    Terminal,
140    Browser,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct ProgressState {
145    /// Progress as permille (0–1000).
146    pub value: u16,
147    pub label: Option<String>,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "snake_case")]
152pub enum PrStatus {
153    Open,
154    Draft,
155    Merged,
156    Closed,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct PullRequestState {
161    pub number: u32,
162    pub title: String,
163    pub status: PrStatus,
164    pub url: String,
165}
166
167#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
168pub struct PaneMetadata {
169    pub title: Option<String>,
170    #[serde(default)]
171    pub agent_title: Option<String>,
172    pub cwd: Option<String>,
173    pub url: Option<String>,
174    pub repo_name: Option<String>,
175    pub git_branch: Option<String>,
176    pub ports: Vec<u16>,
177    pub agent_kind: Option<String>,
178    #[serde(default)]
179    pub agent_active: bool,
180    #[serde(default)]
181    pub agent_state: Option<WorkspaceAgentState>,
182    #[serde(default)]
183    pub latest_agent_message: Option<String>,
184    pub last_signal_at: Option<OffsetDateTime>,
185    #[serde(default)]
186    pub progress: Option<ProgressState>,
187    #[serde(default)]
188    pub pull_requests: Vec<PullRequestState>,
189}
190
191#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
192pub struct PaneMetadataPatch {
193    pub title: Option<String>,
194    pub cwd: Option<String>,
195    pub url: Option<String>,
196    pub repo_name: Option<String>,
197    pub git_branch: Option<String>,
198    pub ports: Option<Vec<u16>>,
199    pub agent_kind: Option<String>,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct SurfaceAgentSession {
204    pub id: SessionId,
205    pub kind: String,
206    pub title: String,
207    pub state: WorkspaceAgentState,
208    pub latest_message: Option<String>,
209    pub updated_at: OffsetDateTime,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct SurfaceAgentProcess {
214    pub id: SessionId,
215    pub kind: String,
216    pub title: String,
217    pub started_at: OffsetDateTime,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct SurfaceRecord {
222    pub id: SurfaceId,
223    pub kind: PaneKind,
224    pub metadata: PaneMetadata,
225    #[serde(default)]
226    pub agent_process: Option<SurfaceAgentProcess>,
227    #[serde(default)]
228    pub agent_session: Option<SurfaceAgentSession>,
229    pub attention: AttentionState,
230    pub session_id: SessionId,
231    pub command: Option<Vec<String>>,
232}
233
234impl SurfaceRecord {
235    pub fn new(kind: PaneKind) -> Self {
236        Self {
237            id: SurfaceId::new(),
238            kind,
239            metadata: PaneMetadata::default(),
240            agent_process: None,
241            agent_session: None,
242            attention: AttentionState::Normal,
243            session_id: SessionId::new(),
244            command: None,
245        }
246    }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct PaneRecord {
251    pub id: PaneId,
252    pub surfaces: IndexMap<SurfaceId, SurfaceRecord>,
253    pub active_surface: SurfaceId,
254}
255
256impl PaneRecord {
257    pub fn new(kind: PaneKind) -> Self {
258        let surface = SurfaceRecord::new(kind);
259        Self::from_surface(surface)
260    }
261
262    fn from_surface(surface: SurfaceRecord) -> Self {
263        let active_surface = surface.id;
264        let mut surfaces = IndexMap::new();
265        surfaces.insert(active_surface, surface);
266        Self {
267            id: PaneId::new(),
268            surfaces,
269            active_surface,
270        }
271    }
272
273    pub fn active_surface(&self) -> Option<&SurfaceRecord> {
274        self.surfaces.get(&self.active_surface)
275    }
276
277    pub fn active_surface_mut(&mut self) -> Option<&mut SurfaceRecord> {
278        self.surfaces.get_mut(&self.active_surface)
279    }
280
281    pub fn active_metadata(&self) -> Option<&PaneMetadata> {
282        self.active_surface().map(|surface| &surface.metadata)
283    }
284
285    pub fn active_metadata_mut(&mut self) -> Option<&mut PaneMetadata> {
286        self.active_surface_mut()
287            .map(|surface| &mut surface.metadata)
288    }
289
290    pub fn active_kind(&self) -> Option<PaneKind> {
291        self.active_surface().map(|surface| surface.kind.clone())
292    }
293
294    pub fn active_attention(&self) -> AttentionState {
295        self.active_surface()
296            .map(|surface| surface.attention)
297            .unwrap_or(AttentionState::Normal)
298    }
299
300    pub fn active_session_id(&self) -> Option<SessionId> {
301        self.active_surface().map(|surface| surface.session_id)
302    }
303
304    pub fn active_command(&self) -> Option<&[String]> {
305        self.active_surface()
306            .and_then(|surface| surface.command.as_deref())
307    }
308
309    pub fn highest_attention(&self) -> AttentionState {
310        self.surfaces
311            .values()
312            .map(|surface| surface.attention)
313            .max_by_key(|attention| attention.rank())
314            .unwrap_or(AttentionState::Normal)
315    }
316
317    pub fn surface_ids(&self) -> impl Iterator<Item = SurfaceId> + '_ {
318        self.surfaces.keys().copied()
319    }
320
321    fn insert_surface(&mut self, surface: SurfaceRecord) {
322        self.active_surface = surface.id;
323        self.surfaces.insert(surface.id, surface);
324    }
325
326    fn focus_surface(&mut self, surface_id: SurfaceId) -> bool {
327        if self.surfaces.contains_key(&surface_id) {
328            self.active_surface = surface_id;
329            true
330        } else {
331            false
332        }
333    }
334
335    fn move_surface(&mut self, surface_id: SurfaceId, to_index: usize) -> bool {
336        let Some(from_index) = self.surfaces.get_index_of(&surface_id) else {
337            return false;
338        };
339
340        let last_index = self.surfaces.len().saturating_sub(1);
341        let target_index = to_index.min(last_index);
342        if from_index == target_index {
343            return true;
344        }
345
346        self.surfaces.move_index(from_index, target_index);
347        true
348    }
349
350    fn normalize_active_surface(&mut self) {
351        if !self.surfaces.contains_key(&self.active_surface) {
352            self.active_surface = self
353                .surfaces
354                .first()
355                .map(|(surface_id, _)| *surface_id)
356                .expect("pane has at least one surface");
357        }
358    }
359
360    fn normalize(&mut self) {
361        if self.surfaces.is_empty() {
362            let replacement = SurfaceRecord::new(PaneKind::Terminal);
363            self.active_surface = replacement.id;
364            self.surfaces.insert(replacement.id, replacement);
365            return;
366        }
367
368        self.normalize_active_surface();
369    }
370}
371
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct NotificationItem {
374    pub id: NotificationId,
375    pub pane_id: PaneId,
376    pub surface_id: SurfaceId,
377    #[serde(default = "default_notification_kind")]
378    pub kind: SignalKind,
379    pub state: AttentionState,
380    #[serde(default)]
381    pub title: Option<String>,
382    #[serde(default)]
383    pub subtitle: Option<String>,
384    #[serde(default)]
385    pub external_id: Option<String>,
386    pub message: String,
387    pub created_at: OffsetDateTime,
388    #[serde(default)]
389    pub read_at: Option<OffsetDateTime>,
390    pub cleared_at: Option<OffsetDateTime>,
391    #[serde(default = "default_notification_delivery_state")]
392    pub desktop_delivery: NotificationDeliveryState,
393}
394
395impl NotificationItem {
396    pub fn unread(&self) -> bool {
397        self.cleared_at.is_none() && self.read_at.is_none()
398    }
399
400    pub fn active(&self) -> bool {
401        self.cleared_at.is_none()
402    }
403}
404
405#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
406pub struct WorkspaceLogEntry {
407    #[serde(default)]
408    pub source: Option<String>,
409    pub message: String,
410    pub created_at: OffsetDateTime,
411}
412
413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
414pub struct ActivityItem {
415    pub notification_id: NotificationId,
416    pub workspace_id: WorkspaceId,
417    pub workspace_window_id: Option<WorkspaceWindowId>,
418    pub pane_id: PaneId,
419    pub surface_id: SurfaceId,
420    pub kind: SignalKind,
421    pub state: AttentionState,
422    pub title: Option<String>,
423    pub subtitle: Option<String>,
424    pub message: String,
425    pub read_at: Option<OffsetDateTime>,
426    pub created_at: OffsetDateTime,
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
430#[serde(rename_all = "snake_case")]
431pub enum NotificationDeliveryState {
432    Pending,
433    Shown,
434    Suppressed,
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
438#[serde(rename_all = "snake_case")]
439pub enum WorkspaceAgentState {
440    Working,
441    Waiting,
442    Completed,
443    Failed,
444}
445
446impl WorkspaceAgentState {
447    pub fn label(self) -> &'static str {
448        match self {
449            Self::Working => "Working",
450            Self::Waiting => "Waiting",
451            Self::Completed => "Completed",
452            Self::Failed => "Failed",
453        }
454    }
455
456    fn sort_rank(self) -> u8 {
457        match self {
458            Self::Waiting => 0,
459            Self::Working => 1,
460            Self::Failed => 2,
461            Self::Completed => 3,
462        }
463    }
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
467pub struct WorkspaceAgentSummary {
468    pub workspace_window_id: WorkspaceWindowId,
469    pub pane_id: PaneId,
470    pub surface_id: SurfaceId,
471    pub agent_kind: String,
472    pub title: Option<String>,
473    pub state: WorkspaceAgentState,
474    pub last_signal_at: Option<OffsetDateTime>,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(tag = "scope", rename_all = "snake_case")]
479pub enum AgentTarget {
480    Workspace {
481        workspace_id: WorkspaceId,
482    },
483    Pane {
484        workspace_id: WorkspaceId,
485        pane_id: PaneId,
486    },
487    Surface {
488        workspace_id: WorkspaceId,
489        pane_id: PaneId,
490        surface_id: SurfaceId,
491    },
492}
493
494fn default_notification_kind() -> SignalKind {
495    SignalKind::Notification
496}
497
498fn default_notification_delivery_state() -> NotificationDeliveryState {
499    NotificationDeliveryState::Shown
500}
501
502#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
503pub struct WorkspaceViewport {
504    #[serde(default)]
505    pub x: i32,
506    #[serde(default)]
507    pub y: i32,
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
511#[serde(rename_all = "snake_case")]
512pub enum WorkspaceWindowMoveTarget {
513    ColumnBefore { column_id: WorkspaceColumnId },
514    ColumnAfter { column_id: WorkspaceColumnId },
515    StackAbove { window_id: WorkspaceWindowId },
516    StackBelow { window_id: WorkspaceWindowId },
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520pub struct WindowFrame {
521    pub x: i32,
522    pub y: i32,
523    pub width: i32,
524    pub height: i32,
525}
526
527impl WindowFrame {
528    pub fn root() -> Self {
529        Self {
530            x: 0,
531            y: 0,
532            width: DEFAULT_WORKSPACE_WINDOW_WIDTH,
533            height: DEFAULT_WORKSPACE_WINDOW_HEIGHT,
534        }
535    }
536
537    pub fn right(self) -> i32 {
538        self.x + self.width
539    }
540
541    pub fn bottom(self) -> i32 {
542        self.y + self.height
543    }
544
545    pub fn center_x(self) -> i32 {
546        self.x + (self.width / 2)
547    }
548
549    pub fn center_y(self) -> i32 {
550        self.y + (self.height / 2)
551    }
552
553    pub fn shifted(self, direction: Direction) -> Self {
554        match direction {
555            Direction::Left => Self {
556                x: self.x - self.width - DEFAULT_WORKSPACE_WINDOW_GAP,
557                ..self
558            },
559            Direction::Right => Self {
560                x: self.x + self.width + DEFAULT_WORKSPACE_WINDOW_GAP,
561                ..self
562            },
563            Direction::Up => Self {
564                y: self.y - self.height - DEFAULT_WORKSPACE_WINDOW_GAP,
565                ..self
566            },
567            Direction::Down => Self {
568                y: self.y + self.height + DEFAULT_WORKSPACE_WINDOW_GAP,
569                ..self
570            },
571        }
572    }
573
574    pub fn resize_by_direction(&mut self, direction: Direction, amount: i32) {
575        match direction {
576            Direction::Left => {
577                self.width = (self.width - amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
578            }
579            Direction::Right => {
580                self.width = (self.width + amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
581            }
582            Direction::Up => {
583                self.height = (self.height - amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
584            }
585            Direction::Down => {
586                self.height = (self.height + amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
587            }
588        }
589    }
590
591    pub fn clamp(&mut self) {
592        self.width = self.width.max(MIN_WORKSPACE_WINDOW_WIDTH);
593        self.height = self.height.max(MIN_WORKSPACE_WINDOW_HEIGHT);
594    }
595}
596
597#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
598pub struct WorkspaceWindowTabRecord {
599    pub id: WorkspaceWindowTabId,
600    pub layout: LayoutNode,
601    pub active_pane: PaneId,
602}
603
604impl WorkspaceWindowTabRecord {
605    fn new(pane_id: PaneId) -> Self {
606        Self {
607            id: WorkspaceWindowTabId::new(),
608            layout: LayoutNode::leaf(pane_id),
609            active_pane: pane_id,
610        }
611    }
612
613    fn normalize(&mut self, panes: &IndexMap<PaneId, PaneRecord>, fallback_pane: PaneId) {
614        if !self.layout.contains(self.active_pane) {
615            self.active_pane = self
616                .layout
617                .leaves()
618                .into_iter()
619                .find(|pane_id| panes.contains_key(pane_id))
620                .unwrap_or(fallback_pane);
621        }
622    }
623}
624
625#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
626pub struct WorkspaceWindowRecord {
627    pub id: WorkspaceWindowId,
628    pub height: i32,
629    pub tabs: IndexMap<WorkspaceWindowTabId, WorkspaceWindowTabRecord>,
630    pub active_tab: WorkspaceWindowTabId,
631}
632
633impl WorkspaceWindowRecord {
634    fn new(pane_id: PaneId) -> Self {
635        let first_tab = WorkspaceWindowTabRecord::new(pane_id);
636        let active_tab = first_tab.id;
637        let mut tabs = IndexMap::new();
638        tabs.insert(active_tab, first_tab);
639        Self {
640            id: WorkspaceWindowId::new(),
641            height: DEFAULT_WORKSPACE_WINDOW_HEIGHT,
642            tabs,
643            active_tab,
644        }
645    }
646
647    pub fn active_tab_record(&self) -> Option<&WorkspaceWindowTabRecord> {
648        self.tabs.get(&self.active_tab)
649    }
650
651    pub fn active_tab_record_mut(&mut self) -> Option<&mut WorkspaceWindowTabRecord> {
652        self.tabs.get_mut(&self.active_tab)
653    }
654
655    pub fn active_pane(&self) -> Option<PaneId> {
656        self.active_tab_record().map(|tab| tab.active_pane)
657    }
658
659    pub fn active_layout(&self) -> Option<&LayoutNode> {
660        self.active_tab_record().map(|tab| &tab.layout)
661    }
662
663    pub fn active_layout_mut(&mut self) -> Option<&mut LayoutNode> {
664        self.active_tab_record_mut().map(|tab| &mut tab.layout)
665    }
666
667    pub fn contains_pane(&self, pane_id: PaneId) -> bool {
668        self.tabs.values().any(|tab| tab.layout.contains(pane_id))
669    }
670
671    pub fn tab_for_pane(&self, pane_id: PaneId) -> Option<WorkspaceWindowTabId> {
672        self.tabs
673            .values()
674            .find_map(|tab| tab.layout.contains(pane_id).then_some(tab.id))
675    }
676
677    pub fn focus_tab(&mut self, tab_id: WorkspaceWindowTabId) -> bool {
678        if self.tabs.contains_key(&tab_id) {
679            self.active_tab = tab_id;
680            true
681        } else {
682            false
683        }
684    }
685
686    pub fn focus_pane(&mut self, pane_id: PaneId) -> bool {
687        let Some(tab_id) = self.tab_for_pane(pane_id) else {
688            return false;
689        };
690        self.active_tab = tab_id;
691        if let Some(tab) = self.tabs.get_mut(&tab_id) {
692            tab.active_pane = pane_id;
693        }
694        true
695    }
696
697    fn insert_tab(&mut self, tab: WorkspaceWindowTabRecord, to_index: usize) {
698        let tab_id = tab.id;
699        self.tabs.insert(tab_id, tab);
700        if self.tabs.len() > 1 {
701            let last_index = self.tabs.len() - 1;
702            let target_index = to_index.min(last_index);
703            self.tabs.move_index(last_index, target_index);
704        }
705        self.active_tab = tab_id;
706    }
707
708    fn move_tab(&mut self, tab_id: WorkspaceWindowTabId, to_index: usize) -> bool {
709        let Some(from_index) = self.tabs.get_index_of(&tab_id) else {
710            return false;
711        };
712        let last_index = self.tabs.len().saturating_sub(1);
713        let target_index = to_index.min(last_index);
714        if from_index == target_index {
715            return true;
716        }
717        self.tabs.move_index(from_index, target_index);
718        true
719    }
720
721    fn remove_tab(&mut self, tab_id: WorkspaceWindowTabId) -> Option<WorkspaceWindowTabRecord> {
722        let removed = self.tabs.shift_remove(&tab_id)?;
723        if !self.tabs.contains_key(&self.active_tab)
724            && let Some((next_tab_id, _)) = self.tabs.first()
725        {
726            self.active_tab = *next_tab_id;
727        }
728        Some(removed)
729    }
730
731    fn normalize(&mut self, panes: &IndexMap<PaneId, PaneRecord>, fallback_pane: PaneId) {
732        if self.tabs.is_empty() {
733            let fallback_tab = WorkspaceWindowTabRecord::new(fallback_pane);
734            self.active_tab = fallback_tab.id;
735            self.tabs.insert(fallback_tab.id, fallback_tab);
736        }
737
738        for tab in self.tabs.values_mut() {
739            tab.normalize(panes, fallback_pane);
740        }
741
742        if !self.tabs.contains_key(&self.active_tab)
743            && let Some((tab_id, _)) = self.tabs.first()
744        {
745            self.active_tab = *tab_id;
746        }
747    }
748}
749
750#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct WorkspaceColumnRecord {
752    pub id: WorkspaceColumnId,
753    pub width: i32,
754    pub window_order: Vec<WorkspaceWindowId>,
755    pub active_window: WorkspaceWindowId,
756}
757
758impl WorkspaceColumnRecord {
759    fn new(window_id: WorkspaceWindowId) -> Self {
760        Self {
761            id: WorkspaceColumnId::new(),
762            width: DEFAULT_WORKSPACE_WINDOW_WIDTH,
763            window_order: vec![window_id],
764            active_window: window_id,
765        }
766    }
767
768    fn normalize(&mut self, windows: &IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>) {
769        self.width = self.width.max(MIN_WORKSPACE_WINDOW_WIDTH);
770        self.window_order
771            .retain(|window_id| windows.contains_key(window_id));
772        if !self.window_order.contains(&self.active_window)
773            && let Some(window_id) = self.window_order.first()
774        {
775            self.active_window = *window_id;
776        }
777    }
778}
779
780#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
781pub struct Workspace {
782    pub id: WorkspaceId,
783    pub label: String,
784    pub columns: IndexMap<WorkspaceColumnId, WorkspaceColumnRecord>,
785    pub windows: IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>,
786    pub active_window: WorkspaceWindowId,
787    pub panes: IndexMap<PaneId, PaneRecord>,
788    pub active_pane: PaneId,
789    #[serde(default)]
790    pub viewport: WorkspaceViewport,
791    pub notifications: Vec<NotificationItem>,
792    #[serde(default)]
793    pub status_text: Option<String>,
794    #[serde(default)]
795    pub progress: Option<ProgressState>,
796    #[serde(default)]
797    pub log_entries: Vec<WorkspaceLogEntry>,
798    #[serde(default)]
799    pub surface_flash_tokens: BTreeMap<SurfaceId, u64>,
800    #[serde(default)]
801    pub next_flash_token: u64,
802    #[serde(default)]
803    pub custom_color: Option<String>,
804}
805
806impl<'de> Deserialize<'de> for Workspace {
807    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
808    where
809        D: Deserializer<'de>,
810    {
811        Ok(CurrentWorkspaceSerde::deserialize(deserializer)?.into_workspace())
812    }
813}
814
815impl Workspace {
816    pub fn bootstrap(label: impl Into<String>) -> Self {
817        let first_pane = PaneRecord::new(PaneKind::Terminal);
818        let active_pane = first_pane.id;
819        let mut panes = IndexMap::new();
820        panes.insert(active_pane, first_pane);
821        let first_window = WorkspaceWindowRecord::new(active_pane);
822        let active_window = first_window.id;
823        let mut windows = IndexMap::new();
824        windows.insert(active_window, first_window);
825        let first_column = WorkspaceColumnRecord::new(active_window);
826        let mut columns = IndexMap::new();
827        columns.insert(first_column.id, first_column);
828
829        Self {
830            id: WorkspaceId::new(),
831            label: label.into(),
832            columns,
833            windows,
834            active_window,
835            panes,
836            active_pane,
837            viewport: WorkspaceViewport::default(),
838            notifications: Vec::new(),
839            status_text: None,
840            progress: None,
841            log_entries: Vec::new(),
842            surface_flash_tokens: BTreeMap::new(),
843            next_flash_token: 0,
844            custom_color: None,
845        }
846    }
847
848    pub fn active_window_record(&self) -> Option<&WorkspaceWindowRecord> {
849        self.windows.get(&self.active_window)
850    }
851
852    pub fn active_window_record_mut(&mut self) -> Option<&mut WorkspaceWindowRecord> {
853        self.windows.get_mut(&self.active_window)
854    }
855
856    pub fn column_for_window(&self, window_id: WorkspaceWindowId) -> Option<WorkspaceColumnId> {
857        self.columns.iter().find_map(|(column_id, column)| {
858            column
859                .window_order
860                .contains(&window_id)
861                .then_some(*column_id)
862        })
863    }
864
865    pub fn active_column_id(&self) -> Option<WorkspaceColumnId> {
866        self.column_for_window(self.active_window)
867    }
868
869    fn position_for_window(
870        &self,
871        window_id: WorkspaceWindowId,
872    ) -> Option<(WorkspaceColumnId, usize, usize)> {
873        self.columns
874            .iter()
875            .enumerate()
876            .find_map(|(column_index, (column_id, column))| {
877                column
878                    .window_order
879                    .iter()
880                    .position(|candidate| *candidate == window_id)
881                    .map(|window_index| (*column_id, column_index, window_index))
882            })
883    }
884
885    pub fn window_for_pane(&self, pane_id: PaneId) -> Option<WorkspaceWindowId> {
886        self.windows
887            .iter()
888            .find_map(|(window_id, window)| window.contains_pane(pane_id).then_some(*window_id))
889    }
890
891    fn sync_active_from_window(&mut self, window_id: WorkspaceWindowId) {
892        if let Some(window) = self.windows.get(&window_id) {
893            self.active_window = window_id;
894            self.active_pane = window.active_pane().unwrap_or(self.active_pane);
895            if let Some(column_id) = self.column_for_window(window_id)
896                && let Some(column) = self.columns.get_mut(&column_id)
897            {
898                column.active_window = window_id;
899            }
900        }
901    }
902
903    fn focus_window(&mut self, window_id: WorkspaceWindowId) {
904        self.sync_active_from_window(window_id);
905    }
906
907    fn focus_pane(&mut self, pane_id: PaneId) -> bool {
908        let Some(window_id) = self.window_for_pane(pane_id) else {
909            return false;
910        };
911        if let Some(window) = self.windows.get_mut(&window_id) {
912            let _ = window.focus_pane(pane_id);
913        }
914        self.sync_active_from_window(window_id);
915        true
916    }
917
918    fn focus_surface(&mut self, pane_id: PaneId, surface_id: SurfaceId) -> bool {
919        let Some(pane) = self.panes.get_mut(&pane_id) else {
920            return false;
921        };
922        if !pane.focus_surface(surface_id) {
923            return false;
924        }
925        self.focus_pane(pane_id)
926    }
927
928    fn acknowledge_pane_notifications(&mut self, pane_id: PaneId) {
929        let now = OffsetDateTime::now_utc();
930        for notification in &mut self.notifications {
931            if notification.pane_id == pane_id
932                && notification.active()
933                && notification.read_at.is_none()
934            {
935                notification.read_at = Some(now);
936            }
937        }
938    }
939
940    fn acknowledge_surface_notifications(&mut self, pane_id: PaneId, surface_id: SurfaceId) {
941        let now = OffsetDateTime::now_utc();
942        for notification in &mut self.notifications {
943            if notification.pane_id == pane_id
944                && notification.surface_id == surface_id
945                && notification.active()
946                && notification.read_at.is_none()
947            {
948                notification.read_at = Some(now);
949            }
950        }
951    }
952
953    fn complete_surface_notifications(&mut self, pane_id: PaneId, surface_id: SurfaceId) {
954        let now = OffsetDateTime::now_utc();
955        for notification in &mut self.notifications {
956            if notification.pane_id == pane_id
957                && notification.surface_id == surface_id
958                && notification.cleared_at.is_none()
959            {
960                if notification.read_at.is_none() {
961                    notification.read_at = Some(now);
962                }
963                notification.cleared_at = Some(now);
964            }
965        }
966    }
967
968    fn upsert_notification(&mut self, notification: NotificationItem) {
969        if let Some(external_id) = notification.external_id.as_deref()
970            && let Some(existing) = self.notifications.iter_mut().rev().find(|existing| {
971                existing.external_id.as_deref() == Some(external_id)
972                    && existing.surface_id == notification.surface_id
973            })
974        {
975            existing.pane_id = notification.pane_id;
976            existing.kind = notification.kind;
977            existing.state = notification.state;
978            existing.title = notification.title;
979            existing.subtitle = notification.subtitle;
980            existing.message = notification.message;
981            existing.created_at = notification.created_at;
982            existing.read_at = None;
983            existing.cleared_at = None;
984            existing.desktop_delivery = NotificationDeliveryState::Pending;
985            return;
986        }
987
988        self.notifications.push(notification);
989    }
990
991    fn active_surface_for_pane(&self, pane_id: PaneId) -> Option<SurfaceId> {
992        self.panes.get(&pane_id).map(|pane| pane.active_surface)
993    }
994
995    fn notification_target_ids(
996        &self,
997        target: &AgentTarget,
998    ) -> Result<(WorkspaceId, PaneId, SurfaceId), DomainError> {
999        match *target {
1000            AgentTarget::Workspace { workspace_id } => {
1001                if workspace_id != self.id {
1002                    return Err(DomainError::MissingWorkspace(workspace_id));
1003                }
1004                let pane_id = self.active_pane;
1005                let surface_id = self.active_surface_for_pane(pane_id).ok_or(
1006                    DomainError::PaneNotInWorkspace {
1007                        workspace_id,
1008                        pane_id,
1009                    },
1010                )?;
1011                Ok((workspace_id, pane_id, surface_id))
1012            }
1013            AgentTarget::Pane {
1014                workspace_id,
1015                pane_id,
1016            } => {
1017                if workspace_id != self.id {
1018                    return Err(DomainError::MissingWorkspace(workspace_id));
1019                }
1020                let surface_id = self.active_surface_for_pane(pane_id).ok_or(
1021                    DomainError::PaneNotInWorkspace {
1022                        workspace_id,
1023                        pane_id,
1024                    },
1025                )?;
1026                Ok((workspace_id, pane_id, surface_id))
1027            }
1028            AgentTarget::Surface {
1029                workspace_id,
1030                pane_id,
1031                surface_id,
1032            } => {
1033                if workspace_id != self.id {
1034                    return Err(DomainError::MissingWorkspace(workspace_id));
1035                }
1036                if !self
1037                    .panes
1038                    .get(&pane_id)
1039                    .is_some_and(|pane| pane.surfaces.contains_key(&surface_id))
1040                {
1041                    return Err(DomainError::SurfaceNotInPane {
1042                        workspace_id,
1043                        pane_id,
1044                        surface_id,
1045                    });
1046                }
1047                Ok((workspace_id, pane_id, surface_id))
1048            }
1049        }
1050    }
1051
1052    fn clear_notifications_matching(&mut self, target: &AgentTarget) -> Result<(), DomainError> {
1053        let now = OffsetDateTime::now_utc();
1054        let mut cleared_surfaces = Vec::new();
1055        match *target {
1056            AgentTarget::Workspace { workspace_id } => {
1057                if workspace_id != self.id {
1058                    return Err(DomainError::MissingWorkspace(workspace_id));
1059                }
1060                for notification in &mut self.notifications {
1061                    if notification.cleared_at.is_none() {
1062                        let target = (notification.pane_id, notification.surface_id);
1063                        if !cleared_surfaces.contains(&target) {
1064                            cleared_surfaces.push(target);
1065                        }
1066                        if notification.read_at.is_none() {
1067                            notification.read_at = Some(now);
1068                        }
1069                        notification.cleared_at = Some(now);
1070                    }
1071                }
1072            }
1073            AgentTarget::Pane {
1074                workspace_id,
1075                pane_id,
1076            } => {
1077                if workspace_id != self.id {
1078                    return Err(DomainError::MissingWorkspace(workspace_id));
1079                }
1080                if !self.panes.contains_key(&pane_id) {
1081                    return Err(DomainError::PaneNotInWorkspace {
1082                        workspace_id,
1083                        pane_id,
1084                    });
1085                }
1086                for notification in &mut self.notifications {
1087                    if notification.pane_id == pane_id && notification.cleared_at.is_none() {
1088                        let target = (notification.pane_id, notification.surface_id);
1089                        if !cleared_surfaces.contains(&target) {
1090                            cleared_surfaces.push(target);
1091                        }
1092                        if notification.read_at.is_none() {
1093                            notification.read_at = Some(now);
1094                        }
1095                        notification.cleared_at = Some(now);
1096                    }
1097                }
1098            }
1099            AgentTarget::Surface {
1100                workspace_id,
1101                pane_id,
1102                surface_id,
1103            } => {
1104                if workspace_id != self.id {
1105                    return Err(DomainError::MissingWorkspace(workspace_id));
1106                }
1107                if !self
1108                    .panes
1109                    .get(&pane_id)
1110                    .is_some_and(|pane| pane.surfaces.contains_key(&surface_id))
1111                {
1112                    return Err(DomainError::SurfaceNotInPane {
1113                        workspace_id,
1114                        pane_id,
1115                        surface_id,
1116                    });
1117                }
1118                for notification in &mut self.notifications {
1119                    if notification.pane_id == pane_id
1120                        && notification.surface_id == surface_id
1121                        && notification.cleared_at.is_none()
1122                    {
1123                        let target = (notification.pane_id, notification.surface_id);
1124                        if !cleared_surfaces.contains(&target) {
1125                            cleared_surfaces.push(target);
1126                        }
1127                        if notification.read_at.is_none() {
1128                            notification.read_at = Some(now);
1129                        }
1130                        notification.cleared_at = Some(now);
1131                    }
1132                }
1133            }
1134        }
1135        for (pane_id, surface_id) in cleared_surfaces {
1136            self.sync_surface_attention_with_active_notifications(pane_id, surface_id);
1137        }
1138        Ok(())
1139    }
1140
1141    fn clear_notification(&mut self, notification_id: NotificationId) -> bool {
1142        let now = OffsetDateTime::now_utc();
1143        if let Some(notification) = self.notifications.iter_mut().find(|notification| {
1144            notification.id == notification_id && notification.cleared_at.is_none()
1145        }) {
1146            let pane_id = notification.pane_id;
1147            let surface_id = notification.surface_id;
1148            if notification.read_at.is_none() {
1149                notification.read_at = Some(now);
1150            }
1151            notification.cleared_at = Some(now);
1152            self.sync_surface_attention_with_active_notifications(pane_id, surface_id);
1153            return true;
1154        }
1155        false
1156    }
1157
1158    fn sync_surface_attention_with_active_notifications(
1159        &mut self,
1160        pane_id: PaneId,
1161        surface_id: SurfaceId,
1162    ) {
1163        let next_attention = self
1164            .notifications
1165            .iter()
1166            .filter(|notification| {
1167                notification.pane_id == pane_id
1168                    && notification.surface_id == surface_id
1169                    && notification.active()
1170            })
1171            .map(|notification| notification.state)
1172            .max_by_key(|state| state.rank());
1173
1174        let Some(surface) = self
1175            .panes
1176            .get_mut(&pane_id)
1177            .and_then(|pane| pane.surfaces.get_mut(&surface_id))
1178        else {
1179            return;
1180        };
1181
1182        if let Some(attention) = next_attention {
1183            surface.attention = attention;
1184            return;
1185        }
1186
1187        if matches!(
1188            surface.attention,
1189            AttentionState::Completed | AttentionState::WaitingInput | AttentionState::Error
1190        ) {
1191            surface.attention = AttentionState::Normal;
1192        }
1193    }
1194
1195    fn notification_target(&self, notification_id: NotificationId) -> Option<(PaneId, SurfaceId)> {
1196        self.notifications
1197            .iter()
1198            .find(|notification| notification.id == notification_id)
1199            .map(|notification| (notification.pane_id, notification.surface_id))
1200    }
1201
1202    fn mark_notification_read(&mut self, notification_id: NotificationId) -> bool {
1203        let now = OffsetDateTime::now_utc();
1204        if let Some(notification) = self.notifications.iter_mut().find(|notification| {
1205            notification.id == notification_id && notification.cleared_at.is_none()
1206        }) {
1207            if notification.read_at.is_none() {
1208                notification.read_at = Some(now);
1209            }
1210            return true;
1211        }
1212        false
1213    }
1214
1215    fn set_notification_delivery(
1216        &mut self,
1217        notification_id: NotificationId,
1218        delivery: NotificationDeliveryState,
1219    ) -> bool {
1220        if let Some(notification) = self
1221            .notifications
1222            .iter_mut()
1223            .find(|notification| notification.id == notification_id)
1224        {
1225            notification.desktop_delivery = delivery;
1226            return true;
1227        }
1228        false
1229    }
1230
1231    fn append_log_entry(&mut self, entry: WorkspaceLogEntry) {
1232        self.log_entries.push(entry);
1233        let overflow = self
1234            .log_entries
1235            .len()
1236            .saturating_sub(WORKSPACE_LOG_RETENTION);
1237        if overflow > 0 {
1238            self.log_entries.drain(0..overflow);
1239        }
1240    }
1241
1242    fn trigger_surface_flash(&mut self, surface_id: SurfaceId) {
1243        self.next_flash_token = self.next_flash_token.saturating_add(1);
1244        self.surface_flash_tokens
1245            .insert(surface_id, self.next_flash_token);
1246    }
1247
1248    fn top_level_neighbor(
1249        &self,
1250        source_window_id: WorkspaceWindowId,
1251        direction: Direction,
1252    ) -> Option<WorkspaceWindowId> {
1253        let (_, column_index, window_index) = self.position_for_window(source_window_id)?;
1254        match direction {
1255            Direction::Left => column_index
1256                .checked_sub(1)
1257                .and_then(|index| self.columns.get_index(index))
1258                .map(|(_, column)| column.active_window),
1259            Direction::Right => self
1260                .columns
1261                .get_index(column_index + 1)
1262                .map(|(_, column)| column.active_window),
1263            Direction::Up => self
1264                .columns
1265                .get_index(column_index)
1266                .and_then(|(_, column)| {
1267                    window_index
1268                        .checked_sub(1)
1269                        .and_then(|index| column.window_order.get(index))
1270                })
1271                .copied(),
1272            Direction::Down => self
1273                .columns
1274                .get_index(column_index)
1275                .and_then(|(_, column)| column.window_order.get(window_index + 1))
1276                .copied(),
1277        }
1278    }
1279
1280    fn fallback_window_after_close(
1281        &self,
1282        source_column_index: usize,
1283        source_window_index: usize,
1284        same_column_survived: bool,
1285    ) -> Option<WorkspaceWindowId> {
1286        if same_column_survived
1287            && let Some((_, column)) = self.columns.get_index(source_column_index)
1288        {
1289            if let Some(window_id) = column.window_order.get(source_window_index) {
1290                return Some(*window_id);
1291            }
1292            if let Some(window_id) = source_window_index
1293                .checked_sub(1)
1294                .and_then(|index| column.window_order.get(index))
1295            {
1296                return Some(*window_id);
1297            }
1298        }
1299
1300        let right_column_index = if same_column_survived {
1301            source_column_index + 1
1302        } else {
1303            source_column_index
1304        };
1305        if let Some((_, column)) = self.columns.get_index(right_column_index)
1306            && let Some(window_id) = column.window_order.first()
1307        {
1308            return Some(*window_id);
1309        }
1310
1311        source_column_index
1312            .checked_sub(1)
1313            .and_then(|index| self.columns.get_index(index))
1314            .and_then(|(_, column)| column.window_order.first())
1315            .copied()
1316    }
1317
1318    fn insert_column_at(&mut self, index: usize, column: WorkspaceColumnRecord) {
1319        let insert_index = index.min(self.columns.len());
1320        let mut next = IndexMap::with_capacity(self.columns.len() + 1);
1321        let mut pending = Some(column);
1322        for (current_index, (column_id, current_column)) in
1323            std::mem::take(&mut self.columns).into_iter().enumerate()
1324        {
1325            if current_index == insert_index
1326                && let Some(column) = pending.take()
1327            {
1328                next.insert(column.id, column);
1329            }
1330            next.insert(column_id, current_column);
1331        }
1332        if let Some(column) = pending.take() {
1333            next.insert(column.id, column);
1334        }
1335        self.columns = next;
1336    }
1337
1338    fn append_missing_windows_to_columns(&mut self) {
1339        let assigned = self
1340            .columns
1341            .values()
1342            .flat_map(|column| column.window_order.iter().copied())
1343            .collect::<BTreeSet<_>>();
1344        for window_id in self.windows.keys().copied().collect::<Vec<_>>() {
1345            if assigned.contains(&window_id) {
1346                continue;
1347            }
1348            let column = WorkspaceColumnRecord::new(window_id);
1349            self.columns.insert(column.id, column);
1350        }
1351    }
1352
1353    fn normalize(&mut self) {
1354        if self.panes.is_empty() {
1355            let id = self.id;
1356            let label = self.label.clone();
1357            *self = Self::bootstrap(label);
1358            self.id = id;
1359            return;
1360        }
1361
1362        for pane in self.panes.values_mut() {
1363            pane.normalize();
1364        }
1365
1366        if self.windows.is_empty() {
1367            let fallback_pane = self
1368                .panes
1369                .first()
1370                .map(|(pane_id, _)| *pane_id)
1371                .expect("workspace has at least one pane");
1372            let fallback_window = WorkspaceWindowRecord::new(fallback_pane);
1373            self.active_window = fallback_window.id;
1374            self.active_pane = fallback_pane;
1375            self.windows.insert(fallback_window.id, fallback_window);
1376        }
1377
1378        for window in self.windows.values_mut() {
1379            window.height = window.height.max(MIN_WORKSPACE_WINDOW_HEIGHT);
1380            let fallback_pane = self
1381                .panes
1382                .first()
1383                .map(|(pane_id, _)| *pane_id)
1384                .expect("workspace has at least one pane");
1385            window.normalize(&self.panes, fallback_pane);
1386        }
1387
1388        for column in self.columns.values_mut() {
1389            column.normalize(&self.windows);
1390        }
1391
1392        let mut assigned = BTreeSet::new();
1393        for column in self.columns.values_mut() {
1394            column
1395                .window_order
1396                .retain(|window_id| assigned.insert(*window_id));
1397            if !column.window_order.contains(&column.active_window)
1398                && let Some(window_id) = column.window_order.first()
1399            {
1400                column.active_window = *window_id;
1401            }
1402        }
1403        self.columns
1404            .retain(|_, column| !column.window_order.is_empty());
1405        self.append_missing_windows_to_columns();
1406
1407        if self.columns.is_empty() {
1408            let fallback_window_id = self
1409                .windows
1410                .first()
1411                .map(|(window_id, _)| *window_id)
1412                .expect("workspace has at least one window");
1413            let column = WorkspaceColumnRecord::new(fallback_window_id);
1414            self.columns.insert(column.id, column);
1415        }
1416
1417        if !self.windows.contains_key(&self.active_window) {
1418            self.active_window = self
1419                .columns
1420                .first()
1421                .map(|(_, column)| column.active_window)
1422                .expect("workspace has at least one column");
1423        }
1424        if !self
1425            .windows
1426            .get(&self.active_window)
1427            .is_some_and(|window| window.contains_pane(self.active_pane))
1428        {
1429            self.active_pane = self
1430                .windows
1431                .get(&self.active_window)
1432                .and_then(WorkspaceWindowRecord::active_pane)
1433                .expect("active window exists");
1434        }
1435        self.sync_active_from_window(self.active_window);
1436    }
1437
1438    pub fn repo_hint(&self) -> Option<&str> {
1439        self.panes.values().find_map(|pane| {
1440            pane.active_metadata()
1441                .and_then(|metadata| metadata.repo_name.as_deref())
1442        })
1443    }
1444
1445    pub fn attention_counts(&self) -> BTreeMap<AttentionState, usize> {
1446        let mut counts = BTreeMap::new();
1447        for pane in self.panes.values() {
1448            for surface in pane.surfaces.values() {
1449                *counts.entry(surface.attention).or_insert(0) += 1;
1450            }
1451        }
1452        counts
1453    }
1454
1455    pub fn active_surface_id(&self) -> Option<SurfaceId> {
1456        self.panes
1457            .get(&self.active_pane)
1458            .map(|pane| pane.active_surface)
1459    }
1460
1461    pub fn agent_summaries(&self, _now: OffsetDateTime) -> Vec<WorkspaceAgentSummary> {
1462        let mut summaries = self
1463            .panes
1464            .iter()
1465            .flat_map(|(pane_id, pane)| {
1466                let workspace_window_id = self.window_for_pane(*pane_id);
1467                pane.surfaces.values().filter_map(move |surface| {
1468                    let workspace_window_id = workspace_window_id?;
1469                    let session = surface.agent_session.as_ref()?;
1470                    Some(WorkspaceAgentSummary {
1471                        workspace_window_id,
1472                        pane_id: *pane_id,
1473                        surface_id: surface.id,
1474                        agent_kind: session.kind.clone(),
1475                        title: Some(session.title.clone()),
1476                        state: session.state,
1477                        last_signal_at: Some(session.updated_at),
1478                    })
1479                })
1480            })
1481            .collect::<Vec<_>>();
1482
1483        summaries.sort_by(|left, right| {
1484            left.state
1485                .sort_rank()
1486                .cmp(&right.state.sort_rank())
1487                .then_with(|| right.last_signal_at.cmp(&left.last_signal_at))
1488                .then_with(|| left.agent_kind.cmp(&right.agent_kind))
1489                .then_with(|| left.pane_id.cmp(&right.pane_id))
1490                .then_with(|| left.surface_id.cmp(&right.surface_id))
1491        });
1492
1493        summaries
1494    }
1495}
1496
1497#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1498pub struct WorkspaceSummary {
1499    pub workspace_id: WorkspaceId,
1500    pub label: String,
1501    pub active_pane: PaneId,
1502    pub repo_hint: Option<String>,
1503    pub agent_summaries: Vec<WorkspaceAgentSummary>,
1504    pub counts_by_attention: BTreeMap<AttentionState, usize>,
1505    pub highest_attention: AttentionState,
1506    pub display_attention: AttentionState,
1507    pub unread_count: usize,
1508    pub latest_notification: Option<String>,
1509    pub status_text: Option<String>,
1510}
1511
1512#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1513pub struct WindowRecord {
1514    pub id: WindowId,
1515    pub workspace_order: Vec<WorkspaceId>,
1516    pub active_workspace: WorkspaceId,
1517}
1518
1519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1520pub struct AppModel {
1521    pub active_window: WindowId,
1522    pub windows: IndexMap<WindowId, WindowRecord>,
1523    pub workspaces: IndexMap<WorkspaceId, Workspace>,
1524}
1525
1526#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1527pub struct PersistedSession {
1528    pub schema_version: u32,
1529    pub captured_at: OffsetDateTime,
1530    pub model: AppModel,
1531}
1532
1533impl AppModel {
1534    pub fn new(label: impl Into<String>) -> Self {
1535        let window_id = WindowId::new();
1536        let workspace = Workspace::bootstrap(label);
1537        let workspace_id = workspace.id;
1538
1539        let mut windows = IndexMap::new();
1540        windows.insert(
1541            window_id,
1542            WindowRecord {
1543                id: window_id,
1544                workspace_order: vec![workspace_id],
1545                active_workspace: workspace_id,
1546            },
1547        );
1548
1549        let mut workspaces = IndexMap::new();
1550        workspaces.insert(workspace_id, workspace);
1551
1552        Self {
1553            active_window: window_id,
1554            windows,
1555            workspaces,
1556        }
1557    }
1558
1559    pub fn demo() -> Self {
1560        let mut model = Self::new("Repo A");
1561        let primary_workspace = model.active_workspace_id().unwrap_or_else(WorkspaceId::new);
1562        let first_pane = model
1563            .active_workspace()
1564            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1565            .unwrap_or_else(PaneId::new);
1566
1567        let _ = model.update_pane_metadata(
1568            first_pane,
1569            PaneMetadataPatch {
1570                title: Some("Codex".into()),
1571                cwd: Some("/home/notes/Projects/taskers".into()),
1572                url: None,
1573                repo_name: Some("taskers".into()),
1574                git_branch: Some("main".into()),
1575                ports: Some(vec![3000]),
1576                agent_kind: Some("codex".into()),
1577            },
1578        );
1579        let _ = model.apply_signal(
1580            primary_workspace,
1581            first_pane,
1582            SignalEvent::new(
1583                "demo",
1584                SignalKind::WaitingInput,
1585                Some("Waiting for review on workspace bootstrap".into()),
1586            ),
1587        );
1588
1589        let second_window_pane = model
1590            .create_workspace_window(primary_workspace, Direction::Right)
1591            .unwrap_or(first_pane);
1592        let _ = model.update_pane_metadata(
1593            second_window_pane,
1594            PaneMetadataPatch {
1595                title: Some("Claude".into()),
1596                cwd: Some("/home/notes/Projects/taskers".into()),
1597                url: None,
1598                repo_name: Some("taskers".into()),
1599                git_branch: Some("feature/bootstrap".into()),
1600                ports: Some(vec![]),
1601                agent_kind: Some("claude".into()),
1602            },
1603        );
1604        let split_pane = model
1605            .split_pane(
1606                primary_workspace,
1607                Some(second_window_pane),
1608                SplitAxis::Vertical,
1609            )
1610            .unwrap_or(second_window_pane);
1611        let _ = model.apply_signal(
1612            primary_workspace,
1613            split_pane,
1614            SignalEvent::new(
1615                "demo",
1616                SignalKind::Progress,
1617                Some("Running long task".into()),
1618            ),
1619        );
1620
1621        let second_workspace = model.create_workspace("Docs");
1622        let second_pane = model
1623            .workspaces
1624            .get(&second_workspace)
1625            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
1626            .unwrap_or_else(PaneId::new);
1627        let _ = model.update_pane_metadata(
1628            second_pane,
1629            PaneMetadataPatch {
1630                title: Some("OpenCode".into()),
1631                cwd: Some("/home/notes/Documents".into()),
1632                url: None,
1633                repo_name: Some("notes".into()),
1634                git_branch: Some("docs".into()),
1635                ports: Some(vec![8080, 8081]),
1636                agent_kind: Some("opencode".into()),
1637            },
1638        );
1639        let _ = model.apply_signal(
1640            second_workspace,
1641            second_pane,
1642            SignalEvent::new(
1643                "demo",
1644                SignalKind::Completed,
1645                Some("Draft completed, ready for merge".into()),
1646            ),
1647        );
1648        let _ = model.switch_workspace(model.active_window, second_workspace);
1649
1650        model
1651    }
1652
1653    pub fn active_window(&self) -> Option<&WindowRecord> {
1654        self.windows.get(&self.active_window)
1655    }
1656
1657    pub fn active_workspace_id(&self) -> Option<WorkspaceId> {
1658        self.active_window().map(|window| window.active_workspace)
1659    }
1660
1661    pub fn active_workspace(&self) -> Option<&Workspace> {
1662        self.active_workspace_id()
1663            .and_then(|workspace_id| self.workspaces.get(&workspace_id))
1664    }
1665
1666    pub fn create_workspace(&mut self, label: impl Into<String>) -> WorkspaceId {
1667        let workspace = Workspace::bootstrap(label);
1668        let workspace_id = workspace.id;
1669        self.workspaces.insert(workspace_id, workspace);
1670        if let Some(window) = self.windows.get_mut(&self.active_window) {
1671            window.workspace_order.push(workspace_id);
1672            window.active_workspace = workspace_id;
1673        }
1674        workspace_id
1675    }
1676
1677    pub fn rename_workspace(
1678        &mut self,
1679        workspace_id: WorkspaceId,
1680        label: impl Into<String>,
1681    ) -> Result<(), DomainError> {
1682        let workspace = self
1683            .workspaces
1684            .get_mut(&workspace_id)
1685            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1686        workspace.label = label.into();
1687        Ok(())
1688    }
1689
1690    pub fn reorder_workspaces(
1691        &mut self,
1692        window_id: WindowId,
1693        new_order: Vec<WorkspaceId>,
1694    ) -> Result<(), DomainError> {
1695        let window = self
1696            .windows
1697            .get_mut(&window_id)
1698            .ok_or(DomainError::MissingWindow(window_id))?;
1699        let existing: std::collections::HashSet<_> =
1700            window.workspace_order.iter().copied().collect();
1701        let proposed: std::collections::HashSet<_> = new_order.iter().copied().collect();
1702        if existing != proposed {
1703            return Ok(());
1704        }
1705        window.workspace_order = new_order;
1706        Ok(())
1707    }
1708
1709    pub fn switch_workspace(
1710        &mut self,
1711        window_id: WindowId,
1712        workspace_id: WorkspaceId,
1713    ) -> Result<(), DomainError> {
1714        let window = self
1715            .windows
1716            .get_mut(&window_id)
1717            .ok_or(DomainError::MissingWindow(window_id))?;
1718        if !window.workspace_order.contains(&workspace_id) {
1719            return Err(DomainError::MissingWorkspace(workspace_id));
1720        }
1721        window.active_workspace = workspace_id;
1722        Ok(())
1723    }
1724
1725    pub fn create_workspace_window(
1726        &mut self,
1727        workspace_id: WorkspaceId,
1728        direction: Direction,
1729    ) -> Result<PaneId, DomainError> {
1730        let workspace = self
1731            .workspaces
1732            .get_mut(&workspace_id)
1733            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1734        let new_pane = PaneRecord::new(PaneKind::Terminal);
1735        let new_pane_id = new_pane.id;
1736        workspace.panes.insert(new_pane_id, new_pane);
1737
1738        let new_window = WorkspaceWindowRecord::new(new_pane_id);
1739        let new_window_id = new_window.id;
1740        workspace.windows.insert(new_window_id, new_window);
1741        insert_window_relative_to_active(workspace, new_window_id, direction)?;
1742
1743        workspace.sync_active_from_window(new_window_id);
1744
1745        Ok(new_pane_id)
1746    }
1747
1748    pub fn create_workspace_window_tab(
1749        &mut self,
1750        workspace_id: WorkspaceId,
1751        workspace_window_id: WorkspaceWindowId,
1752    ) -> Result<(WorkspaceWindowTabId, PaneId), DomainError> {
1753        let workspace = self
1754            .workspaces
1755            .get_mut(&workspace_id)
1756            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1757        if !workspace.windows.contains_key(&workspace_window_id) {
1758            return Err(DomainError::MissingWorkspaceWindow(workspace_window_id));
1759        }
1760
1761        let new_pane = PaneRecord::new(PaneKind::Terminal);
1762        let new_pane_id = new_pane.id;
1763        workspace.panes.insert(new_pane_id, new_pane);
1764
1765        let window = workspace
1766            .windows
1767            .get_mut(&workspace_window_id)
1768            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
1769        let insert_index = window
1770            .tabs
1771            .get_index_of(&window.active_tab)
1772            .map(|index| index + 1)
1773            .unwrap_or(window.tabs.len());
1774        let new_tab = WorkspaceWindowTabRecord::new(new_pane_id);
1775        let new_tab_id = new_tab.id;
1776        window.insert_tab(new_tab, insert_index);
1777        workspace.sync_active_from_window(workspace_window_id);
1778
1779        Ok((new_tab_id, new_pane_id))
1780    }
1781
1782    pub fn focus_workspace_window_tab(
1783        &mut self,
1784        workspace_id: WorkspaceId,
1785        workspace_window_id: WorkspaceWindowId,
1786        workspace_window_tab_id: WorkspaceWindowTabId,
1787    ) -> Result<(), DomainError> {
1788        let workspace = self
1789            .workspaces
1790            .get_mut(&workspace_id)
1791            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1792        let window = workspace
1793            .windows
1794            .get_mut(&workspace_window_id)
1795            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
1796        if !window.focus_tab(workspace_window_tab_id) {
1797            return Err(DomainError::MissingWorkspaceWindowTab(
1798                workspace_window_tab_id,
1799            ));
1800        }
1801        workspace.sync_active_from_window(workspace_window_id);
1802        Ok(())
1803    }
1804
1805    pub fn move_workspace_window_tab(
1806        &mut self,
1807        workspace_id: WorkspaceId,
1808        workspace_window_id: WorkspaceWindowId,
1809        workspace_window_tab_id: WorkspaceWindowTabId,
1810        to_index: usize,
1811    ) -> Result<(), DomainError> {
1812        let workspace = self
1813            .workspaces
1814            .get_mut(&workspace_id)
1815            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1816        let window = workspace
1817            .windows
1818            .get_mut(&workspace_window_id)
1819            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
1820        if !window.move_tab(workspace_window_tab_id, to_index) {
1821            return Err(DomainError::MissingWorkspaceWindowTab(
1822                workspace_window_tab_id,
1823            ));
1824        }
1825        workspace.sync_active_from_window(workspace_window_id);
1826        Ok(())
1827    }
1828
1829    pub fn transfer_workspace_window_tab(
1830        &mut self,
1831        workspace_id: WorkspaceId,
1832        source_workspace_window_id: WorkspaceWindowId,
1833        workspace_window_tab_id: WorkspaceWindowTabId,
1834        target_workspace_window_id: WorkspaceWindowId,
1835        to_index: usize,
1836    ) -> Result<(), DomainError> {
1837        if source_workspace_window_id == target_workspace_window_id {
1838            return self.move_workspace_window_tab(
1839                workspace_id,
1840                source_workspace_window_id,
1841                workspace_window_tab_id,
1842                to_index,
1843            );
1844        }
1845
1846        let (source_column_id, _source_column_index, source_window_index, remove_source_window) = {
1847            let workspace = self
1848                .workspaces
1849                .get(&workspace_id)
1850                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1851            let source_window = workspace.windows.get(&source_workspace_window_id).ok_or(
1852                DomainError::MissingWorkspaceWindow(source_workspace_window_id),
1853            )?;
1854            if !workspace.windows.contains_key(&target_workspace_window_id) {
1855                return Err(DomainError::MissingWorkspaceWindow(
1856                    target_workspace_window_id,
1857                ));
1858            }
1859            if !source_window.tabs.contains_key(&workspace_window_tab_id) {
1860                return Err(DomainError::MissingWorkspaceWindowTab(
1861                    workspace_window_tab_id,
1862                ));
1863            }
1864            let (source_column_id, source_column_index, source_window_index) = workspace
1865                .position_for_window(source_workspace_window_id)
1866                .ok_or(DomainError::MissingWorkspaceWindow(
1867                    source_workspace_window_id,
1868                ))?;
1869            (
1870                source_column_id,
1871                source_column_index,
1872                source_window_index,
1873                source_window.tabs.len() == 1,
1874            )
1875        };
1876
1877        let moved_tab = {
1878            let workspace = self
1879                .workspaces
1880                .get_mut(&workspace_id)
1881                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1882            let source_window = workspace
1883                .windows
1884                .get_mut(&source_workspace_window_id)
1885                .ok_or(DomainError::MissingWorkspaceWindow(
1886                    source_workspace_window_id,
1887                ))?;
1888            source_window.remove_tab(workspace_window_tab_id).ok_or(
1889                DomainError::MissingWorkspaceWindowTab(workspace_window_tab_id),
1890            )?
1891        };
1892
1893        if remove_source_window {
1894            let workspace = self
1895                .workspaces
1896                .get_mut(&workspace_id)
1897                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1898            let same_column_survived = {
1899                let column = workspace
1900                    .columns
1901                    .get_mut(&source_column_id)
1902                    .ok_or(DomainError::MissingWorkspaceColumn(source_column_id))?;
1903                column.window_order.remove(source_window_index);
1904                if column.window_order.is_empty() {
1905                    false
1906                } else {
1907                    if !column.window_order.contains(&column.active_window) {
1908                        let replacement_index =
1909                            source_window_index.min(column.window_order.len() - 1);
1910                        column.active_window = column.window_order[replacement_index];
1911                    }
1912                    true
1913                }
1914            };
1915            if !same_column_survived {
1916                workspace.columns.shift_remove(&source_column_id);
1917            }
1918            workspace.windows.shift_remove(&source_workspace_window_id);
1919        }
1920
1921        {
1922            let workspace = self
1923                .workspaces
1924                .get_mut(&workspace_id)
1925                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1926            let target_window = workspace
1927                .windows
1928                .get_mut(&target_workspace_window_id)
1929                .ok_or(DomainError::MissingWorkspaceWindow(
1930                    target_workspace_window_id,
1931                ))?;
1932            target_window.insert_tab(moved_tab, to_index);
1933            workspace.sync_active_from_window(target_workspace_window_id);
1934        }
1935
1936        Ok(())
1937    }
1938
1939    pub fn extract_workspace_window_tab(
1940        &mut self,
1941        workspace_id: WorkspaceId,
1942        source_workspace_window_id: WorkspaceWindowId,
1943        workspace_window_tab_id: WorkspaceWindowTabId,
1944        target: WorkspaceWindowMoveTarget,
1945    ) -> Result<WorkspaceWindowId, DomainError> {
1946        let source_tab_count = self
1947            .workspaces
1948            .get(&workspace_id)
1949            .ok_or(DomainError::MissingWorkspace(workspace_id))?
1950            .windows
1951            .get(&source_workspace_window_id)
1952            .ok_or(DomainError::MissingWorkspaceWindow(
1953                source_workspace_window_id,
1954            ))?
1955            .tabs
1956            .len();
1957
1958        if source_tab_count <= 1 {
1959            self.move_workspace_window(workspace_id, source_workspace_window_id, target)?;
1960            return Ok(source_workspace_window_id);
1961        }
1962
1963        let moved_tab = {
1964            let workspace = self
1965                .workspaces
1966                .get_mut(&workspace_id)
1967                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1968            let source_window = workspace
1969                .windows
1970                .get_mut(&source_workspace_window_id)
1971                .ok_or(DomainError::MissingWorkspaceWindow(
1972                    source_workspace_window_id,
1973                ))?;
1974            source_window.remove_tab(workspace_window_tab_id).ok_or(
1975                DomainError::MissingWorkspaceWindowTab(workspace_window_tab_id),
1976            )?
1977        };
1978
1979        let new_window_id = {
1980            let workspace = self
1981                .workspaces
1982                .get_mut(&workspace_id)
1983                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
1984            let mut new_window = WorkspaceWindowRecord::new(moved_tab.active_pane);
1985            new_window.tabs.clear();
1986            new_window.active_tab = moved_tab.id;
1987            new_window.tabs.insert(moved_tab.id, moved_tab);
1988            let new_window_id = new_window.id;
1989            workspace.windows.insert(new_window_id, new_window);
1990            insert_window_relative_to_active(workspace, new_window_id, Direction::Right)?;
1991            workspace.sync_active_from_window(new_window_id);
1992            new_window_id
1993        };
1994
1995        self.move_workspace_window(workspace_id, new_window_id, target)?;
1996        let workspace = self
1997            .workspaces
1998            .get_mut(&workspace_id)
1999            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2000        workspace.sync_active_from_window(new_window_id);
2001        Ok(new_window_id)
2002    }
2003
2004    pub fn close_workspace_window_tab(
2005        &mut self,
2006        workspace_id: WorkspaceId,
2007        workspace_window_id: WorkspaceWindowId,
2008        workspace_window_tab_id: WorkspaceWindowTabId,
2009    ) -> Result<(), DomainError> {
2010        let (tab_panes, close_entire_window) = {
2011            let workspace = self
2012                .workspaces
2013                .get(&workspace_id)
2014                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2015            let window = workspace
2016                .windows
2017                .get(&workspace_window_id)
2018                .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
2019            let tab = window.tabs.get(&workspace_window_tab_id).ok_or(
2020                DomainError::MissingWorkspaceWindowTab(workspace_window_tab_id),
2021            )?;
2022            (tab.layout.leaves(), window.tabs.len() == 1)
2023        };
2024
2025        if close_entire_window {
2026            if self
2027                .workspaces
2028                .get(&workspace_id)
2029                .is_some_and(|workspace| workspace.windows.len() <= 1)
2030            {
2031                return self.close_workspace(workspace_id);
2032            }
2033
2034            let workspace = self
2035                .workspaces
2036                .get_mut(&workspace_id)
2037                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2038            let (column_id, column_index, window_index) = workspace
2039                .position_for_window(workspace_window_id)
2040                .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
2041            let column = workspace
2042                .columns
2043                .get_mut(&column_id)
2044                .expect("window column should exist");
2045            column.window_order.remove(window_index);
2046            let same_column_survived = !column.window_order.is_empty();
2047            if same_column_survived {
2048                if !column.window_order.contains(&column.active_window) {
2049                    let replacement_index = window_index.min(column.window_order.len() - 1);
2050                    column.active_window = column.window_order[replacement_index];
2051                }
2052            } else {
2053                workspace.columns.shift_remove(&column_id);
2054            }
2055            workspace.windows.shift_remove(&workspace_window_id);
2056            remove_panes_from_workspace(workspace, &tab_panes);
2057            if let Some(next_window_id) = workspace.fallback_window_after_close(
2058                column_index,
2059                window_index,
2060                same_column_survived,
2061            ) {
2062                workspace.sync_active_from_window(next_window_id);
2063            }
2064            return Ok(());
2065        }
2066
2067        let workspace = self
2068            .workspaces
2069            .get_mut(&workspace_id)
2070            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2071        let window = workspace
2072            .windows
2073            .get_mut(&workspace_window_id)
2074            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
2075        let _ = window.remove_tab(workspace_window_tab_id).ok_or(
2076            DomainError::MissingWorkspaceWindowTab(workspace_window_tab_id),
2077        )?;
2078        remove_panes_from_workspace(workspace, &tab_panes);
2079        if workspace.active_window == workspace_window_id {
2080            workspace.sync_active_from_window(workspace_window_id);
2081        } else if tab_panes.contains(&workspace.active_pane) {
2082            workspace.sync_active_from_window(workspace.active_window);
2083        }
2084        Ok(())
2085    }
2086
2087    pub fn split_pane(
2088        &mut self,
2089        workspace_id: WorkspaceId,
2090        target_pane: Option<PaneId>,
2091        axis: SplitAxis,
2092    ) -> Result<PaneId, DomainError> {
2093        let direction = match axis {
2094            SplitAxis::Horizontal => Direction::Right,
2095            SplitAxis::Vertical => Direction::Down,
2096        };
2097        self.split_pane_direction(workspace_id, target_pane, direction)
2098    }
2099
2100    pub fn split_pane_direction(
2101        &mut self,
2102        workspace_id: WorkspaceId,
2103        target_pane: Option<PaneId>,
2104        direction: Direction,
2105    ) -> Result<PaneId, DomainError> {
2106        let workspace = self
2107            .workspaces
2108            .get_mut(&workspace_id)
2109            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2110
2111        let target = target_pane.unwrap_or(workspace.active_pane);
2112        if !workspace.panes.contains_key(&target) {
2113            return Err(DomainError::PaneNotInWorkspace {
2114                workspace_id,
2115                pane_id: target,
2116            });
2117        }
2118
2119        let window_id = workspace
2120            .window_for_pane(target)
2121            .ok_or(DomainError::MissingPane(target))?;
2122        let new_pane = PaneRecord::new(PaneKind::Terminal);
2123        let new_pane_id = new_pane.id;
2124        workspace.panes.insert(new_pane_id, new_pane);
2125
2126        if let Some(window) = workspace.windows.get_mut(&window_id) {
2127            let Some(layout) = window.active_layout_mut() else {
2128                return Err(DomainError::MissingWorkspaceWindow(window_id));
2129            };
2130            layout.split_leaf_with_direction(target, direction, new_pane_id, 500);
2131            let _ = window.focus_pane(new_pane_id);
2132        }
2133        workspace.sync_active_from_window(window_id);
2134
2135        Ok(new_pane_id)
2136    }
2137
2138    pub fn focus_workspace_window(
2139        &mut self,
2140        workspace_id: WorkspaceId,
2141        workspace_window_id: WorkspaceWindowId,
2142    ) -> Result<(), DomainError> {
2143        let workspace = self
2144            .workspaces
2145            .get_mut(&workspace_id)
2146            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2147        if !workspace.windows.contains_key(&workspace_window_id) {
2148            return Err(DomainError::MissingWorkspaceWindow(workspace_window_id));
2149        }
2150        workspace.focus_window(workspace_window_id);
2151        Ok(())
2152    }
2153
2154    pub fn focus_pane(
2155        &mut self,
2156        workspace_id: WorkspaceId,
2157        pane_id: PaneId,
2158    ) -> Result<(), DomainError> {
2159        let workspace = self
2160            .workspaces
2161            .get_mut(&workspace_id)
2162            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2163
2164        if !workspace.panes.contains_key(&pane_id) {
2165            return Err(DomainError::PaneNotInWorkspace {
2166                workspace_id,
2167                pane_id,
2168            });
2169        }
2170
2171        workspace.focus_pane(pane_id);
2172        workspace.acknowledge_pane_notifications(pane_id);
2173        Ok(())
2174    }
2175
2176    pub fn acknowledge_pane_notifications(
2177        &mut self,
2178        workspace_id: WorkspaceId,
2179        pane_id: PaneId,
2180    ) -> Result<(), DomainError> {
2181        let workspace = self
2182            .workspaces
2183            .get_mut(&workspace_id)
2184            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2185        if !workspace.panes.contains_key(&pane_id) {
2186            return Err(DomainError::PaneNotInWorkspace {
2187                workspace_id,
2188                pane_id,
2189            });
2190        }
2191        workspace.acknowledge_pane_notifications(pane_id);
2192        Ok(())
2193    }
2194
2195    pub fn mark_surface_completed(
2196        &mut self,
2197        workspace_id: WorkspaceId,
2198        pane_id: PaneId,
2199        surface_id: SurfaceId,
2200    ) -> Result<(), DomainError> {
2201        let workspace = self
2202            .workspaces
2203            .get_mut(&workspace_id)
2204            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2205        let pane = workspace
2206            .panes
2207            .get_mut(&pane_id)
2208            .ok_or(DomainError::PaneNotInWorkspace {
2209                workspace_id,
2210                pane_id,
2211            })?;
2212        let surface = pane
2213            .surfaces
2214            .get_mut(&surface_id)
2215            .ok_or(DomainError::SurfaceNotInPane {
2216                workspace_id,
2217                pane_id,
2218                surface_id,
2219            })?;
2220
2221        surface.agent_process = None;
2222        surface.agent_session = None;
2223        surface.attention = AttentionState::Normal;
2224        surface.metadata.agent_active = false;
2225        surface.metadata.agent_state = None;
2226        surface.metadata.last_signal_at = None;
2227        surface.metadata.agent_title = None;
2228        surface.metadata.agent_kind = None;
2229        surface.metadata.latest_agent_message = None;
2230        workspace.complete_surface_notifications(pane_id, surface_id);
2231        Ok(())
2232    }
2233
2234    pub fn focus_pane_direction(
2235        &mut self,
2236        workspace_id: WorkspaceId,
2237        direction: Direction,
2238    ) -> Result<(), DomainError> {
2239        let workspace = self
2240            .workspaces
2241            .get_mut(&workspace_id)
2242            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2243        let active_window_id = workspace.active_window;
2244
2245        let next_pane = workspace.windows.get(&active_window_id).and_then(|window| {
2246            let active_pane = window.active_pane()?;
2247            let layout = window.active_layout()?;
2248            layout.focus_neighbor(active_pane, direction)
2249        });
2250        if let Some(next_pane) = next_pane {
2251            if let Some(window) = workspace.windows.get_mut(&active_window_id) {
2252                let _ = window.focus_pane(next_pane);
2253            }
2254            workspace.sync_active_from_window(active_window_id);
2255            return Ok(());
2256        }
2257
2258        if let Some(next_window_id) = workspace.top_level_neighbor(active_window_id, direction) {
2259            workspace.focus_window(next_window_id);
2260        }
2261
2262        Ok(())
2263    }
2264
2265    pub fn move_workspace_window(
2266        &mut self,
2267        workspace_id: WorkspaceId,
2268        workspace_window_id: WorkspaceWindowId,
2269        target: WorkspaceWindowMoveTarget,
2270    ) -> Result<(), DomainError> {
2271        let workspace = self
2272            .workspaces
2273            .get_mut(&workspace_id)
2274            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2275        if !workspace.windows.contains_key(&workspace_window_id) {
2276            return Err(DomainError::MissingWorkspaceWindow(workspace_window_id));
2277        }
2278
2279        let (source_column_id, _source_column_index, source_window_index) = workspace
2280            .position_for_window(workspace_window_id)
2281            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
2282        let source_window_count = workspace
2283            .columns
2284            .get(&source_column_id)
2285            .map(|column| column.window_order.len())
2286            .unwrap_or_default();
2287
2288        match target {
2289            WorkspaceWindowMoveTarget::ColumnBefore { column_id }
2290            | WorkspaceWindowMoveTarget::ColumnAfter { column_id } => {
2291                let place_after = matches!(target, WorkspaceWindowMoveTarget::ColumnAfter { .. });
2292                if !workspace.columns.contains_key(&column_id) {
2293                    return Err(DomainError::MissingWorkspaceColumn(column_id));
2294                }
2295                if source_window_count <= 1 {
2296                    if source_column_id == column_id {
2297                        workspace.sync_active_from_window(workspace_window_id);
2298                        return Ok(());
2299                    }
2300                    let source_column = workspace
2301                        .columns
2302                        .shift_remove(&source_column_id)
2303                        .ok_or(DomainError::MissingWorkspaceColumn(source_column_id))?;
2304                    let mut insert_index = workspace
2305                        .columns
2306                        .get_index_of(&column_id)
2307                        .ok_or(DomainError::MissingWorkspaceColumn(column_id))?;
2308                    if place_after {
2309                        insert_index += 1;
2310                    }
2311                    workspace.insert_column_at(insert_index, source_column);
2312                } else {
2313                    remove_window_from_column(workspace, source_column_id, source_window_index)?;
2314                    let target_width = workspace
2315                        .columns
2316                        .get(&column_id)
2317                        .map(|column| column.width)
2318                        .ok_or(DomainError::MissingWorkspaceColumn(column_id))?;
2319                    let (retained_width, new_width) =
2320                        split_top_level_extent(target_width, MIN_WORKSPACE_WINDOW_WIDTH);
2321                    let target_column = workspace
2322                        .columns
2323                        .get_mut(&column_id)
2324                        .ok_or(DomainError::MissingWorkspaceColumn(column_id))?;
2325                    target_column.width = retained_width;
2326
2327                    let mut new_column = WorkspaceColumnRecord::new(workspace_window_id);
2328                    new_column.width = new_width;
2329                    let insert_index = workspace
2330                        .columns
2331                        .get_index_of(&column_id)
2332                        .ok_or(DomainError::MissingWorkspaceColumn(column_id))?;
2333                    workspace.insert_column_at(
2334                        if place_after {
2335                            insert_index + 1
2336                        } else {
2337                            insert_index
2338                        },
2339                        new_column,
2340                    );
2341                }
2342            }
2343            WorkspaceWindowMoveTarget::StackAbove { window_id }
2344            | WorkspaceWindowMoveTarget::StackBelow { window_id } => {
2345                let place_below = matches!(target, WorkspaceWindowMoveTarget::StackBelow { .. });
2346                if workspace_window_id == window_id {
2347                    workspace.sync_active_from_window(workspace_window_id);
2348                    return Ok(());
2349                }
2350                let (target_column_id, _, _) = workspace
2351                    .position_for_window(window_id)
2352                    .ok_or(DomainError::MissingWorkspaceWindow(window_id))?;
2353
2354                remove_window_from_column(workspace, source_column_id, source_window_index)?;
2355                let (_, _, target_window_index) = workspace
2356                    .position_for_window(window_id)
2357                    .ok_or(DomainError::MissingWorkspaceWindow(window_id))?;
2358                let insert_index = if place_below {
2359                    target_window_index + 1
2360                } else {
2361                    target_window_index
2362                };
2363                let target_column = workspace
2364                    .columns
2365                    .get_mut(&target_column_id)
2366                    .ok_or(DomainError::MissingWorkspaceColumn(target_column_id))?;
2367                target_column
2368                    .window_order
2369                    .insert(insert_index, workspace_window_id);
2370                target_column.active_window = workspace_window_id;
2371            }
2372        }
2373
2374        workspace.normalize();
2375        workspace.sync_active_from_window(workspace_window_id);
2376        Ok(())
2377    }
2378
2379    pub fn resize_active_window(
2380        &mut self,
2381        workspace_id: WorkspaceId,
2382        direction: Direction,
2383        amount: i32,
2384    ) -> Result<(), DomainError> {
2385        let workspace = self
2386            .workspaces
2387            .get_mut(&workspace_id)
2388            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2389        let active_window = workspace.active_window;
2390        let (column_id, _, _) = workspace
2391            .position_for_window(active_window)
2392            .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
2393        match direction {
2394            Direction::Left => {
2395                let column = workspace
2396                    .columns
2397                    .get_mut(&column_id)
2398                    .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
2399                column.width = (column.width - amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
2400            }
2401            Direction::Right => {
2402                let column = workspace
2403                    .columns
2404                    .get_mut(&column_id)
2405                    .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
2406                column.width = (column.width + amount).max(MIN_WORKSPACE_WINDOW_WIDTH);
2407            }
2408            Direction::Up => {
2409                let window = workspace
2410                    .active_window_record_mut()
2411                    .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
2412                window.height = (window.height - amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
2413            }
2414            Direction::Down => {
2415                let window = workspace
2416                    .active_window_record_mut()
2417                    .ok_or(DomainError::MissingWorkspaceWindow(active_window))?;
2418                window.height = (window.height + amount).max(MIN_WORKSPACE_WINDOW_HEIGHT);
2419            }
2420        }
2421        Ok(())
2422    }
2423
2424    pub fn resize_active_pane_split(
2425        &mut self,
2426        workspace_id: WorkspaceId,
2427        direction: Direction,
2428        amount: i32,
2429    ) -> Result<(), DomainError> {
2430        let workspace = self
2431            .workspaces
2432            .get_mut(&workspace_id)
2433            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2434        let active_window_id = workspace.active_window;
2435        let active_pane = workspace.active_pane;
2436        let window = workspace
2437            .windows
2438            .get_mut(&active_window_id)
2439            .ok_or(DomainError::MissingWorkspaceWindow(active_window_id))?;
2440        let layout = window
2441            .active_layout_mut()
2442            .ok_or(DomainError::MissingWorkspaceWindow(active_window_id))?;
2443        layout.resize_leaf(active_pane, direction, amount);
2444        Ok(())
2445    }
2446
2447    pub fn set_workspace_column_width(
2448        &mut self,
2449        workspace_id: WorkspaceId,
2450        workspace_column_id: WorkspaceColumnId,
2451        width: i32,
2452    ) -> Result<(), DomainError> {
2453        let workspace = self
2454            .workspaces
2455            .get_mut(&workspace_id)
2456            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2457        let column = workspace
2458            .columns
2459            .get_mut(&workspace_column_id)
2460            .ok_or(DomainError::MissingWorkspaceColumn(workspace_column_id))?;
2461        column.width = width.max(MIN_WORKSPACE_WINDOW_WIDTH);
2462        Ok(())
2463    }
2464
2465    pub fn set_workspace_window_height(
2466        &mut self,
2467        workspace_id: WorkspaceId,
2468        workspace_window_id: WorkspaceWindowId,
2469        height: i32,
2470    ) -> Result<(), DomainError> {
2471        let workspace = self
2472            .workspaces
2473            .get_mut(&workspace_id)
2474            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2475        let window = workspace
2476            .windows
2477            .get_mut(&workspace_window_id)
2478            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
2479        window.height = height.max(MIN_WORKSPACE_WINDOW_HEIGHT);
2480        Ok(())
2481    }
2482
2483    pub fn set_window_split_ratio(
2484        &mut self,
2485        workspace_id: WorkspaceId,
2486        workspace_window_id: WorkspaceWindowId,
2487        path: &[bool],
2488        ratio: u16,
2489    ) -> Result<(), DomainError> {
2490        let workspace = self
2491            .workspaces
2492            .get_mut(&workspace_id)
2493            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2494        let window = workspace
2495            .windows
2496            .get_mut(&workspace_window_id)
2497            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
2498        let layout = window
2499            .active_layout_mut()
2500            .ok_or(DomainError::MissingWorkspaceWindow(workspace_window_id))?;
2501        layout.set_ratio_at_path(path, ratio);
2502        Ok(())
2503    }
2504
2505    pub fn set_workspace_viewport(
2506        &mut self,
2507        workspace_id: WorkspaceId,
2508        viewport: WorkspaceViewport,
2509    ) -> Result<(), DomainError> {
2510        let workspace = self
2511            .workspaces
2512            .get_mut(&workspace_id)
2513            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2514        workspace.viewport = viewport;
2515        Ok(())
2516    }
2517
2518    pub fn update_pane_metadata(
2519        &mut self,
2520        pane_id: PaneId,
2521        patch: PaneMetadataPatch,
2522    ) -> Result<(), DomainError> {
2523        let surface_id = self
2524            .workspaces
2525            .values()
2526            .find_map(|workspace| {
2527                workspace
2528                    .panes
2529                    .get(&pane_id)
2530                    .map(|pane| pane.active_surface)
2531            })
2532            .ok_or(DomainError::MissingPane(pane_id))?;
2533        self.update_surface_metadata(surface_id, patch)
2534    }
2535
2536    pub fn update_surface_metadata(
2537        &mut self,
2538        surface_id: SurfaceId,
2539        patch: PaneMetadataPatch,
2540    ) -> Result<(), DomainError> {
2541        let pane = self
2542            .workspaces
2543            .values_mut()
2544            .find_map(|workspace| {
2545                workspace
2546                    .panes
2547                    .values_mut()
2548                    .find(|pane| pane.surfaces.contains_key(&surface_id))
2549            })
2550            .ok_or(DomainError::MissingSurface(surface_id))?;
2551        let surface = pane
2552            .surfaces
2553            .get_mut(&surface_id)
2554            .ok_or(DomainError::MissingSurface(surface_id))?;
2555
2556        if patch.title.is_some() {
2557            surface.metadata.title = patch.title;
2558        }
2559        if patch.cwd.is_some() {
2560            surface.metadata.cwd = patch.cwd;
2561        }
2562        if patch.url.is_some() {
2563            surface.metadata.url = patch.url;
2564        }
2565        if patch.repo_name.is_some() {
2566            surface.metadata.repo_name = patch.repo_name;
2567        }
2568        if patch.git_branch.is_some() {
2569            surface.metadata.git_branch = patch.git_branch;
2570        }
2571        if let Some(ports) = patch.ports {
2572            surface.metadata.ports = ports;
2573        }
2574        if patch.agent_kind.is_some() {
2575            surface.metadata.agent_kind = patch.agent_kind;
2576        }
2577
2578        Ok(())
2579    }
2580
2581    pub fn apply_signal(
2582        &mut self,
2583        workspace_id: WorkspaceId,
2584        pane_id: PaneId,
2585        event: SignalEvent,
2586    ) -> Result<(), DomainError> {
2587        let surface_id = self
2588            .workspaces
2589            .get(&workspace_id)
2590            .and_then(|workspace| workspace.panes.get(&pane_id))
2591            .map(|pane| pane.active_surface)
2592            .ok_or(DomainError::PaneNotInWorkspace {
2593                workspace_id,
2594                pane_id,
2595            })?;
2596        self.apply_surface_signal(workspace_id, pane_id, surface_id, event)
2597    }
2598
2599    pub fn apply_surface_signal(
2600        &mut self,
2601        workspace_id: WorkspaceId,
2602        pane_id: PaneId,
2603        surface_id: SurfaceId,
2604        event: SignalEvent,
2605    ) -> Result<(), DomainError> {
2606        let SignalEvent {
2607            source,
2608            kind,
2609            message,
2610            metadata,
2611            timestamp,
2612        } = event;
2613        let workspace = self
2614            .workspaces
2615            .get_mut(&workspace_id)
2616            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2617        let pane = workspace
2618            .panes
2619            .get_mut(&pane_id)
2620            .ok_or(DomainError::PaneNotInWorkspace {
2621                workspace_id,
2622                pane_id,
2623            })?;
2624        let surface = pane
2625            .surfaces
2626            .get_mut(&surface_id)
2627            .ok_or(DomainError::SurfaceNotInPane {
2628                workspace_id,
2629                pane_id,
2630                surface_id,
2631            })?;
2632
2633        let agent_signal = is_agent_signal(surface, &source, metadata.as_ref());
2634        let normalized_message = normalized_signal_message(message.as_deref());
2635        let metadata_reported_inactive = metadata
2636            .as_ref()
2637            .and_then(|metadata| metadata.agent_active)
2638            .is_some_and(|active| !active);
2639        let metadata_clears_agent_identity =
2640            matches!(kind, SignalKind::Metadata) && metadata_reported_inactive;
2641        let (surface_attention, should_acknowledge_surface_notifications) = {
2642            let mut acknowledged_inactive_resolution = false;
2643            if agent_signal && matches!(kind, SignalKind::Started) {
2644                surface.metadata.latest_agent_message = None;
2645            }
2646            if let Some(metadata) = metadata.as_ref() {
2647                surface.metadata.title = metadata.title.clone();
2648                surface.metadata.agent_title = metadata.agent_title.clone();
2649                surface.metadata.cwd = metadata.cwd.clone();
2650                surface.metadata.repo_name = metadata.repo_name.clone();
2651                surface.metadata.git_branch = metadata.git_branch.clone();
2652                surface.metadata.ports = metadata.ports.clone();
2653                surface.metadata.agent_kind = normalized_agent_kind(metadata.agent_kind.as_deref());
2654                if let Some(agent_active) = metadata.agent_active {
2655                    surface.metadata.agent_active = agent_active;
2656                }
2657                if metadata_clears_agent_identity {
2658                    surface.agent_process = None;
2659                    surface.agent_session = None;
2660                    surface.metadata.agent_state = None;
2661                    surface.metadata.latest_agent_message = None;
2662                    surface.metadata.last_signal_at = None;
2663                    surface.attention = AttentionState::Normal;
2664                    acknowledged_inactive_resolution = true;
2665                }
2666            }
2667            if agent_signal {
2668                let agent_identity = agent_identity_for_surface(surface, metadata.as_ref());
2669                if let Some(agent_state) = signal_agent_state(&kind) {
2670                    surface.metadata.agent_state = Some(agent_state);
2671                    match kind {
2672                        SignalKind::Started | SignalKind::Progress => {
2673                            if let Some((agent_kind, title)) = agent_identity.clone() {
2674                                set_agent_turn(
2675                                    surface,
2676                                    agent_kind,
2677                                    title,
2678                                    WorkspaceAgentState::Working,
2679                                    normalized_message.clone(),
2680                                    timestamp,
2681                                );
2682                            }
2683                        }
2684                        SignalKind::WaitingInput | SignalKind::Notification => {
2685                            if (surface.agent_process.is_some() || surface.agent_session.is_some())
2686                                && let Some((agent_kind, title)) = agent_identity.clone()
2687                            {
2688                                set_agent_turn(
2689                                    surface,
2690                                    agent_kind,
2691                                    title,
2692                                    WorkspaceAgentState::Waiting,
2693                                    normalized_message.clone(),
2694                                    timestamp,
2695                                );
2696                            }
2697                        }
2698                        SignalKind::Completed | SignalKind::Error => {
2699                            let session_state = match kind {
2700                                SignalKind::Completed => WorkspaceAgentState::Completed,
2701                                SignalKind::Error => WorkspaceAgentState::Failed,
2702                                _ => unreachable!("only completed/error reach this branch"),
2703                            };
2704                            let session_message = normalized_message
2705                                .clone()
2706                                .or_else(|| surface.metadata.latest_agent_message.clone());
2707                            if let Some((agent_kind, title)) = agent_identity.clone() {
2708                                set_agent_turn(
2709                                    surface,
2710                                    agent_kind,
2711                                    title,
2712                                    session_state,
2713                                    session_message,
2714                                    timestamp,
2715                                );
2716                            }
2717                        }
2718                        SignalKind::Metadata => {}
2719                    }
2720                }
2721                if let Some(message) = normalized_message.as_ref() {
2722                    surface.metadata.latest_agent_message = Some(message.clone());
2723                }
2724            }
2725            if !matches!(kind, SignalKind::Metadata) {
2726                surface.metadata.last_signal_at = Some(timestamp);
2727                surface.attention = map_signal_to_attention(&kind);
2728                if let Some(agent_active) = signal_agent_active(&kind) {
2729                    surface.metadata.agent_active = agent_active;
2730                }
2731            } else if metadata_reported_inactive
2732                && (surface.agent_process.is_some() || surface.agent_session.is_some())
2733            {
2734                surface.agent_process = None;
2735                surface.agent_session = None;
2736                surface.attention = AttentionState::Normal;
2737                surface.metadata.agent_state = None;
2738                surface.metadata.latest_agent_message = None;
2739                surface.metadata.last_signal_at = None;
2740                acknowledged_inactive_resolution = true;
2741            }
2742
2743            (surface.attention, acknowledged_inactive_resolution)
2744        };
2745        let notification_title = surface_notification_title(surface);
2746        let notification_message = if signal_creates_notification(&source, &kind) {
2747            notification_message_for_signal(&kind, normalized_message, &notification_title, surface)
2748        } else {
2749            None
2750        };
2751
2752        if should_acknowledge_surface_notifications {
2753            workspace.complete_surface_notifications(pane_id, surface_id);
2754        }
2755
2756        if let Some(message) = notification_message {
2757            workspace.upsert_notification(NotificationItem {
2758                id: NotificationId::new(),
2759                pane_id,
2760                surface_id,
2761                kind,
2762                state: surface_attention,
2763                title: notification_title,
2764                subtitle: None,
2765                external_id: None,
2766                message,
2767                created_at: timestamp,
2768                read_at: None,
2769                cleared_at: None,
2770                desktop_delivery: NotificationDeliveryState::Pending,
2771            });
2772        }
2773
2774        Ok(())
2775    }
2776
2777    pub fn create_surface(
2778        &mut self,
2779        workspace_id: WorkspaceId,
2780        pane_id: PaneId,
2781        kind: PaneKind,
2782    ) -> Result<SurfaceId, DomainError> {
2783        let workspace = self
2784            .workspaces
2785            .get_mut(&workspace_id)
2786            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2787        let pane = workspace
2788            .panes
2789            .get_mut(&pane_id)
2790            .ok_or(DomainError::PaneNotInWorkspace {
2791                workspace_id,
2792                pane_id,
2793            })?;
2794        let surface = SurfaceRecord::new(kind);
2795        let surface_id = surface.id;
2796        pane.insert_surface(surface);
2797        workspace.focus_pane(pane_id);
2798        Ok(surface_id)
2799    }
2800
2801    pub fn focus_surface(
2802        &mut self,
2803        workspace_id: WorkspaceId,
2804        pane_id: PaneId,
2805        surface_id: SurfaceId,
2806    ) -> Result<(), DomainError> {
2807        let workspace = self
2808            .workspaces
2809            .get_mut(&workspace_id)
2810            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2811        if !workspace.focus_surface(pane_id, surface_id) {
2812            return Err(DomainError::SurfaceNotInPane {
2813                workspace_id,
2814                pane_id,
2815                surface_id,
2816            });
2817        }
2818        workspace.acknowledge_surface_notifications(pane_id, surface_id);
2819        Ok(())
2820    }
2821
2822    pub fn set_workspace_status(
2823        &mut self,
2824        workspace_id: WorkspaceId,
2825        text: String,
2826    ) -> Result<(), DomainError> {
2827        let workspace = self
2828            .workspaces
2829            .get_mut(&workspace_id)
2830            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2831        let normalized = text.trim();
2832        workspace.status_text = (!normalized.is_empty()).then(|| normalized.to_owned());
2833        Ok(())
2834    }
2835
2836    pub fn clear_workspace_status(&mut self, workspace_id: WorkspaceId) -> Result<(), DomainError> {
2837        let workspace = self
2838            .workspaces
2839            .get_mut(&workspace_id)
2840            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2841        workspace.status_text = None;
2842        Ok(())
2843    }
2844
2845    pub fn set_workspace_progress(
2846        &mut self,
2847        workspace_id: WorkspaceId,
2848        progress: ProgressState,
2849    ) -> Result<(), DomainError> {
2850        let workspace = self
2851            .workspaces
2852            .get_mut(&workspace_id)
2853            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2854        workspace.progress = Some(ProgressState {
2855            value: progress.value.min(1000),
2856            label: progress.label,
2857        });
2858        Ok(())
2859    }
2860
2861    pub fn clear_workspace_progress(
2862        &mut self,
2863        workspace_id: WorkspaceId,
2864    ) -> Result<(), DomainError> {
2865        let workspace = self
2866            .workspaces
2867            .get_mut(&workspace_id)
2868            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2869        workspace.progress = None;
2870        Ok(())
2871    }
2872
2873    pub fn append_workspace_log(
2874        &mut self,
2875        workspace_id: WorkspaceId,
2876        entry: WorkspaceLogEntry,
2877    ) -> Result<(), DomainError> {
2878        let workspace = self
2879            .workspaces
2880            .get_mut(&workspace_id)
2881            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2882        workspace.append_log_entry(entry);
2883        Ok(())
2884    }
2885
2886    pub fn clear_workspace_log(&mut self, workspace_id: WorkspaceId) -> Result<(), DomainError> {
2887        let workspace = self
2888            .workspaces
2889            .get_mut(&workspace_id)
2890            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2891        workspace.log_entries.clear();
2892        Ok(())
2893    }
2894
2895    pub fn create_agent_notification(
2896        &mut self,
2897        target: AgentTarget,
2898        kind: SignalKind,
2899        title: Option<String>,
2900        subtitle: Option<String>,
2901        external_id: Option<String>,
2902        message: String,
2903        state: AttentionState,
2904    ) -> Result<(), DomainError> {
2905        let workspace_id = match target {
2906            AgentTarget::Workspace { workspace_id }
2907            | AgentTarget::Pane { workspace_id, .. }
2908            | AgentTarget::Surface { workspace_id, .. } => workspace_id,
2909        };
2910        let workspace = self
2911            .workspaces
2912            .get_mut(&workspace_id)
2913            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2914        let (_, pane_id, surface_id) = workspace.notification_target_ids(&target)?;
2915        let now = OffsetDateTime::now_utc();
2916        let normalized_title = title
2917            .map(|value| value.trim().to_owned())
2918            .filter(|value| !value.is_empty());
2919        let normalized_subtitle = subtitle
2920            .map(|value| value.trim().to_owned())
2921            .filter(|value| !value.is_empty());
2922        let normalized_external_id = external_id
2923            .map(|value| value.trim().to_owned())
2924            .filter(|value| !value.is_empty());
2925
2926        if let Some(pane) = workspace.panes.get_mut(&pane_id)
2927            && let Some(surface) = pane.surfaces.get_mut(&surface_id)
2928        {
2929            let normalized_kind = normalized_title
2930                .as_deref()
2931                .and_then(|title| normalized_agent_kind(Some(title)));
2932            match state {
2933                AttentionState::Busy | AttentionState::WaitingInput => {
2934                    surface.attention = state;
2935                }
2936                AttentionState::Normal | AttentionState::Completed | AttentionState::Error => {
2937                    surface.attention = state;
2938                }
2939            }
2940            surface.metadata.last_signal_at = Some(now);
2941            surface.metadata.agent_state =
2942                surface.agent_session.as_ref().map(|session| session.state);
2943            surface.metadata.agent_active = surface.agent_process.is_some();
2944            surface.metadata.latest_agent_message = Some(message.clone());
2945            if let Some(agent_title) = normalized_title.clone() {
2946                surface.metadata.agent_title = Some(agent_title.clone());
2947                if let Some(agent_kind) = normalized_kind {
2948                    surface.metadata.agent_kind = Some(agent_kind);
2949                }
2950            }
2951        }
2952
2953        workspace.upsert_notification(NotificationItem {
2954            id: NotificationId::new(),
2955            pane_id,
2956            surface_id,
2957            kind,
2958            state,
2959            title: normalized_title,
2960            subtitle: normalized_subtitle,
2961            external_id: normalized_external_id,
2962            message,
2963            created_at: now,
2964            read_at: None,
2965            cleared_at: None,
2966            desktop_delivery: NotificationDeliveryState::Pending,
2967        });
2968        Ok(())
2969    }
2970
2971    pub fn clear_agent_notifications(&mut self, target: AgentTarget) -> Result<(), DomainError> {
2972        let workspace_id = match target {
2973            AgentTarget::Workspace { workspace_id }
2974            | AgentTarget::Pane { workspace_id, .. }
2975            | AgentTarget::Surface { workspace_id, .. } => workspace_id,
2976        };
2977        let workspace = self
2978            .workspaces
2979            .get_mut(&workspace_id)
2980            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2981        workspace.clear_notifications_matching(&target)
2982    }
2983
2984    pub fn start_surface_agent_session(
2985        &mut self,
2986        workspace_id: WorkspaceId,
2987        pane_id: PaneId,
2988        surface_id: SurfaceId,
2989        agent_kind: String,
2990    ) -> Result<(), DomainError> {
2991        let workspace = self
2992            .workspaces
2993            .get_mut(&workspace_id)
2994            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
2995        let surface = workspace
2996            .panes
2997            .get_mut(&pane_id)
2998            .ok_or(DomainError::PaneNotInWorkspace {
2999                workspace_id,
3000                pane_id,
3001            })?
3002            .surfaces
3003            .get_mut(&surface_id)
3004            .ok_or(DomainError::SurfaceNotInPane {
3005                workspace_id,
3006                pane_id,
3007                surface_id,
3008            })?;
3009
3010        let normalized_kind = normalized_agent_kind(Some(agent_kind.as_str()))
3011            .ok_or(DomainError::InvalidOperation("invalid agent kind"))?;
3012        let now = OffsetDateTime::now_utc();
3013        surface.agent_process = Some(SurfaceAgentProcess {
3014            id: SessionId::new(),
3015            kind: normalized_kind.clone(),
3016            title: agent_display_title(&normalized_kind),
3017            started_at: now,
3018        });
3019        surface.agent_session = None;
3020        surface.attention = AttentionState::Normal;
3021        surface.metadata.agent_kind = Some(normalized_kind);
3022        surface.metadata.agent_title = surface
3023            .agent_process
3024            .as_ref()
3025            .map(|process| process.title.clone());
3026        surface.metadata.agent_active = true;
3027        surface.metadata.agent_state = None;
3028        surface.metadata.latest_agent_message = None;
3029        surface.metadata.last_signal_at = None;
3030
3031        Ok(())
3032    }
3033
3034    pub fn stop_surface_agent_session(
3035        &mut self,
3036        workspace_id: WorkspaceId,
3037        pane_id: PaneId,
3038        surface_id: SurfaceId,
3039        exit_status: i32,
3040    ) -> Result<(), DomainError> {
3041        let workspace = self
3042            .workspaces
3043            .get_mut(&workspace_id)
3044            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
3045        let title = {
3046            let surface = workspace
3047                .panes
3048                .get_mut(&pane_id)
3049                .ok_or(DomainError::PaneNotInWorkspace {
3050                    workspace_id,
3051                    pane_id,
3052                })?
3053                .surfaces
3054                .get_mut(&surface_id)
3055                .ok_or(DomainError::SurfaceNotInPane {
3056                    workspace_id,
3057                    pane_id,
3058                    surface_id,
3059                })?;
3060
3061            let title = surface
3062                .agent_process
3063                .as_ref()
3064                .map(|process| process.title.clone())
3065                .or_else(|| {
3066                    surface
3067                        .agent_session
3068                        .as_ref()
3069                        .map(|session| session.title.clone())
3070                });
3071            surface.agent_process = None;
3072            surface.agent_session = None;
3073            surface.attention = AttentionState::Normal;
3074            surface.metadata.agent_active = false;
3075            surface.metadata.agent_state = None;
3076            surface.metadata.agent_title = None;
3077            surface.metadata.agent_kind = None;
3078            surface.metadata.latest_agent_message = None;
3079            surface.metadata.last_signal_at = None;
3080            title
3081        };
3082
3083        if exit_status != 0 && exit_status != 130 {
3084            workspace.upsert_notification(NotificationItem {
3085                id: NotificationId::new(),
3086                pane_id,
3087                surface_id,
3088                kind: SignalKind::Error,
3089                state: AttentionState::Error,
3090                title,
3091                subtitle: None,
3092                external_id: None,
3093                message: format!("Exited with status {exit_status}"),
3094                created_at: OffsetDateTime::now_utc(),
3095                read_at: None,
3096                cleared_at: None,
3097                desktop_delivery: NotificationDeliveryState::Pending,
3098            });
3099            if let Some(surface) = workspace
3100                .panes
3101                .get_mut(&pane_id)
3102                .and_then(|pane| pane.surfaces.get_mut(&surface_id))
3103            {
3104                surface.attention = AttentionState::Error;
3105            }
3106        }
3107
3108        Ok(())
3109    }
3110
3111    pub fn dismiss_surface_alert(
3112        &mut self,
3113        workspace_id: WorkspaceId,
3114        pane_id: PaneId,
3115        surface_id: SurfaceId,
3116    ) -> Result<(), DomainError> {
3117        let workspace = self
3118            .workspaces
3119            .get_mut(&workspace_id)
3120            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
3121        workspace.clear_notifications_matching(&AgentTarget::Surface {
3122            workspace_id,
3123            pane_id,
3124            surface_id,
3125        })?;
3126
3127        let surface = workspace
3128            .panes
3129            .get_mut(&pane_id)
3130            .ok_or(DomainError::PaneNotInWorkspace {
3131                workspace_id,
3132                pane_id,
3133            })?
3134            .surfaces
3135            .get_mut(&surface_id)
3136            .ok_or(DomainError::SurfaceNotInPane {
3137                workspace_id,
3138                pane_id,
3139                surface_id,
3140            })?;
3141
3142        surface.agent_session = None;
3143        surface.attention = AttentionState::Normal;
3144        surface.metadata.agent_active = surface.agent_process.is_some();
3145        surface.metadata.agent_state = None;
3146        if surface.agent_process.is_none() {
3147            surface.metadata.agent_title = None;
3148            surface.metadata.agent_kind = None;
3149        }
3150        surface.metadata.latest_agent_message = None;
3151        surface.metadata.last_signal_at = None;
3152
3153        Ok(())
3154    }
3155
3156    pub fn clear_notification(
3157        &mut self,
3158        notification_id: NotificationId,
3159    ) -> Result<(), DomainError> {
3160        for workspace in self.workspaces.values_mut() {
3161            if workspace.clear_notification(notification_id) {
3162                return Ok(());
3163            }
3164        }
3165        Err(DomainError::InvalidOperation("notification not found"))
3166    }
3167
3168    pub fn mark_notification_delivery(
3169        &mut self,
3170        notification_id: NotificationId,
3171        delivery: NotificationDeliveryState,
3172    ) -> Result<(), DomainError> {
3173        for workspace in self.workspaces.values_mut() {
3174            if workspace.set_notification_delivery(notification_id, delivery) {
3175                return Ok(());
3176            }
3177        }
3178        Err(DomainError::InvalidOperation("notification not found"))
3179    }
3180
3181    pub fn trigger_surface_flash(
3182        &mut self,
3183        workspace_id: WorkspaceId,
3184        pane_id: PaneId,
3185        surface_id: SurfaceId,
3186    ) -> Result<(), DomainError> {
3187        let workspace = self
3188            .workspaces
3189            .get_mut(&workspace_id)
3190            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
3191        if !workspace
3192            .panes
3193            .get(&pane_id)
3194            .is_some_and(|pane| pane.surfaces.contains_key(&surface_id))
3195        {
3196            return Err(DomainError::SurfaceNotInPane {
3197                workspace_id,
3198                pane_id,
3199                surface_id,
3200            });
3201        }
3202        workspace.trigger_surface_flash(surface_id);
3203        Ok(())
3204    }
3205
3206    pub fn focus_latest_unread(&mut self, window_id: WindowId) -> Result<bool, DomainError> {
3207        let window = self
3208            .windows
3209            .get(&window_id)
3210            .ok_or(DomainError::MissingWindow(window_id))?;
3211        let target = window
3212            .workspace_order
3213            .iter()
3214            .filter_map(|workspace_id| self.workspaces.get(workspace_id))
3215            .flat_map(|workspace| {
3216                workspace
3217                    .notifications
3218                    .iter()
3219                    .filter(|notification| notification.unread())
3220                    .map(move |notification| (workspace.id, notification))
3221            })
3222            .max_by_key(|(_, notification)| notification.created_at)
3223            .map(|(_, notification)| notification.id);
3224
3225        let Some(notification_id) = target else {
3226            return Ok(false);
3227        };
3228
3229        self.open_notification(window_id, notification_id)?;
3230        Ok(true)
3231    }
3232
3233    pub fn open_notification(
3234        &mut self,
3235        window_id: WindowId,
3236        notification_id: NotificationId,
3237    ) -> Result<(), DomainError> {
3238        let mut target = None;
3239        for workspace in self.workspaces.values() {
3240            if let Some((pane_id, surface_id)) = workspace.notification_target(notification_id) {
3241                target = Some((workspace.id, pane_id, surface_id));
3242                break;
3243            }
3244        }
3245
3246        let (workspace_id, pane_id, surface_id) =
3247            target.ok_or(DomainError::InvalidOperation("notification not found"))?;
3248        self.switch_workspace(window_id, workspace_id)?;
3249        self.focus_surface(workspace_id, pane_id, surface_id)?;
3250
3251        for workspace in self.workspaces.values_mut() {
3252            if workspace.mark_notification_read(notification_id) {
3253                return Ok(());
3254            }
3255        }
3256
3257        Err(DomainError::InvalidOperation("notification not found"))
3258    }
3259
3260    pub fn close_surface(
3261        &mut self,
3262        workspace_id: WorkspaceId,
3263        pane_id: PaneId,
3264        surface_id: SurfaceId,
3265    ) -> Result<(), DomainError> {
3266        let close_entire_pane = self
3267            .workspaces
3268            .get(&workspace_id)
3269            .and_then(|workspace| workspace.panes.get(&pane_id))
3270            .ok_or(DomainError::PaneNotInWorkspace {
3271                workspace_id,
3272                pane_id,
3273            })?
3274            .surfaces
3275            .len()
3276            <= 1;
3277
3278        if close_entire_pane {
3279            return self.close_pane(workspace_id, pane_id);
3280        }
3281
3282        let workspace = self
3283            .workspaces
3284            .get_mut(&workspace_id)
3285            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
3286        let pane = workspace
3287            .panes
3288            .get_mut(&pane_id)
3289            .ok_or(DomainError::PaneNotInWorkspace {
3290                workspace_id,
3291                pane_id,
3292            })?;
3293        if pane.surfaces.shift_remove(&surface_id).is_none() {
3294            return Err(DomainError::SurfaceNotInPane {
3295                workspace_id,
3296                pane_id,
3297                surface_id,
3298            });
3299        }
3300        pane.normalize();
3301        workspace
3302            .notifications
3303            .retain(|item| item.surface_id != surface_id);
3304        if workspace.active_pane == pane_id {
3305            workspace.acknowledge_pane_notifications(pane_id);
3306        }
3307        Ok(())
3308    }
3309
3310    pub fn move_surface(
3311        &mut self,
3312        workspace_id: WorkspaceId,
3313        pane_id: PaneId,
3314        surface_id: SurfaceId,
3315        to_index: usize,
3316    ) -> Result<(), DomainError> {
3317        let workspace = self
3318            .workspaces
3319            .get_mut(&workspace_id)
3320            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
3321        let pane = workspace
3322            .panes
3323            .get_mut(&pane_id)
3324            .ok_or(DomainError::PaneNotInWorkspace {
3325                workspace_id,
3326                pane_id,
3327            })?;
3328        if !pane.move_surface(surface_id, to_index) {
3329            return Err(DomainError::SurfaceNotInPane {
3330                workspace_id,
3331                pane_id,
3332                surface_id,
3333            });
3334        }
3335        Ok(())
3336    }
3337
3338    fn take_surface_from_pane(
3339        &mut self,
3340        workspace_id: WorkspaceId,
3341        pane_id: PaneId,
3342        surface_id: SurfaceId,
3343    ) -> Result<SurfaceRecord, DomainError> {
3344        let workspace = self
3345            .workspaces
3346            .get_mut(&workspace_id)
3347            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
3348        let source_pane =
3349            workspace
3350                .panes
3351                .get_mut(&pane_id)
3352                .ok_or(DomainError::PaneNotInWorkspace {
3353                    workspace_id,
3354                    pane_id,
3355                })?;
3356        let moved_surface = source_pane.surfaces.shift_remove(&surface_id).ok_or(
3357            DomainError::SurfaceNotInPane {
3358                workspace_id,
3359                pane_id,
3360                surface_id,
3361            },
3362        )?;
3363        if !source_pane.surfaces.is_empty() {
3364            source_pane.normalize_active_surface();
3365        }
3366        Ok(moved_surface)
3367    }
3368
3369    fn should_close_source_pane(&self, workspace_id: WorkspaceId, pane_id: PaneId) -> bool {
3370        self.workspaces
3371            .get(&workspace_id)
3372            .and_then(|workspace| workspace.panes.get(&pane_id))
3373            .is_some_and(|pane| pane.surfaces.is_empty())
3374    }
3375
3376    fn retarget_surface_state(
3377        &mut self,
3378        source_workspace_id: WorkspaceId,
3379        target_workspace_id: WorkspaceId,
3380        surface_id: SurfaceId,
3381        target_pane_id: PaneId,
3382    ) -> Result<(), DomainError> {
3383        if source_workspace_id == target_workspace_id {
3384            let workspace = self
3385                .workspaces
3386                .get_mut(&source_workspace_id)
3387                .ok_or(DomainError::MissingWorkspace(source_workspace_id))?;
3388            for notification in &mut workspace.notifications {
3389                if notification.surface_id == surface_id {
3390                    notification.pane_id = target_pane_id;
3391                }
3392            }
3393            return Ok(());
3394        }
3395
3396        let (mut moved_notifications, moved_flash_token) = {
3397            let source_workspace = self
3398                .workspaces
3399                .get_mut(&source_workspace_id)
3400                .ok_or(DomainError::MissingWorkspace(source_workspace_id))?;
3401            let moved_flash_token = source_workspace.surface_flash_tokens.remove(&surface_id);
3402            let mut moved_notifications = Vec::new();
3403            source_workspace.notifications.retain(|notification| {
3404                if notification.surface_id == surface_id {
3405                    moved_notifications.push(notification.clone());
3406                    false
3407                } else {
3408                    true
3409                }
3410            });
3411            (moved_notifications, moved_flash_token)
3412        };
3413
3414        let target_workspace = self
3415            .workspaces
3416            .get_mut(&target_workspace_id)
3417            .ok_or(DomainError::MissingWorkspace(target_workspace_id))?;
3418        for notification in &mut moved_notifications {
3419            notification.pane_id = target_pane_id;
3420        }
3421        target_workspace.notifications.extend(moved_notifications);
3422        if let Some(token) = moved_flash_token {
3423            target_workspace
3424                .surface_flash_tokens
3425                .insert(surface_id, token);
3426        }
3427        Ok(())
3428    }
3429
3430    pub fn transfer_surface(
3431        &mut self,
3432        source_workspace_id: WorkspaceId,
3433        source_pane_id: PaneId,
3434        surface_id: SurfaceId,
3435        target_workspace_id: WorkspaceId,
3436        target_pane_id: PaneId,
3437        to_index: usize,
3438    ) -> Result<(), DomainError> {
3439        if source_workspace_id == target_workspace_id && source_pane_id == target_pane_id {
3440            return self.move_surface(source_workspace_id, source_pane_id, surface_id, to_index);
3441        }
3442
3443        {
3444            let source_workspace = self
3445                .workspaces
3446                .get(&source_workspace_id)
3447                .ok_or(DomainError::MissingWorkspace(source_workspace_id))?;
3448            let target_workspace = self
3449                .workspaces
3450                .get(&target_workspace_id)
3451                .ok_or(DomainError::MissingWorkspace(target_workspace_id))?;
3452            if !source_workspace.panes.contains_key(&source_pane_id) {
3453                return Err(DomainError::PaneNotInWorkspace {
3454                    workspace_id: source_workspace_id,
3455                    pane_id: source_pane_id,
3456                });
3457            }
3458            if !target_workspace.panes.contains_key(&target_pane_id) {
3459                return Err(DomainError::PaneNotInWorkspace {
3460                    workspace_id: target_workspace_id,
3461                    pane_id: target_pane_id,
3462                });
3463            }
3464            if !source_workspace
3465                .panes
3466                .get(&source_pane_id)
3467                .is_some_and(|pane| pane.surfaces.contains_key(&surface_id))
3468            {
3469                return Err(DomainError::SurfaceNotInPane {
3470                    workspace_id: source_workspace_id,
3471                    pane_id: source_pane_id,
3472                    surface_id,
3473                });
3474            }
3475        }
3476
3477        let moved_surface =
3478            self.take_surface_from_pane(source_workspace_id, source_pane_id, surface_id)?;
3479        let should_close_source_pane =
3480            self.should_close_source_pane(source_workspace_id, source_pane_id);
3481
3482        {
3483            let workspace = self
3484                .workspaces
3485                .get_mut(&target_workspace_id)
3486                .ok_or(DomainError::MissingWorkspace(target_workspace_id))?;
3487            let target_pane = workspace.panes.get_mut(&target_pane_id).ok_or(
3488                DomainError::PaneNotInWorkspace {
3489                    workspace_id: target_workspace_id,
3490                    pane_id: target_pane_id,
3491                },
3492            )?;
3493            target_pane.insert_surface(moved_surface);
3494            if target_pane.surfaces.len() > 1 {
3495                let last_index = target_pane.surfaces.len() - 1;
3496                let target_index = to_index.min(last_index);
3497                let _ = target_pane.move_surface(surface_id, target_index);
3498            }
3499            target_pane.active_surface = surface_id;
3500            let _ = workspace.focus_surface(target_pane_id, surface_id);
3501        }
3502        self.retarget_surface_state(
3503            source_workspace_id,
3504            target_workspace_id,
3505            surface_id,
3506            target_pane_id,
3507        )?;
3508
3509        if should_close_source_pane {
3510            self.close_pane(source_workspace_id, source_pane_id)?;
3511        }
3512
3513        if source_workspace_id != target_workspace_id {
3514            self.switch_workspace(self.active_window, target_workspace_id)?;
3515        }
3516
3517        let workspace = self
3518            .workspaces
3519            .get_mut(&target_workspace_id)
3520            .ok_or(DomainError::MissingWorkspace(target_workspace_id))?;
3521        let _ = workspace.focus_surface(target_pane_id, surface_id);
3522
3523        Ok(())
3524    }
3525
3526    pub fn move_surface_to_split(
3527        &mut self,
3528        source_workspace_id: WorkspaceId,
3529        source_pane_id: PaneId,
3530        surface_id: SurfaceId,
3531        target_workspace_id: WorkspaceId,
3532        target_pane_id: PaneId,
3533        direction: Direction,
3534    ) -> Result<PaneId, DomainError> {
3535        {
3536            let source_workspace = self
3537                .workspaces
3538                .get(&source_workspace_id)
3539                .ok_or(DomainError::MissingWorkspace(source_workspace_id))?;
3540            let source_pane = source_workspace.panes.get(&source_pane_id).ok_or(
3541                DomainError::PaneNotInWorkspace {
3542                    workspace_id: source_workspace_id,
3543                    pane_id: source_pane_id,
3544                },
3545            )?;
3546            let target_workspace = self
3547                .workspaces
3548                .get(&target_workspace_id)
3549                .ok_or(DomainError::MissingWorkspace(target_workspace_id))?;
3550            if !target_workspace.panes.contains_key(&target_pane_id) {
3551                return Err(DomainError::PaneNotInWorkspace {
3552                    workspace_id: target_workspace_id,
3553                    pane_id: target_pane_id,
3554                });
3555            }
3556            if !source_pane.surfaces.contains_key(&surface_id) {
3557                return Err(DomainError::SurfaceNotInPane {
3558                    workspace_id: source_workspace_id,
3559                    pane_id: source_pane_id,
3560                    surface_id,
3561                });
3562            }
3563            if source_workspace_id == target_workspace_id
3564                && source_pane_id == target_pane_id
3565                && source_pane.surfaces.len() <= 1
3566            {
3567                return Err(DomainError::InvalidOperation(
3568                    "cannot split a pane from its only surface",
3569                ));
3570            }
3571        }
3572
3573        let target_window_id = self
3574            .workspaces
3575            .get(&target_workspace_id)
3576            .and_then(|workspace| workspace.window_for_pane(target_pane_id))
3577            .ok_or(DomainError::MissingPane(target_pane_id))?;
3578
3579        let moved_surface =
3580            self.take_surface_from_pane(source_workspace_id, source_pane_id, surface_id)?;
3581        let new_pane = PaneRecord::from_surface(moved_surface);
3582        let new_pane_id = new_pane.id;
3583
3584        let should_close_source_pane =
3585            self.should_close_source_pane(source_workspace_id, source_pane_id);
3586
3587        {
3588            let workspace = self
3589                .workspaces
3590                .get_mut(&target_workspace_id)
3591                .ok_or(DomainError::MissingWorkspace(target_workspace_id))?;
3592            workspace.panes.insert(new_pane_id, new_pane);
3593
3594            let target_window = workspace
3595                .windows
3596                .get_mut(&target_window_id)
3597                .ok_or(DomainError::MissingPane(target_pane_id))?;
3598            let Some(target_tab_id) = target_window.tab_for_pane(target_pane_id) else {
3599                return Err(DomainError::MissingPane(target_pane_id));
3600            };
3601            let _ = target_window.focus_tab(target_tab_id);
3602            let layout = target_window
3603                .active_layout_mut()
3604                .ok_or(DomainError::MissingPane(target_pane_id))?;
3605            layout.split_leaf_with_direction(target_pane_id, direction, new_pane_id, 500);
3606            let _ = target_window.focus_pane(new_pane_id);
3607            workspace.sync_active_from_window(target_window_id);
3608            let _ = workspace.focus_surface(new_pane_id, surface_id);
3609        }
3610        self.retarget_surface_state(
3611            source_workspace_id,
3612            target_workspace_id,
3613            surface_id,
3614            new_pane_id,
3615        )?;
3616
3617        if should_close_source_pane {
3618            self.close_pane(source_workspace_id, source_pane_id)?;
3619        }
3620
3621        if source_workspace_id != target_workspace_id {
3622            self.switch_workspace(self.active_window, target_workspace_id)?;
3623        }
3624
3625        let workspace = self
3626            .workspaces
3627            .get_mut(&target_workspace_id)
3628            .ok_or(DomainError::MissingWorkspace(target_workspace_id))?;
3629        let _ = workspace.focus_surface(new_pane_id, surface_id);
3630
3631        Ok(new_pane_id)
3632    }
3633
3634    pub fn move_surface_to_workspace(
3635        &mut self,
3636        source_workspace_id: WorkspaceId,
3637        source_pane_id: PaneId,
3638        surface_id: SurfaceId,
3639        target_workspace_id: WorkspaceId,
3640    ) -> Result<PaneId, DomainError> {
3641        if source_workspace_id == target_workspace_id {
3642            return Err(DomainError::InvalidOperation(
3643                "surface is already in the target workspace",
3644            ));
3645        }
3646
3647        {
3648            let source_workspace = self
3649                .workspaces
3650                .get(&source_workspace_id)
3651                .ok_or(DomainError::MissingWorkspace(source_workspace_id))?;
3652            if !self.workspaces.contains_key(&target_workspace_id) {
3653                return Err(DomainError::MissingWorkspace(target_workspace_id));
3654            }
3655            let source_pane = source_workspace.panes.get(&source_pane_id).ok_or(
3656                DomainError::PaneNotInWorkspace {
3657                    workspace_id: source_workspace_id,
3658                    pane_id: source_pane_id,
3659                },
3660            )?;
3661            if !source_pane.surfaces.contains_key(&surface_id) {
3662                return Err(DomainError::SurfaceNotInPane {
3663                    workspace_id: source_workspace_id,
3664                    pane_id: source_pane_id,
3665                    surface_id,
3666                });
3667            }
3668        }
3669
3670        let moved_surface =
3671            self.take_surface_from_pane(source_workspace_id, source_pane_id, surface_id)?;
3672
3673        let should_close_source_pane =
3674            self.should_close_source_pane(source_workspace_id, source_pane_id);
3675
3676        let new_pane = PaneRecord::from_surface(moved_surface);
3677        let new_pane_id = new_pane.id;
3678        let new_window = WorkspaceWindowRecord::new(new_pane_id);
3679        let new_window_id = new_window.id;
3680
3681        {
3682            let target_workspace = self
3683                .workspaces
3684                .get_mut(&target_workspace_id)
3685                .ok_or(DomainError::MissingWorkspace(target_workspace_id))?;
3686            target_workspace.panes.insert(new_pane_id, new_pane);
3687            target_workspace.windows.insert(new_window_id, new_window);
3688            insert_window_relative_to_active(target_workspace, new_window_id, Direction::Right)?;
3689            target_workspace.sync_active_from_window(new_window_id);
3690            let _ = target_workspace.focus_surface(new_pane_id, surface_id);
3691        }
3692        self.retarget_surface_state(
3693            source_workspace_id,
3694            target_workspace_id,
3695            surface_id,
3696            new_pane_id,
3697        )?;
3698
3699        if should_close_source_pane {
3700            self.close_pane(source_workspace_id, source_pane_id)?;
3701        }
3702
3703        self.switch_workspace(self.active_window, target_workspace_id)?;
3704        let target_workspace = self
3705            .workspaces
3706            .get_mut(&target_workspace_id)
3707            .ok_or(DomainError::MissingWorkspace(target_workspace_id))?;
3708        let _ = target_workspace.focus_surface(new_pane_id, surface_id);
3709
3710        Ok(new_pane_id)
3711    }
3712
3713    pub fn close_pane(
3714        &mut self,
3715        workspace_id: WorkspaceId,
3716        pane_id: PaneId,
3717    ) -> Result<(), DomainError> {
3718        {
3719            let workspace = self
3720                .workspaces
3721                .get(&workspace_id)
3722                .ok_or(DomainError::MissingWorkspace(workspace_id))?;
3723
3724            if !workspace.panes.contains_key(&pane_id) {
3725                return Err(DomainError::PaneNotInWorkspace {
3726                    workspace_id,
3727                    pane_id,
3728                });
3729            }
3730
3731            if workspace.panes.len() <= 1 {
3732                return self.close_workspace(workspace_id);
3733            }
3734        }
3735
3736        let workspace = self
3737            .workspaces
3738            .get_mut(&workspace_id)
3739            .ok_or(DomainError::MissingWorkspace(workspace_id))?;
3740        let window_id = workspace
3741            .window_for_pane(pane_id)
3742            .ok_or(DomainError::MissingPane(pane_id))?;
3743        let (column_id, column_index, window_index) = workspace
3744            .position_for_window(window_id)
3745            .ok_or(DomainError::MissingWorkspaceWindow(window_id))?;
3746
3747        let (tab_id, tab_leaf_count, window_tab_count) = workspace
3748            .windows
3749            .get(&window_id)
3750            .and_then(|window| {
3751                let tab_id = window.tab_for_pane(pane_id)?;
3752                let tab = window.tabs.get(&tab_id)?;
3753                Some((tab_id, tab.layout.leaves().len(), window.tabs.len()))
3754            })
3755            .ok_or(DomainError::MissingPane(pane_id))?;
3756
3757        if tab_leaf_count <= 1 {
3758            if window_tab_count > 1 {
3759                return self.close_workspace_window_tab(workspace_id, window_id, tab_id);
3760            }
3761            if workspace.windows.len() > 1 {
3762                let tab_panes = workspace
3763                    .windows
3764                    .get(&window_id)
3765                    .and_then(|window| window.tabs.get(&tab_id))
3766                    .map(|tab| tab.layout.leaves())
3767                    .unwrap_or_else(|| vec![pane_id]);
3768                let column = workspace
3769                    .columns
3770                    .get_mut(&column_id)
3771                    .expect("window column should exist");
3772                column.window_order.remove(window_index);
3773                let same_column_survived = !column.window_order.is_empty();
3774                if same_column_survived {
3775                    if !column.window_order.contains(&column.active_window) {
3776                        let replacement_index = window_index.min(column.window_order.len() - 1);
3777                        column.active_window = column.window_order[replacement_index];
3778                    }
3779                } else {
3780                    workspace.columns.shift_remove(&column_id);
3781                }
3782
3783                workspace.windows.shift_remove(&window_id);
3784                remove_panes_from_workspace(workspace, &tab_panes);
3785                if let Some(next_window_id) = workspace.fallback_window_after_close(
3786                    column_index,
3787                    window_index,
3788                    same_column_survived,
3789                ) {
3790                    workspace.sync_active_from_window(next_window_id);
3791                }
3792                return Ok(());
3793            }
3794        }
3795
3796        if let Some(window) = workspace.windows.get_mut(&window_id) {
3797            let tab = window
3798                .tabs
3799                .get_mut(&tab_id)
3800                .ok_or(DomainError::MissingWorkspaceWindowTab(tab_id))?;
3801            let fallback_focus = close_layout_pane(tab, pane_id)
3802                .or_else(|| tab.layout.leaves().into_iter().next())
3803                .expect("tab should retain at least one pane");
3804            tab.active_pane = fallback_focus;
3805            let _ = window.focus_tab(tab_id);
3806        }
3807        remove_panes_from_workspace(workspace, &[pane_id]);
3808
3809        if workspace.active_window == window_id {
3810            workspace.sync_active_from_window(window_id);
3811        } else if workspace.active_pane == pane_id {
3812            workspace.sync_active_from_window(workspace.active_window);
3813        }
3814
3815        Ok(())
3816    }
3817
3818    pub fn close_workspace(&mut self, workspace_id: WorkspaceId) -> Result<(), DomainError> {
3819        if !self.workspaces.contains_key(&workspace_id) {
3820            return Err(DomainError::MissingWorkspace(workspace_id));
3821        }
3822
3823        if self.workspaces.len() <= 1 {
3824            self.create_workspace("Workspace 1");
3825        }
3826
3827        self.workspaces.shift_remove(&workspace_id);
3828
3829        for window in self.windows.values_mut() {
3830            window.workspace_order.retain(|id| *id != workspace_id);
3831            if window.active_workspace == workspace_id
3832                && let Some(first) = window.workspace_order.first()
3833            {
3834                window.active_workspace = *first;
3835            }
3836        }
3837
3838        Ok(())
3839    }
3840
3841    pub fn workspace_summaries(
3842        &self,
3843        window_id: WindowId,
3844    ) -> Result<Vec<WorkspaceSummary>, DomainError> {
3845        let window = self
3846            .windows
3847            .get(&window_id)
3848            .ok_or(DomainError::MissingWindow(window_id))?;
3849        let now = OffsetDateTime::now_utc();
3850
3851        let summaries = window
3852            .workspace_order
3853            .iter()
3854            .filter_map(|workspace_id| self.workspaces.get(workspace_id))
3855            .map(|workspace| {
3856                let counts = workspace.attention_counts();
3857                let agent_summaries = workspace.agent_summaries(now);
3858                let highest_attention = workspace
3859                    .panes
3860                    .values()
3861                    .map(PaneRecord::highest_attention)
3862                    .max_by_key(|attention| attention.rank())
3863                    .unwrap_or(AttentionState::Normal);
3864                let unread = workspace
3865                    .notifications
3866                    .iter()
3867                    .filter(|notification| notification.unread())
3868                    .collect::<Vec<_>>();
3869                let unread_attention = unread
3870                    .iter()
3871                    .map(|notification| notification.state)
3872                    .max_by_key(|attention| attention.rank());
3873                let latest_notification = unread
3874                    .iter()
3875                    .max_by_key(|notification| notification.created_at)
3876                    .map(|notification| notification.message.clone());
3877
3878                WorkspaceSummary {
3879                    workspace_id: workspace.id,
3880                    label: workspace.label.clone(),
3881                    active_pane: workspace.active_pane,
3882                    repo_hint: workspace.repo_hint().map(str::to_owned),
3883                    agent_summaries,
3884                    counts_by_attention: counts,
3885                    highest_attention,
3886                    display_attention: unread_attention.unwrap_or(highest_attention),
3887                    unread_count: unread.len(),
3888                    latest_notification,
3889                    status_text: workspace.status_text.clone(),
3890                }
3891            })
3892            .collect();
3893
3894        Ok(summaries)
3895    }
3896
3897    pub fn activity_items(&self) -> Vec<ActivityItem> {
3898        let mut items = self
3899            .workspaces
3900            .values()
3901            .flat_map(|workspace| {
3902                workspace
3903                    .notifications
3904                    .iter()
3905                    .filter(|notification| notification.active())
3906                    .map(move |notification| ActivityItem {
3907                        notification_id: notification.id,
3908                        workspace_id: workspace.id,
3909                        workspace_window_id: workspace.window_for_pane(notification.pane_id),
3910                        pane_id: notification.pane_id,
3911                        surface_id: notification.surface_id,
3912                        kind: notification.kind.clone(),
3913                        state: notification.state,
3914                        title: notification.title.clone(),
3915                        subtitle: notification.subtitle.clone(),
3916                        message: notification.message.clone(),
3917                        read_at: notification.read_at,
3918                        created_at: notification.created_at,
3919                    })
3920            })
3921            .collect::<Vec<_>>();
3922
3923        items.sort_by(|left, right| right.created_at.cmp(&left.created_at));
3924        items
3925    }
3926
3927    pub fn snapshot(&self) -> PersistedSession {
3928        PersistedSession {
3929            schema_version: SESSION_SCHEMA_VERSION,
3930            captured_at: OffsetDateTime::now_utc(),
3931            model: self.clone(),
3932        }
3933    }
3934}
3935
3936#[derive(Debug, Deserialize)]
3937struct CurrentWorkspaceSerde {
3938    id: WorkspaceId,
3939    label: String,
3940    columns: IndexMap<WorkspaceColumnId, WorkspaceColumnRecord>,
3941    windows: IndexMap<WorkspaceWindowId, WorkspaceWindowRecord>,
3942    active_window: WorkspaceWindowId,
3943    panes: IndexMap<PaneId, PaneRecord>,
3944    active_pane: PaneId,
3945    #[serde(default)]
3946    viewport: WorkspaceViewport,
3947    #[serde(default)]
3948    notifications: Vec<NotificationItem>,
3949    #[serde(default)]
3950    status_text: Option<String>,
3951    #[serde(default)]
3952    progress: Option<ProgressState>,
3953    #[serde(default)]
3954    log_entries: Vec<WorkspaceLogEntry>,
3955    #[serde(default)]
3956    surface_flash_tokens: BTreeMap<SurfaceId, u64>,
3957    #[serde(default)]
3958    next_flash_token: u64,
3959    #[serde(default)]
3960    custom_color: Option<String>,
3961}
3962
3963impl CurrentWorkspaceSerde {
3964    fn into_workspace(self) -> Workspace {
3965        let mut workspace = Workspace {
3966            id: self.id,
3967            label: self.label,
3968            columns: self.columns,
3969            windows: self.windows,
3970            active_window: self.active_window,
3971            panes: self.panes,
3972            active_pane: self.active_pane,
3973            viewport: self.viewport,
3974            notifications: self.notifications,
3975            status_text: self.status_text,
3976            progress: self.progress,
3977            log_entries: self.log_entries,
3978            surface_flash_tokens: self.surface_flash_tokens,
3979            next_flash_token: self.next_flash_token,
3980            custom_color: self.custom_color,
3981        };
3982        workspace.normalize();
3983        workspace
3984    }
3985}
3986
3987fn signal_kind_creates_notification(kind: &SignalKind) -> bool {
3988    matches!(
3989        kind,
3990        SignalKind::Completed | SignalKind::WaitingInput | SignalKind::Error
3991    )
3992}
3993
3994fn is_agent_kind(agent_kind: Option<&str>) -> bool {
3995    normalized_agent_kind(agent_kind).is_some()
3996}
3997
3998fn is_agent_hook_source(source: &str) -> bool {
3999    source.trim().starts_with("agent-hook:")
4000}
4001
4002fn normalized_agent_kind(agent_kind: Option<&str>) -> Option<String> {
4003    let normalized = agent_kind
4004        .map(str::trim)
4005        .filter(|agent| !agent.is_empty())
4006        .map(|agent| agent.to_ascii_lowercase())?;
4007    match normalized.as_str() {
4008        "shell" => None,
4009        "claude code" | "claude-code" => Some("claude".into()),
4010        other => Some(other.to_string()),
4011    }
4012}
4013
4014fn agent_display_title(agent_kind: &str) -> String {
4015    match agent_kind {
4016        "codex" => "Codex".into(),
4017        "claude" => "Claude".into(),
4018        "opencode" => "OpenCode".into(),
4019        "aider" => "Aider".into(),
4020        other => other.to_string(),
4021    }
4022}
4023
4024fn agent_identity_for_surface(
4025    surface: &SurfaceRecord,
4026    metadata: Option<&SignalPaneMetadata>,
4027) -> Option<(String, String)> {
4028    if let Some(process) = surface.agent_process.as_ref() {
4029        return Some((process.kind.clone(), process.title.clone()));
4030    }
4031
4032    let kind = surface.metadata.agent_kind.clone().or_else(|| {
4033        metadata.and_then(|metadata| normalized_agent_kind(metadata.agent_kind.as_deref()))
4034    })?;
4035    let title = surface
4036        .metadata
4037        .agent_title
4038        .clone()
4039        .or_else(|| metadata.and_then(|metadata| metadata.agent_title.clone()))
4040        .unwrap_or_else(|| agent_display_title(&kind));
4041    Some((kind, title))
4042}
4043
4044fn set_agent_turn(
4045    surface: &mut SurfaceRecord,
4046    kind: String,
4047    title: String,
4048    state: WorkspaceAgentState,
4049    latest_message: Option<String>,
4050    updated_at: OffsetDateTime,
4051) {
4052    match surface.agent_session.as_mut() {
4053        Some(session) => {
4054            session.kind = kind;
4055            session.title = title;
4056            session.state = state;
4057            session.latest_message = latest_message;
4058            session.updated_at = updated_at;
4059        }
4060        None => {
4061            surface.agent_session = Some(SurfaceAgentSession {
4062                id: SessionId::new(),
4063                kind,
4064                title,
4065                state,
4066                latest_message,
4067                updated_at,
4068            });
4069        }
4070    }
4071}
4072
4073fn is_agent_signal(
4074    surface: &SurfaceRecord,
4075    source: &str,
4076    metadata: Option<&SignalPaneMetadata>,
4077) -> bool {
4078    is_agent_hook_source(source)
4079        || is_agent_kind(metadata.and_then(|metadata| metadata.agent_kind.as_deref()))
4080        || surface.agent_process.is_some()
4081        || surface.agent_session.is_some()
4082}
4083
4084fn normalized_signal_message(message: Option<&str>) -> Option<String> {
4085    message
4086        .map(str::trim)
4087        .filter(|message| !message.is_empty())
4088        .map(str::to_owned)
4089}
4090
4091fn surface_notification_title(surface: &SurfaceRecord) -> Option<String> {
4092    if let Some(session) = surface.agent_session.as_ref() {
4093        return Some(session.title.clone());
4094    }
4095
4096    if let Some(process) = surface.agent_process.as_ref() {
4097        return Some(process.title.clone());
4098    }
4099
4100    surface
4101        .metadata
4102        .agent_title
4103        .as_deref()
4104        .or(surface.metadata.title.as_deref())
4105        .map(str::trim)
4106        .filter(|title| !title.is_empty())
4107        .map(str::to_owned)
4108}
4109
4110fn notification_message_for_signal(
4111    kind: &SignalKind,
4112    explicit_message: Option<String>,
4113    notification_title: &Option<String>,
4114    surface: &SurfaceRecord,
4115) -> Option<String> {
4116    match kind {
4117        SignalKind::Metadata | SignalKind::Started | SignalKind::Progress => None,
4118        SignalKind::Notification => explicit_message.or_else(|| notification_title.clone()),
4119        SignalKind::WaitingInput => explicit_message.or_else(|| notification_title.clone()),
4120        SignalKind::Completed | SignalKind::Error => explicit_message
4121            .or_else(|| surface.metadata.latest_agent_message.clone())
4122            .or_else(|| notification_title.clone()),
4123    }
4124}
4125
4126fn signal_creates_notification(source: &str, kind: &SignalKind) -> bool {
4127    match kind {
4128        SignalKind::Notification => !is_agent_hook_source(source),
4129        _ => signal_kind_creates_notification(kind),
4130    }
4131}
4132
4133fn remove_window_from_column(
4134    workspace: &mut Workspace,
4135    column_id: WorkspaceColumnId,
4136    window_index: usize,
4137) -> Result<(), DomainError> {
4138    let remove_column = {
4139        let column = workspace
4140            .columns
4141            .get_mut(&column_id)
4142            .ok_or(DomainError::MissingWorkspaceColumn(column_id))?;
4143        if window_index >= column.window_order.len() {
4144            return Err(DomainError::MissingWorkspaceColumn(column_id));
4145        }
4146        column.window_order.remove(window_index);
4147        if column.window_order.is_empty() {
4148            true
4149        } else {
4150            if !column.window_order.contains(&column.active_window) {
4151                let replacement_index = window_index.min(column.window_order.len() - 1);
4152                column.active_window = column.window_order[replacement_index];
4153            }
4154            false
4155        }
4156    };
4157    if remove_column {
4158        workspace.columns.shift_remove(&column_id);
4159    }
4160    Ok(())
4161}
4162
4163fn remove_panes_from_workspace(workspace: &mut Workspace, pane_ids: &[PaneId]) {
4164    let pane_set = pane_ids.iter().copied().collect::<BTreeSet<_>>();
4165    let surface_set = pane_set
4166        .iter()
4167        .filter_map(|pane_id| workspace.panes.get(pane_id))
4168        .flat_map(|pane| pane.surface_ids())
4169        .collect::<BTreeSet<_>>();
4170    for pane_id in &pane_set {
4171        workspace.panes.shift_remove(pane_id);
4172    }
4173    workspace
4174        .notifications
4175        .retain(|item| !pane_set.contains(&item.pane_id));
4176    workspace
4177        .surface_flash_tokens
4178        .retain(|surface_id, _| !surface_set.contains(surface_id));
4179}
4180
4181fn close_layout_pane(tab: &mut WorkspaceWindowTabRecord, pane_id: PaneId) -> Option<PaneId> {
4182    let fallback = [
4183        Direction::Right,
4184        Direction::Down,
4185        Direction::Left,
4186        Direction::Up,
4187    ]
4188    .into_iter()
4189    .find_map(|direction| tab.layout.focus_neighbor(pane_id, direction))
4190    .or_else(|| {
4191        tab.layout
4192            .leaves()
4193            .into_iter()
4194            .find(|candidate| *candidate != pane_id)
4195    });
4196    let removed = tab.layout.remove_leaf(pane_id);
4197    removed.then_some(fallback).flatten()
4198}
4199
4200fn map_signal_to_attention(kind: &SignalKind) -> AttentionState {
4201    match kind {
4202        SignalKind::Metadata => AttentionState::Normal,
4203        SignalKind::Started | SignalKind::Progress => AttentionState::Busy,
4204        SignalKind::Completed => AttentionState::Completed,
4205        SignalKind::WaitingInput => AttentionState::WaitingInput,
4206        SignalKind::Error => AttentionState::Error,
4207        SignalKind::Notification => AttentionState::WaitingInput,
4208    }
4209}
4210
4211fn signal_agent_active(kind: &SignalKind) -> Option<bool> {
4212    match kind {
4213        SignalKind::Metadata => None,
4214        SignalKind::Started | SignalKind::Progress | SignalKind::WaitingInput => Some(true),
4215        SignalKind::Completed | SignalKind::Error => Some(false),
4216        SignalKind::Notification => None,
4217    }
4218}
4219
4220fn signal_agent_state(kind: &SignalKind) -> Option<WorkspaceAgentState> {
4221    match kind {
4222        SignalKind::Metadata => None,
4223        SignalKind::Started | SignalKind::Progress => Some(WorkspaceAgentState::Working),
4224        SignalKind::WaitingInput | SignalKind::Notification => Some(WorkspaceAgentState::Waiting),
4225        SignalKind::Completed => Some(WorkspaceAgentState::Completed),
4226        SignalKind::Error => Some(WorkspaceAgentState::Failed),
4227    }
4228}
4229
4230#[cfg(test)]
4231mod tests {
4232    use serde_json::json;
4233    use time::Duration;
4234
4235    use super::*;
4236    use crate::SignalPaneMetadata;
4237
4238    #[test]
4239    fn creating_workspace_windows_creates_columns_and_stacks() {
4240        let mut model = AppModel::new("Main");
4241        let workspace_id = model.active_workspace_id().expect("workspace");
4242        let first_window_id = model
4243            .active_workspace()
4244            .map(|workspace| workspace.active_window)
4245            .expect("window");
4246
4247        let right_pane = model
4248            .create_workspace_window(workspace_id, Direction::Right)
4249            .expect("window created");
4250        let stacked_pane = model
4251            .create_workspace_window(workspace_id, Direction::Down)
4252            .expect("stacked window created");
4253        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4254        let right_column = workspace
4255            .columns
4256            .values()
4257            .find(|column| column.window_order.contains(&workspace.active_window))
4258            .expect("active column");
4259
4260        assert_eq!(workspace.windows.len(), 3);
4261        assert_eq!(workspace.columns.len(), 2);
4262        assert_eq!(workspace.active_pane, stacked_pane);
4263        assert_eq!(right_column.width, MIN_WORKSPACE_WINDOW_WIDTH);
4264        assert_eq!(right_column.window_order.len(), 2);
4265        assert_ne!(workspace.active_window, first_window_id);
4266        assert!(workspace.columns.values().any(|column| {
4267            column.window_order == vec![first_window_id]
4268                && column.width == MIN_WORKSPACE_WINDOW_WIDTH
4269        }));
4270        let upper_window_id = right_column.window_order[0];
4271        assert_eq!(
4272            workspace
4273                .windows
4274                .get(&upper_window_id)
4275                .expect("window")
4276                .active_pane()
4277                .expect("active pane"),
4278            right_pane
4279        );
4280        assert_eq!(
4281            workspace
4282                .windows
4283                .get(&upper_window_id)
4284                .expect("window")
4285                .height,
4286            (DEFAULT_WORKSPACE_WINDOW_HEIGHT + 1) / 2
4287        );
4288        assert_eq!(
4289            workspace
4290                .windows
4291                .get(&workspace.active_window)
4292                .expect("window")
4293                .height,
4294            DEFAULT_WORKSPACE_WINDOW_HEIGHT / 2
4295        );
4296    }
4297
4298    #[test]
4299    fn creating_workspace_window_clamps_split_column_width_to_minimum() {
4300        let mut model = AppModel::new("Main");
4301        let workspace_id = model.active_workspace_id().expect("workspace");
4302        let workspace = model.active_workspace().expect("workspace");
4303        let column_id = workspace.active_column_id().expect("active column");
4304
4305        model
4306            .set_workspace_column_width(workspace_id, column_id, MIN_WORKSPACE_WINDOW_WIDTH + 80)
4307            .expect("set width");
4308        model
4309            .create_workspace_window(workspace_id, Direction::Right)
4310            .expect("window created");
4311
4312        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4313        let widths = workspace
4314            .columns
4315            .values()
4316            .map(|column| column.width)
4317            .collect::<Vec<_>>();
4318        assert_eq!(
4319            widths,
4320            vec![MIN_WORKSPACE_WINDOW_WIDTH, MIN_WORKSPACE_WINDOW_WIDTH]
4321        );
4322    }
4323
4324    #[test]
4325    fn creating_workspace_window_clamps_split_window_height_to_minimum() {
4326        let mut model = AppModel::new("Main");
4327        let workspace_id = model.active_workspace_id().expect("workspace");
4328        let window_id = model
4329            .active_workspace()
4330            .map(|workspace| workspace.active_window)
4331            .expect("active window");
4332
4333        model
4334            .set_workspace_window_height(workspace_id, window_id, MIN_WORKSPACE_WINDOW_HEIGHT + 50)
4335            .expect("set height");
4336        model
4337            .create_workspace_window(workspace_id, Direction::Down)
4338            .expect("window created");
4339
4340        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4341        let heights = workspace
4342            .windows
4343            .values()
4344            .map(|window| window.height)
4345            .collect::<Vec<_>>();
4346        assert_eq!(
4347            heights,
4348            vec![MIN_WORKSPACE_WINDOW_HEIGHT, MIN_WORKSPACE_WINDOW_HEIGHT]
4349        );
4350    }
4351
4352    #[test]
4353    fn split_pane_updates_inner_layout_and_focus() {
4354        let mut model = AppModel::new("Main");
4355        let workspace_id = model.active_workspace_id().expect("workspace");
4356        let first_pane = model
4357            .active_workspace()
4358            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
4359            .expect("pane");
4360
4361        let new_pane = model
4362            .split_pane(workspace_id, Some(first_pane), SplitAxis::Vertical)
4363            .expect("split works");
4364        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4365        let active_window = workspace.active_window_record().expect("window");
4366
4367        assert_eq!(workspace.active_pane, new_pane);
4368        assert_eq!(
4369            active_window.active_layout().expect("layout").leaves(),
4370            vec![first_pane, new_pane]
4371        );
4372    }
4373
4374    #[test]
4375    fn split_pane_direction_places_new_pane_on_requested_side() {
4376        let mut model = AppModel::new("Main");
4377        let workspace_id = model.active_workspace_id().expect("workspace");
4378        let first_pane = model
4379            .active_workspace()
4380            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
4381            .expect("pane");
4382
4383        let left_pane = model
4384            .split_pane_direction(workspace_id, Some(first_pane), Direction::Left)
4385            .expect("split left");
4386        let upper_pane = model
4387            .split_pane_direction(workspace_id, Some(first_pane), Direction::Up)
4388            .expect("split up");
4389
4390        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4391        let active_window = workspace.active_window_record().expect("window");
4392
4393        assert_eq!(workspace.active_pane, upper_pane);
4394        assert_eq!(
4395            active_window.active_layout().expect("layout").leaves(),
4396            vec![left_pane, upper_pane, first_pane]
4397        );
4398    }
4399
4400    #[test]
4401    fn directional_focus_prefers_inner_split_before_neighboring_window() {
4402        let mut model = AppModel::new("Main");
4403        let workspace_id = model.active_workspace_id().expect("workspace");
4404        let first_pane = model
4405            .active_workspace()
4406            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
4407            .expect("pane");
4408        let split_right_pane = model
4409            .split_pane(workspace_id, Some(first_pane), SplitAxis::Horizontal)
4410            .expect("split");
4411        let right_window_pane = model
4412            .create_workspace_window(workspace_id, Direction::Right)
4413            .expect("window");
4414        model
4415            .focus_pane(workspace_id, first_pane)
4416            .expect("focus first pane");
4417        model
4418            .focus_pane_direction(workspace_id, Direction::Right)
4419            .expect("move right");
4420
4421        assert_eq!(
4422            model
4423                .workspaces
4424                .get(&workspace_id)
4425                .expect("workspace")
4426                .active_pane,
4427            split_right_pane
4428        );
4429
4430        model
4431            .focus_pane_direction(workspace_id, Direction::Right)
4432            .expect("move right again");
4433        assert_eq!(
4434            model
4435                .workspaces
4436                .get(&workspace_id)
4437                .expect("workspace")
4438                .active_pane,
4439            right_window_pane
4440        );
4441
4442        model
4443            .focus_pane_direction(workspace_id, Direction::Left)
4444            .expect("move left");
4445
4446        assert_eq!(
4447            model
4448                .workspaces
4449                .get(&workspace_id)
4450                .expect("workspace")
4451                .active_pane,
4452            split_right_pane
4453        );
4454    }
4455
4456    #[test]
4457    fn closing_last_pane_in_window_removes_window_and_falls_back() {
4458        let mut model = AppModel::new("Main");
4459        let workspace_id = model.active_workspace_id().expect("workspace");
4460        let right_window_pane = model
4461            .create_workspace_window(workspace_id, Direction::Right)
4462            .expect("window");
4463        let lower_window_pane = model
4464            .create_workspace_window(workspace_id, Direction::Down)
4465            .expect("window");
4466
4467        model
4468            .close_pane(workspace_id, lower_window_pane)
4469            .expect("close pane");
4470
4471        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4472        assert_eq!(workspace.windows.len(), 2);
4473        assert!(!workspace.panes.contains_key(&lower_window_pane));
4474        assert_eq!(workspace.active_pane, right_window_pane);
4475        let right_column = workspace
4476            .columns
4477            .values()
4478            .find(|column| column.window_order.contains(&workspace.active_window))
4479            .expect("right column");
4480        assert_eq!(right_column.window_order.len(), 1);
4481    }
4482
4483    #[test]
4484    fn moving_single_window_column_reorders_columns_and_preserves_width() {
4485        let mut model = AppModel::new("Main");
4486        let workspace_id = model.active_workspace_id().expect("workspace");
4487        let first_window_id = model.active_workspace().expect("workspace").active_window;
4488        let right_window_pane = model
4489            .create_workspace_window(workspace_id, Direction::Right)
4490            .expect("window");
4491        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4492        let right_window_id = workspace
4493            .window_for_pane(right_window_pane)
4494            .expect("right window id");
4495        let left_column_id = workspace
4496            .column_for_window(first_window_id)
4497            .expect("left column");
4498        let right_column_id = workspace
4499            .column_for_window(right_window_id)
4500            .expect("right column");
4501        let _ = workspace;
4502
4503        model
4504            .set_workspace_column_width(
4505                workspace_id,
4506                right_column_id,
4507                DEFAULT_WORKSPACE_WINDOW_WIDTH + 240,
4508            )
4509            .expect("set width");
4510        model
4511            .move_workspace_window(
4512                workspace_id,
4513                right_window_id,
4514                WorkspaceWindowMoveTarget::ColumnBefore {
4515                    column_id: left_column_id,
4516                },
4517            )
4518            .expect("move window");
4519
4520        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4521        let ordered_columns = workspace.columns.values().collect::<Vec<_>>();
4522        assert_eq!(ordered_columns.len(), 2);
4523        assert_eq!(ordered_columns[0].window_order, vec![right_window_id]);
4524        assert_eq!(
4525            ordered_columns[0].width,
4526            DEFAULT_WORKSPACE_WINDOW_WIDTH + 240
4527        );
4528        assert_eq!(ordered_columns[1].window_order, vec![first_window_id]);
4529        assert_eq!(workspace.active_window, right_window_id);
4530    }
4531
4532    #[test]
4533    fn moving_stacked_window_sideways_creates_a_new_column() {
4534        let mut model = AppModel::new("Main");
4535        let workspace_id = model.active_workspace_id().expect("workspace");
4536        let first_window_id = model.active_workspace().expect("workspace").active_window;
4537        let lower_window_pane = model
4538            .create_workspace_window(workspace_id, Direction::Down)
4539            .expect("window");
4540        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4541        let lower_window_id = workspace
4542            .window_for_pane(lower_window_pane)
4543            .expect("lower window id");
4544        let source_column_id = workspace
4545            .column_for_window(first_window_id)
4546            .expect("source column");
4547        let _ = workspace;
4548
4549        model
4550            .set_workspace_column_width(
4551                workspace_id,
4552                source_column_id,
4553                DEFAULT_WORKSPACE_WINDOW_WIDTH + 400,
4554            )
4555            .expect("set width");
4556        model
4557            .move_workspace_window(
4558                workspace_id,
4559                lower_window_id,
4560                WorkspaceWindowMoveTarget::ColumnAfter {
4561                    column_id: source_column_id,
4562                },
4563            )
4564            .expect("move window");
4565
4566        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4567        let ordered_columns = workspace.columns.values().collect::<Vec<_>>();
4568        assert_eq!(ordered_columns.len(), 2);
4569        assert_eq!(ordered_columns[0].window_order, vec![first_window_id]);
4570        assert_eq!(ordered_columns[0].width, 840);
4571        assert_eq!(ordered_columns[1].window_order, vec![lower_window_id]);
4572        assert_eq!(ordered_columns[1].width, 840);
4573        assert_eq!(workspace.active_window, lower_window_id);
4574    }
4575
4576    #[test]
4577    fn moving_window_into_stack_removes_empty_source_column() {
4578        let mut model = AppModel::new("Main");
4579        let workspace_id = model.active_workspace_id().expect("workspace");
4580        let first_window_id = model.active_workspace().expect("workspace").active_window;
4581        let right_window_pane = model
4582            .create_workspace_window(workspace_id, Direction::Right)
4583            .expect("window");
4584        let right_window_id = model
4585            .workspaces
4586            .get(&workspace_id)
4587            .and_then(|workspace| workspace.window_for_pane(right_window_pane))
4588            .expect("right window id");
4589
4590        model
4591            .move_workspace_window(
4592                workspace_id,
4593                right_window_id,
4594                WorkspaceWindowMoveTarget::StackBelow {
4595                    window_id: first_window_id,
4596                },
4597            )
4598            .expect("stack window");
4599
4600        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
4601        let only_column = workspace.columns.values().next().expect("single column");
4602        assert_eq!(workspace.columns.len(), 1);
4603        assert_eq!(
4604            only_column.window_order,
4605            vec![first_window_id, right_window_id]
4606        );
4607        assert_eq!(workspace.active_window, right_window_id);
4608    }
4609
4610    #[test]
4611    fn moving_surface_reorders_pane_without_changing_active_surface() {
4612        let mut model = AppModel::new("Main");
4613        let workspace_id = model.active_workspace_id().expect("workspace");
4614        let pane_id = model.active_workspace().expect("workspace").active_pane;
4615        let first_surface_id = model
4616            .active_workspace()
4617            .and_then(|workspace| workspace.panes.get(&pane_id))
4618            .map(|pane| pane.active_surface)
4619            .expect("surface id");
4620
4621        let second_surface_id = model
4622            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
4623            .expect("second surface");
4624        let third_surface_id = model
4625            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
4626            .expect("third surface");
4627
4628        model
4629            .focus_surface(workspace_id, pane_id, second_surface_id)
4630            .expect("focus second surface");
4631        model
4632            .move_surface(workspace_id, pane_id, second_surface_id, 0)
4633            .expect("move second surface to front");
4634
4635        let pane = model
4636            .active_workspace()
4637            .and_then(|workspace| workspace.panes.get(&pane_id))
4638            .expect("pane");
4639        let order = pane.surface_ids().collect::<Vec<_>>();
4640
4641        assert_eq!(
4642            order,
4643            vec![second_surface_id, first_surface_id, third_surface_id]
4644        );
4645        assert_eq!(pane.active_surface, second_surface_id);
4646    }
4647
4648    #[test]
4649    fn moving_surface_clamps_to_end_of_pane() {
4650        let mut model = AppModel::new("Main");
4651        let workspace_id = model.active_workspace_id().expect("workspace");
4652        let pane_id = model.active_workspace().expect("workspace").active_pane;
4653        let first_surface_id = model
4654            .active_workspace()
4655            .and_then(|workspace| workspace.panes.get(&pane_id))
4656            .map(|pane| pane.active_surface)
4657            .expect("surface id");
4658        let second_surface_id = model
4659            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
4660            .expect("second surface");
4661        let third_surface_id = model
4662            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
4663            .expect("third surface");
4664
4665        model
4666            .move_surface(workspace_id, pane_id, first_surface_id, usize::MAX)
4667            .expect("move first surface to end");
4668
4669        let pane = model
4670            .active_workspace()
4671            .and_then(|workspace| workspace.panes.get(&pane_id))
4672            .expect("pane");
4673        let order = pane.surface_ids().collect::<Vec<_>>();
4674
4675        assert_eq!(
4676            order,
4677            vec![second_surface_id, third_surface_id, first_surface_id]
4678        );
4679    }
4680
4681    #[test]
4682    fn moving_surface_to_current_index_is_a_noop() {
4683        let mut model = AppModel::new("Main");
4684        let workspace_id = model.active_workspace_id().expect("workspace");
4685        let pane_id = model.active_workspace().expect("workspace").active_pane;
4686        let first_surface_id = model
4687            .active_workspace()
4688            .and_then(|workspace| workspace.panes.get(&pane_id))
4689            .map(|pane| pane.active_surface)
4690            .expect("surface id");
4691        let second_surface_id = model
4692            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
4693            .expect("second surface");
4694
4695        model
4696            .move_surface(workspace_id, pane_id, second_surface_id, 1)
4697            .expect("move second surface to current slot");
4698
4699        let pane = model
4700            .active_workspace()
4701            .and_then(|workspace| workspace.panes.get(&pane_id))
4702            .expect("pane");
4703        let order = pane.surface_ids().collect::<Vec<_>>();
4704
4705        assert_eq!(order, vec![first_surface_id, second_surface_id]);
4706        assert_eq!(pane.active_surface, second_surface_id);
4707    }
4708
4709    #[test]
4710    fn transferring_surface_to_another_pane_focuses_target_pane() {
4711        let mut model = AppModel::new("Main");
4712        let workspace_id = model.active_workspace_id().expect("workspace");
4713        let source_pane_id = model.active_workspace().expect("workspace").active_pane;
4714        let target_pane_id = model
4715            .split_pane(workspace_id, Some(source_pane_id), SplitAxis::Horizontal)
4716            .expect("split");
4717        let target_placeholder_id = model
4718            .active_workspace()
4719            .and_then(|workspace| workspace.panes.get(&target_pane_id))
4720            .and_then(|pane| pane.surface_ids().next())
4721            .expect("placeholder");
4722
4723        let first_surface_id = model
4724            .active_workspace()
4725            .and_then(|workspace| workspace.panes.get(&source_pane_id))
4726            .and_then(|pane| pane.surface_ids().next())
4727            .expect("first surface");
4728        let second_surface_id = model
4729            .create_surface(workspace_id, source_pane_id, PaneKind::Browser)
4730            .expect("second surface");
4731
4732        model
4733            .transfer_surface(
4734                workspace_id,
4735                source_pane_id,
4736                second_surface_id,
4737                workspace_id,
4738                target_pane_id,
4739                0,
4740            )
4741            .expect("transfer");
4742
4743        let workspace = model.active_workspace().expect("workspace");
4744        let source_order = workspace
4745            .panes
4746            .get(&source_pane_id)
4747            .expect("source pane")
4748            .surface_ids()
4749            .collect::<Vec<_>>();
4750        let target_pane = workspace.panes.get(&target_pane_id).expect("target pane");
4751        let target_order = target_pane.surface_ids().collect::<Vec<_>>();
4752
4753        assert_eq!(source_order, vec![first_surface_id]);
4754        assert_eq!(target_order, vec![second_surface_id, target_placeholder_id]);
4755        assert_eq!(target_pane.active_surface, second_surface_id);
4756        assert_eq!(workspace.active_pane, target_pane_id);
4757    }
4758
4759    #[test]
4760    fn moving_surface_to_split_from_same_pane_creates_neighbor_pane() {
4761        let mut model = AppModel::new("Main");
4762        let workspace_id = model.active_workspace_id().expect("workspace");
4763        let source_pane_id = model.active_workspace().expect("workspace").active_pane;
4764        let first_surface_id = model
4765            .active_workspace()
4766            .and_then(|workspace| workspace.panes.get(&source_pane_id))
4767            .map(|pane| pane.active_surface)
4768            .expect("first surface");
4769        let moved_surface_id = model
4770            .create_surface(workspace_id, source_pane_id, PaneKind::Browser)
4771            .expect("second surface");
4772
4773        let new_pane_id = model
4774            .move_surface_to_split(
4775                workspace_id,
4776                source_pane_id,
4777                moved_surface_id,
4778                workspace_id,
4779                source_pane_id,
4780                Direction::Right,
4781            )
4782            .expect("move to split");
4783
4784        let workspace = model.active_workspace().expect("workspace");
4785        let window = workspace.active_window_record().expect("window");
4786        let source_pane = workspace.panes.get(&source_pane_id).expect("source pane");
4787        let target_pane = workspace.panes.get(&new_pane_id).expect("new pane");
4788
4789        assert_eq!(
4790            window.active_layout().expect("layout").leaves(),
4791            vec![source_pane_id, new_pane_id]
4792        );
4793        assert_eq!(
4794            source_pane.surface_ids().collect::<Vec<_>>(),
4795            vec![first_surface_id]
4796        );
4797        assert_eq!(
4798            target_pane.surface_ids().collect::<Vec<_>>(),
4799            vec![moved_surface_id]
4800        );
4801        assert_eq!(workspace.active_pane, new_pane_id);
4802        assert_eq!(target_pane.active_surface, moved_surface_id);
4803    }
4804
4805    #[test]
4806    fn moving_surface_to_split_across_windows_closes_empty_source_window() {
4807        let mut model = AppModel::new("Main");
4808        let workspace_id = model.active_workspace_id().expect("workspace");
4809        let source_pane_id = model.active_workspace().expect("workspace").active_pane;
4810        let target_pane_id = model
4811            .create_workspace_window(workspace_id, Direction::Right)
4812            .expect("window");
4813        let target_window_id = model
4814            .workspaces
4815            .get(&workspace_id)
4816            .and_then(|workspace| workspace.window_for_pane(target_pane_id))
4817            .expect("target window");
4818        let moved_surface_id = model
4819            .active_workspace()
4820            .and_then(|workspace| workspace.panes.get(&source_pane_id))
4821            .map(|pane| pane.active_surface)
4822            .expect("surface");
4823
4824        let new_pane_id = model
4825            .move_surface_to_split(
4826                workspace_id,
4827                source_pane_id,
4828                moved_surface_id,
4829                workspace_id,
4830                target_pane_id,
4831                Direction::Left,
4832            )
4833            .expect("move to split");
4834
4835        let workspace = model.active_workspace().expect("workspace");
4836        let target_window = workspace.windows.get(&target_window_id).expect("window");
4837
4838        assert_eq!(workspace.windows.len(), 1);
4839        assert!(!workspace.panes.contains_key(&source_pane_id));
4840        assert_eq!(workspace.active_window, target_window_id);
4841        assert_eq!(workspace.active_pane, new_pane_id);
4842        assert_eq!(
4843            target_window.active_layout().expect("layout").leaves(),
4844            vec![new_pane_id, target_pane_id]
4845        );
4846        assert_eq!(
4847            workspace
4848                .panes
4849                .get(&new_pane_id)
4850                .expect("new pane")
4851                .surface_ids()
4852                .collect::<Vec<_>>(),
4853            vec![moved_surface_id]
4854        );
4855    }
4856
4857    #[test]
4858    fn moving_only_surface_to_split_from_same_pane_is_rejected() {
4859        let mut model = AppModel::new("Main");
4860        let workspace_id = model.active_workspace_id().expect("workspace");
4861        let pane_id = model.active_workspace().expect("workspace").active_pane;
4862        let surface_id = model
4863            .active_workspace()
4864            .and_then(|workspace| workspace.panes.get(&pane_id))
4865            .map(|pane| pane.active_surface)
4866            .expect("surface");
4867
4868        let error = model
4869            .move_surface_to_split(
4870                workspace_id,
4871                pane_id,
4872                surface_id,
4873                workspace_id,
4874                pane_id,
4875                Direction::Right,
4876            )
4877            .expect_err("reject self split of only surface");
4878
4879        assert!(matches!(
4880            error,
4881            DomainError::InvalidOperation("cannot split a pane from its only surface")
4882        ));
4883    }
4884
4885    #[test]
4886    fn moving_surface_to_another_workspace_creates_new_window_and_switches_workspace() {
4887        let mut model = AppModel::new("Main");
4888        let source_workspace_id = model.active_workspace_id().expect("workspace");
4889        let source_pane_id = model.active_workspace().expect("workspace").active_pane;
4890        let first_surface_id = model
4891            .active_workspace()
4892            .and_then(|workspace| workspace.panes.get(&source_pane_id))
4893            .map(|pane| pane.active_surface)
4894            .expect("first surface");
4895        let moved_surface_id = model
4896            .create_surface(source_workspace_id, source_pane_id, PaneKind::Browser)
4897            .expect("second surface");
4898        let target_workspace_id = model.create_workspace("Secondary");
4899
4900        let new_pane_id = model
4901            .move_surface_to_workspace(
4902                source_workspace_id,
4903                source_pane_id,
4904                moved_surface_id,
4905                target_workspace_id,
4906            )
4907            .expect("move to workspace");
4908
4909        let source_workspace = model
4910            .workspaces
4911            .get(&source_workspace_id)
4912            .expect("source workspace");
4913        let target_workspace = model
4914            .workspaces
4915            .get(&target_workspace_id)
4916            .expect("target workspace");
4917        let moved_window_id = target_workspace
4918            .window_for_pane(new_pane_id)
4919            .expect("moved window");
4920
4921        assert_eq!(model.active_workspace_id(), Some(target_workspace_id));
4922        assert_eq!(
4923            source_workspace
4924                .panes
4925                .get(&source_pane_id)
4926                .expect("source pane")
4927                .surface_ids()
4928                .collect::<Vec<_>>(),
4929            vec![first_surface_id]
4930        );
4931        assert_eq!(
4932            target_workspace
4933                .panes
4934                .get(&new_pane_id)
4935                .expect("new pane")
4936                .surface_ids()
4937                .collect::<Vec<_>>(),
4938            vec![moved_surface_id]
4939        );
4940        assert_eq!(target_workspace.active_window, moved_window_id);
4941        assert_eq!(target_workspace.active_pane, new_pane_id);
4942    }
4943
4944    #[test]
4945    fn transferring_surface_to_existing_pane_in_another_workspace_moves_notifications_and_flash() {
4946        let mut model = AppModel::new("Main");
4947        let source_workspace_id = model.active_workspace_id().expect("workspace");
4948        let source_pane_id = model.active_workspace().expect("workspace").active_pane;
4949        let _first_surface_id = model
4950            .active_workspace()
4951            .and_then(|workspace| workspace.panes.get(&source_pane_id))
4952            .map(|pane| pane.active_surface)
4953            .expect("first surface");
4954        let moved_surface_id = model
4955            .create_surface(source_workspace_id, source_pane_id, PaneKind::Browser)
4956            .expect("second surface");
4957        model
4958            .create_agent_notification(
4959                AgentTarget::Surface {
4960                    workspace_id: source_workspace_id,
4961                    pane_id: source_pane_id,
4962                    surface_id: moved_surface_id,
4963                },
4964                SignalKind::Notification,
4965                Some("Needs review".into()),
4966                None,
4967                Some("review-1".into()),
4968                "Check this browser tab".into(),
4969                AttentionState::WaitingInput,
4970            )
4971            .expect("notification");
4972        model
4973            .trigger_surface_flash(source_workspace_id, source_pane_id, moved_surface_id)
4974            .expect("flash");
4975
4976        let target_workspace_id = model.create_workspace("Secondary");
4977        let target_pane_id = model.active_workspace().expect("workspace").active_pane;
4978
4979        model
4980            .transfer_surface(
4981                source_workspace_id,
4982                source_pane_id,
4983                moved_surface_id,
4984                target_workspace_id,
4985                target_pane_id,
4986                usize::MAX,
4987            )
4988            .expect("transfer");
4989
4990        let source_workspace = model
4991            .workspaces
4992            .get(&source_workspace_id)
4993            .expect("source workspace");
4994        let target_workspace = model
4995            .workspaces
4996            .get(&target_workspace_id)
4997            .expect("target workspace");
4998        let target_pane = target_workspace
4999            .panes
5000            .get(&target_pane_id)
5001            .expect("target pane");
5002
5003        assert_eq!(model.active_workspace_id(), Some(target_workspace_id));
5004        assert!(
5005            !source_workspace
5006                .notifications
5007                .iter()
5008                .any(|notification| notification.surface_id == moved_surface_id)
5009        );
5010        assert!(
5011            source_workspace
5012                .surface_flash_tokens
5013                .get(&moved_surface_id)
5014                .is_none()
5015        );
5016        assert!(
5017            target_pane
5018                .surface_ids()
5019                .collect::<Vec<_>>()
5020                .contains(&moved_surface_id)
5021        );
5022        assert_eq!(target_pane.active_surface, moved_surface_id);
5023        assert!(target_workspace.notifications.iter().any(|notification| {
5024            notification.surface_id == moved_surface_id && notification.pane_id == target_pane_id
5025        }));
5026        assert!(
5027            target_workspace
5028                .surface_flash_tokens
5029                .get(&moved_surface_id)
5030                .is_some()
5031        );
5032    }
5033
5034    #[test]
5035    fn transferring_active_surface_normalizes_the_source_pane() {
5036        let mut model = AppModel::new("Main");
5037        let workspace_id = model.active_workspace_id().expect("workspace");
5038        let source_pane_id = model.active_workspace().expect("workspace").active_pane;
5039        let remaining_surface_id = model
5040            .active_workspace()
5041            .and_then(|workspace| workspace.panes.get(&source_pane_id))
5042            .map(|pane| pane.active_surface)
5043            .expect("remaining surface");
5044        let moved_surface_id = model
5045            .create_surface(workspace_id, source_pane_id, PaneKind::Browser)
5046            .expect("second surface");
5047        let target_pane_id = model
5048            .split_pane(workspace_id, Some(source_pane_id), SplitAxis::Horizontal)
5049            .expect("split pane");
5050
5051        model
5052            .transfer_surface(
5053                workspace_id,
5054                source_pane_id,
5055                moved_surface_id,
5056                workspace_id,
5057                target_pane_id,
5058                usize::MAX,
5059            )
5060            .expect("transfer");
5061
5062        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5063        let source_pane = workspace.panes.get(&source_pane_id).expect("source pane");
5064
5065        assert_eq!(source_pane.active_surface, remaining_surface_id);
5066        assert_eq!(
5067            source_pane.active_surface().map(|surface| surface.id),
5068            Some(remaining_surface_id)
5069        );
5070        assert_eq!(
5071            source_pane.surface_ids().collect::<Vec<_>>(),
5072            vec![remaining_surface_id]
5073        );
5074    }
5075
5076    #[test]
5077    fn moving_surface_to_split_in_another_workspace_closes_empty_source_pane() {
5078        let mut model = AppModel::new("Main");
5079        let source_workspace_id = model.active_workspace_id().expect("workspace");
5080        let source_pane_id = model.active_workspace().expect("workspace").active_pane;
5081        let anchor_pane_id = model
5082            .split_pane(
5083                source_workspace_id,
5084                Some(source_pane_id),
5085                SplitAxis::Horizontal,
5086            )
5087            .expect("split source workspace");
5088        model
5089            .focus_pane(source_workspace_id, source_pane_id)
5090            .expect("focus source pane");
5091        let moved_surface_id = model
5092            .active_workspace()
5093            .and_then(|workspace| workspace.panes.get(&source_pane_id))
5094            .map(|pane| pane.active_surface)
5095            .expect("moved surface");
5096
5097        let target_workspace_id = model.create_workspace("Secondary");
5098        let target_pane_id = model.active_workspace().expect("workspace").active_pane;
5099
5100        let new_pane_id = model
5101            .move_surface_to_split(
5102                source_workspace_id,
5103                source_pane_id,
5104                moved_surface_id,
5105                target_workspace_id,
5106                target_pane_id,
5107                Direction::Left,
5108            )
5109            .expect("move to split");
5110
5111        let source_workspace = model
5112            .workspaces
5113            .get(&source_workspace_id)
5114            .expect("source workspace");
5115        let target_workspace = model
5116            .workspaces
5117            .get(&target_workspace_id)
5118            .expect("target workspace");
5119        let target_window_id = target_workspace
5120            .window_for_pane(target_pane_id)
5121            .expect("target window");
5122        let target_window = target_workspace
5123            .windows
5124            .get(&target_window_id)
5125            .expect("target window record");
5126
5127        assert_eq!(model.active_workspace_id(), Some(target_workspace_id));
5128        assert!(!source_workspace.panes.contains_key(&source_pane_id));
5129        assert!(source_workspace.panes.contains_key(&anchor_pane_id));
5130        assert_eq!(
5131            target_window.active_layout().expect("layout").leaves(),
5132            vec![new_pane_id, target_pane_id]
5133        );
5134        assert_eq!(target_workspace.active_pane, new_pane_id);
5135        assert_eq!(
5136            target_workspace
5137                .panes
5138                .get(&new_pane_id)
5139                .expect("new pane")
5140                .surface_ids()
5141                .collect::<Vec<_>>(),
5142            vec![moved_surface_id]
5143        );
5144    }
5145
5146    #[test]
5147    fn transferring_last_surface_closes_the_source_pane() {
5148        let mut model = AppModel::new("Main");
5149        let workspace_id = model.active_workspace_id().expect("workspace");
5150        let source_pane_id = model.active_workspace().expect("workspace").active_pane;
5151        let target_pane_id = model
5152            .split_pane(workspace_id, Some(source_pane_id), SplitAxis::Horizontal)
5153            .expect("split");
5154        let moved_surface_id = model
5155            .active_workspace()
5156            .and_then(|workspace| workspace.panes.get(&source_pane_id))
5157            .and_then(|pane| pane.surface_ids().next())
5158            .expect("surface");
5159
5160        model
5161            .transfer_surface(
5162                workspace_id,
5163                source_pane_id,
5164                moved_surface_id,
5165                workspace_id,
5166                target_pane_id,
5167                usize::MAX,
5168            )
5169            .expect("transfer");
5170
5171        let workspace = model.active_workspace().expect("workspace");
5172        assert!(!workspace.panes.contains_key(&source_pane_id));
5173        let target_order = workspace
5174            .panes
5175            .get(&target_pane_id)
5176            .expect("target pane")
5177            .surface_ids()
5178            .collect::<Vec<_>>();
5179        assert!(target_order.contains(&moved_surface_id));
5180        assert_eq!(workspace.active_pane, target_pane_id);
5181    }
5182
5183    #[test]
5184    fn closing_surface_after_reorder_removes_the_requested_surface() {
5185        let mut model = AppModel::new("Main");
5186        let workspace_id = model.active_workspace_id().expect("workspace");
5187        let pane_id = model.active_workspace().expect("workspace").active_pane;
5188        let first_surface_id = model
5189            .active_workspace()
5190            .and_then(|workspace| workspace.panes.get(&pane_id))
5191            .map(|pane| pane.active_surface)
5192            .expect("surface id");
5193        let second_surface_id = model
5194            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
5195            .expect("second surface");
5196        let third_surface_id = model
5197            .create_surface(workspace_id, pane_id, PaneKind::Terminal)
5198            .expect("third surface");
5199
5200        model
5201            .move_surface(workspace_id, pane_id, first_surface_id, 2)
5202            .expect("move first surface to end");
5203        model
5204            .close_surface(workspace_id, pane_id, second_surface_id)
5205            .expect("close second surface");
5206
5207        let pane = model
5208            .active_workspace()
5209            .and_then(|workspace| workspace.panes.get(&pane_id))
5210            .expect("pane");
5211        let order = pane.surface_ids().collect::<Vec<_>>();
5212
5213        assert_eq!(order, vec![third_surface_id, first_surface_id]);
5214        assert!(!order.contains(&second_surface_id));
5215    }
5216
5217    #[test]
5218    fn resizing_window_and_split_updates_state() {
5219        let mut model = AppModel::new("Main");
5220        let workspace_id = model.active_workspace_id().expect("workspace");
5221        let first_pane = model
5222            .active_workspace()
5223            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
5224            .expect("pane");
5225        let second_pane = model
5226            .split_pane(workspace_id, Some(first_pane), SplitAxis::Horizontal)
5227            .expect("split");
5228
5229        model
5230            .focus_pane(workspace_id, second_pane)
5231            .expect("focus second pane");
5232        model
5233            .resize_active_pane_split(workspace_id, Direction::Right, 60)
5234            .expect("resize split");
5235        model
5236            .resize_active_window(workspace_id, Direction::Right, 120)
5237            .expect("resize window");
5238        model
5239            .resize_active_window(workspace_id, Direction::Down, 90)
5240            .expect("resize height");
5241
5242        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5243        let window = workspace.active_window_record().expect("window");
5244        let column = workspace
5245            .active_column_id()
5246            .and_then(|column_id| workspace.columns.get(&column_id))
5247            .expect("column");
5248        let LayoutNode::Split { ratio, .. } = window.active_layout().expect("layout") else {
5249            panic!("expected split layout");
5250        };
5251        assert_eq!(*ratio, 440);
5252        assert_eq!(column.width, DEFAULT_WORKSPACE_WINDOW_WIDTH + 120);
5253        assert_eq!(window.height, DEFAULT_WORKSPACE_WINDOW_HEIGHT + 90);
5254    }
5255
5256    #[test]
5257    fn clean_break_rejects_legacy_workspace_layouts() {
5258        let workspace_id = WorkspaceId::new();
5259        let window_id = WindowId::new();
5260        let left_pane = PaneRecord::new(PaneKind::Terminal);
5261        let right_pane = PaneRecord::new(PaneKind::Terminal);
5262
5263        let encoded = json!({
5264            "schema_version": 1,
5265            "captured_at": OffsetDateTime::now_utc(),
5266            "model": {
5267                "active_window": window_id,
5268                "windows": {
5269                    window_id.to_string(): {
5270                        "id": window_id,
5271                        "workspace_order": [workspace_id],
5272                        "active_workspace": workspace_id
5273                    }
5274                },
5275                "workspaces": {
5276                    workspace_id.to_string(): {
5277                        "id": workspace_id,
5278                        "label": "Main",
5279                        "layout": {
5280                            "kind": "scrollable_tiling",
5281                            "columns": [
5282                                {"panes": [left_pane.id]},
5283                                {"panes": [right_pane.id]}
5284                            ],
5285                            "viewport": {"x": 64, "y": 24}
5286                        },
5287                        "panes": {
5288                            left_pane.id.to_string(): left_pane,
5289                            right_pane.id.to_string(): right_pane
5290                        },
5291                        "active_pane": right_pane.id,
5292                        "notifications": []
5293                    }
5294                }
5295            }
5296        });
5297
5298        let decoded = serde_json::from_value::<PersistedSession>(encoded);
5299        assert!(decoded.is_err());
5300    }
5301
5302    #[test]
5303    fn signals_flow_into_activity_and_summary() {
5304        let mut model = AppModel::new("Main");
5305        let workspace_id = model.active_workspace_id().expect("workspace");
5306        let pane_id = model
5307            .active_workspace()
5308            .and_then(|workspace| workspace.panes.first().map(|(pane_id, _)| *pane_id))
5309            .expect("pane");
5310
5311        model
5312            .apply_signal(
5313                workspace_id,
5314                pane_id,
5315                SignalEvent::new(
5316                    "test",
5317                    SignalKind::WaitingInput,
5318                    Some("Need approval".into()),
5319                ),
5320            )
5321            .expect("signal applied");
5322
5323        let summaries = model
5324            .workspace_summaries(model.active_window)
5325            .expect("summary available");
5326        let summary = summaries.first().expect("summary");
5327
5328        assert_eq!(summary.highest_attention, AttentionState::WaitingInput);
5329        assert_eq!(
5330            summary
5331                .counts_by_attention
5332                .get(&AttentionState::WaitingInput)
5333                .copied(),
5334            Some(1)
5335        );
5336        assert_eq!(model.activity_items().len(), 1);
5337    }
5338
5339    #[test]
5340    fn persisted_session_roundtrips() {
5341        let model = AppModel::demo();
5342        let snapshot = model.snapshot();
5343        let encoded = serde_json::to_string_pretty(&snapshot).expect("serialize");
5344        let decoded: PersistedSession = serde_json::from_str(&encoded).expect("deserialize");
5345
5346        assert_eq!(decoded.schema_version, SESSION_SCHEMA_VERSION);
5347        assert_eq!(decoded.model.workspaces.len(), model.workspaces.len());
5348    }
5349
5350    #[test]
5351    fn notification_signal_maps_to_waiting_attention() {
5352        let mut model = AppModel::new("Main");
5353        let workspace_id = model.active_workspace_id().expect("workspace");
5354        let pane_id = model.active_workspace().expect("workspace").active_pane;
5355
5356        model
5357            .apply_signal(
5358                workspace_id,
5359                pane_id,
5360                SignalEvent::new(
5361                    "notify:Codex",
5362                    SignalKind::Notification,
5363                    Some("Turn complete".into()),
5364                ),
5365            )
5366            .expect("signal applied");
5367
5368        let surface = model
5369            .active_workspace()
5370            .and_then(|workspace| workspace.panes.get(&pane_id))
5371            .and_then(PaneRecord::active_surface)
5372            .expect("surface");
5373        assert_eq!(surface.attention, AttentionState::WaitingInput);
5374    }
5375
5376    #[test]
5377    fn agent_hook_notification_updates_context_without_creating_attention_item() {
5378        let mut model = AppModel::new("Main");
5379        let workspace_id = model.active_workspace_id().expect("workspace");
5380        let pane_id = model.active_workspace().expect("workspace").active_pane;
5381
5382        model
5383            .apply_signal(
5384                workspace_id,
5385                pane_id,
5386                SignalEvent::with_metadata(
5387                    "agent-hook:codex",
5388                    SignalKind::Notification,
5389                    Some("Turn complete".into()),
5390                    Some(SignalPaneMetadata {
5391                        title: None,
5392                        agent_title: Some("Codex".into()),
5393                        cwd: None,
5394                        repo_name: None,
5395                        git_branch: None,
5396                        ports: Vec::new(),
5397                        agent_kind: Some("codex".into()),
5398                        agent_active: Some(true),
5399                    }),
5400                ),
5401            )
5402            .expect("notification applied");
5403
5404        let workspace = model.active_workspace().expect("workspace");
5405        let surface = workspace
5406            .panes
5407            .get(&pane_id)
5408            .and_then(PaneRecord::active_surface)
5409            .expect("surface");
5410        assert_eq!(surface.attention, AttentionState::WaitingInput);
5411        assert_eq!(
5412            surface.metadata.latest_agent_message.as_deref(),
5413            Some("Turn complete")
5414        );
5415        assert_eq!(
5416            surface.metadata.agent_state,
5417            Some(WorkspaceAgentState::Waiting)
5418        );
5419        assert!(workspace.notifications.is_empty());
5420    }
5421
5422    #[test]
5423    fn stop_signal_uses_cached_agent_message_for_final_notification() {
5424        let mut model = AppModel::new("Main");
5425        let workspace_id = model.active_workspace_id().expect("workspace");
5426        let pane_id = model.active_workspace().expect("workspace").active_pane;
5427
5428        model
5429            .apply_signal(
5430                workspace_id,
5431                pane_id,
5432                SignalEvent::with_metadata(
5433                    "agent-hook:codex",
5434                    SignalKind::Notification,
5435                    Some("Turn complete".into()),
5436                    Some(SignalPaneMetadata {
5437                        title: None,
5438                        agent_title: Some("Codex".into()),
5439                        cwd: None,
5440                        repo_name: None,
5441                        git_branch: None,
5442                        ports: Vec::new(),
5443                        agent_kind: Some("codex".into()),
5444                        agent_active: Some(true),
5445                    }),
5446                ),
5447            )
5448            .expect("notification applied");
5449
5450        model
5451            .apply_signal(
5452                workspace_id,
5453                pane_id,
5454                SignalEvent::with_metadata(
5455                    "agent-hook:codex",
5456                    SignalKind::Completed,
5457                    None,
5458                    Some(SignalPaneMetadata {
5459                        title: None,
5460                        agent_title: Some("Codex".into()),
5461                        cwd: None,
5462                        repo_name: None,
5463                        git_branch: None,
5464                        ports: Vec::new(),
5465                        agent_kind: Some("codex".into()),
5466                        agent_active: Some(false),
5467                    }),
5468                ),
5469            )
5470            .expect("completed applied");
5471
5472        let workspace = model.active_workspace().expect("workspace");
5473        let notification = workspace
5474            .notifications
5475            .last()
5476            .expect("completion notification");
5477        assert_eq!(notification.kind, SignalKind::Completed);
5478        assert_eq!(notification.state, AttentionState::Completed);
5479        assert_eq!(notification.message, "Turn complete");
5480        assert_eq!(notification.title.as_deref(), Some("Codex"));
5481
5482        let surface = workspace
5483            .panes
5484            .get(&pane_id)
5485            .and_then(PaneRecord::active_surface)
5486            .expect("surface");
5487        let session = surface
5488            .agent_session
5489            .as_ref()
5490            .expect("completed signal should preserve recent agent session");
5491        assert_eq!(
5492            surface.metadata.agent_state,
5493            Some(WorkspaceAgentState::Completed)
5494        );
5495        assert!(!surface.metadata.agent_active);
5496        assert_eq!(session.state, WorkspaceAgentState::Completed);
5497        assert_eq!(session.latest_message.as_deref(), Some("Turn complete"));
5498    }
5499
5500    #[test]
5501    fn progress_signals_update_attention_without_creating_activity_items() {
5502        let mut model = AppModel::new("Main");
5503        let workspace_id = model.active_workspace_id().expect("workspace");
5504        let pane_id = model.active_workspace().expect("workspace").active_pane;
5505
5506        model
5507            .update_pane_metadata(
5508                pane_id,
5509                PaneMetadataPatch {
5510                    title: Some("Codex".into()),
5511                    cwd: None,
5512                    url: None,
5513                    repo_name: None,
5514                    git_branch: None,
5515                    ports: None,
5516                    agent_kind: Some("codex".into()),
5517                },
5518            )
5519            .expect("metadata updated");
5520        model
5521            .apply_signal(
5522                workspace_id,
5523                pane_id,
5524                SignalEvent::new("test", SignalKind::Progress, Some("Still working".into())),
5525            )
5526            .expect("signal applied");
5527
5528        let surface = model
5529            .active_workspace()
5530            .and_then(|workspace| workspace.panes.get(&pane_id))
5531            .and_then(PaneRecord::active_surface)
5532            .expect("surface");
5533
5534        assert_eq!(surface.attention, AttentionState::Busy);
5535        assert!(model.activity_items().is_empty());
5536    }
5537
5538    #[test]
5539    fn started_signals_do_not_create_attention_items() {
5540        let mut model = AppModel::new("Main");
5541        let workspace_id = model.active_workspace_id().expect("workspace");
5542        let pane_id = model.active_workspace().expect("workspace").active_pane;
5543
5544        model
5545            .apply_signal(
5546                workspace_id,
5547                pane_id,
5548                SignalEvent::with_metadata(
5549                    "agent-hook:codex",
5550                    SignalKind::Started,
5551                    None,
5552                    Some(SignalPaneMetadata {
5553                        title: None,
5554                        agent_title: Some("Codex".into()),
5555                        cwd: None,
5556                        repo_name: None,
5557                        git_branch: None,
5558                        ports: Vec::new(),
5559                        agent_kind: Some("codex".into()),
5560                        agent_active: Some(true),
5561                    }),
5562                ),
5563            )
5564            .expect("started applied");
5565
5566        let surface = model
5567            .active_workspace()
5568            .and_then(|workspace| workspace.panes.get(&pane_id))
5569            .and_then(PaneRecord::active_surface)
5570            .expect("surface");
5571
5572        assert_eq!(surface.attention, AttentionState::Busy);
5573        assert!(model.activity_items().is_empty());
5574    }
5575
5576    #[test]
5577    fn metadata_signals_for_shell_prompt_clear_stale_agent_identity() {
5578        let mut model = AppModel::new("Main");
5579        let workspace_id = model.active_workspace_id().expect("workspace");
5580        let pane_id = model.active_workspace().expect("workspace").active_pane;
5581        let stale_timestamp = OffsetDateTime::now_utc() - Duration::minutes(20);
5582
5583        model
5584            .apply_signal(
5585                workspace_id,
5586                pane_id,
5587                SignalEvent {
5588                    source: "test".into(),
5589                    kind: SignalKind::Completed,
5590                    message: Some("Done".into()),
5591                    metadata: Some(SignalPaneMetadata {
5592                        title: None,
5593                        agent_title: Some("Codex".into()),
5594                        cwd: None,
5595                        repo_name: None,
5596                        git_branch: None,
5597                        ports: Vec::new(),
5598                        agent_kind: Some("codex".into()),
5599                        agent_active: Some(false),
5600                    }),
5601                    timestamp: stale_timestamp,
5602                },
5603            )
5604            .expect("completed signal applied");
5605
5606        model
5607            .apply_signal(
5608                workspace_id,
5609                pane_id,
5610                SignalEvent::with_metadata(
5611                    "test",
5612                    SignalKind::Metadata,
5613                    None,
5614                    Some(SignalPaneMetadata {
5615                        title: Some("taskers".into()),
5616                        agent_title: None,
5617                        cwd: Some("/tmp".into()),
5618                        repo_name: Some("taskers".into()),
5619                        git_branch: Some("main".into()),
5620                        ports: Vec::new(),
5621                        agent_kind: Some("shell".into()),
5622                        agent_active: Some(false),
5623                    }),
5624                ),
5625            )
5626            .expect("metadata signal applied");
5627
5628        let summaries = model
5629            .workspace_summaries(model.active_window)
5630            .expect("workspace summaries");
5631        assert!(
5632            summaries
5633                .first()
5634                .expect("summary")
5635                .agent_summaries
5636                .is_empty()
5637        );
5638
5639        let surface = model
5640            .active_workspace()
5641            .and_then(|workspace| workspace.panes.get(&pane_id))
5642            .and_then(PaneRecord::active_surface)
5643            .expect("surface");
5644        assert_eq!(surface.metadata.agent_kind, None);
5645        assert_eq!(surface.metadata.agent_title, None);
5646        assert_eq!(surface.metadata.agent_state, None);
5647        assert_eq!(surface.metadata.latest_agent_message, None);
5648        assert_eq!(surface.attention, AttentionState::Normal);
5649        assert_eq!(surface.metadata.last_signal_at, None);
5650    }
5651
5652    #[test]
5653    fn marking_surface_completed_clears_activity_and_keeps_recent_completed_status() {
5654        let mut model = AppModel::new("Main");
5655        let workspace_id = model.active_workspace_id().expect("workspace");
5656        let pane_id = model.active_workspace().expect("workspace").active_pane;
5657        let surface_id = model
5658            .active_workspace()
5659            .and_then(|workspace| workspace.panes.get(&pane_id))
5660            .map(|pane| pane.active_surface)
5661            .expect("surface id");
5662
5663        model
5664            .apply_signal(
5665                workspace_id,
5666                pane_id,
5667                SignalEvent::with_metadata(
5668                    "test",
5669                    SignalKind::WaitingInput,
5670                    Some("Need review".into()),
5671                    Some(SignalPaneMetadata {
5672                        title: None,
5673                        agent_title: Some("Codex".into()),
5674                        cwd: None,
5675                        repo_name: None,
5676                        git_branch: None,
5677                        ports: Vec::new(),
5678                        agent_kind: Some("codex".into()),
5679                        agent_active: Some(true),
5680                    }),
5681                ),
5682            )
5683            .expect("waiting signal applied");
5684
5685        assert_eq!(model.activity_items().len(), 1);
5686
5687        model
5688            .mark_surface_completed(workspace_id, pane_id, surface_id)
5689            .expect("mark completed");
5690
5691        let surface = model
5692            .active_workspace()
5693            .and_then(|workspace| workspace.panes.get(&pane_id))
5694            .and_then(PaneRecord::active_surface)
5695            .expect("surface");
5696        assert_eq!(surface.attention, AttentionState::Normal);
5697        assert!(model.activity_items().is_empty());
5698
5699        let summaries = model
5700            .workspace_summaries(model.active_window)
5701            .expect("workspace summaries");
5702        assert_eq!(
5703            summaries
5704                .first()
5705                .and_then(|summary| summary.agent_summaries.first())
5706                .map(|summary| summary.state),
5707            None
5708        );
5709    }
5710
5711    #[test]
5712    fn metadata_inactive_resolves_waiting_agent_state() {
5713        let mut model = AppModel::new("Main");
5714        let workspace_id = model.active_workspace_id().expect("workspace");
5715        let pane_id = model.active_workspace().expect("workspace").active_pane;
5716
5717        model
5718            .apply_signal(
5719                workspace_id,
5720                pane_id,
5721                SignalEvent::with_metadata(
5722                    "test",
5723                    SignalKind::WaitingInput,
5724                    Some("Need input".into()),
5725                    Some(SignalPaneMetadata {
5726                        title: None,
5727                        agent_title: Some("Codex".into()),
5728                        cwd: None,
5729                        repo_name: None,
5730                        git_branch: None,
5731                        ports: Vec::new(),
5732                        agent_kind: Some("codex".into()),
5733                        agent_active: Some(true),
5734                    }),
5735                ),
5736            )
5737            .expect("waiting signal applied");
5738
5739        model
5740            .apply_signal(
5741                workspace_id,
5742                pane_id,
5743                SignalEvent::with_metadata(
5744                    "test",
5745                    SignalKind::Metadata,
5746                    None,
5747                    Some(SignalPaneMetadata {
5748                        title: Some("codex :: taskers".into()),
5749                        agent_title: None,
5750                        cwd: Some("/tmp".into()),
5751                        repo_name: Some("taskers".into()),
5752                        git_branch: Some("main".into()),
5753                        ports: Vec::new(),
5754                        agent_kind: Some("codex".into()),
5755                        agent_active: Some(false),
5756                    }),
5757                ),
5758            )
5759            .expect("metadata signal applied");
5760
5761        let workspace = model.active_workspace().expect("workspace");
5762        let surface = workspace
5763            .panes
5764            .get(&pane_id)
5765            .and_then(PaneRecord::active_surface)
5766            .expect("surface");
5767        assert_eq!(surface.attention, AttentionState::Normal);
5768        assert!(surface.agent_session.is_none());
5769        assert!(!surface.metadata.agent_active);
5770        assert_eq!(surface.metadata.agent_state, None);
5771        assert_eq!(surface.metadata.latest_agent_message, None);
5772        assert_eq!(surface.metadata.last_signal_at, None);
5773        assert!(
5774            workspace
5775                .notifications
5776                .iter()
5777                .all(|item| item.cleared_at.is_some())
5778        );
5779
5780        let summaries = model
5781            .workspace_summaries(model.active_window)
5782            .expect("workspace summaries");
5783        assert_eq!(
5784            summaries
5785                .first()
5786                .and_then(|summary| summary.agent_summaries.first())
5787                .map(|summary| summary.state),
5788            None
5789        );
5790    }
5791
5792    #[test]
5793    fn focusing_waiting_agent_does_not_clear_attention_item() {
5794        let mut model = AppModel::new("Main");
5795        let workspace_id = model.active_workspace_id().expect("workspace");
5796        let window_id = model.active_window;
5797        let pane_id = model.active_workspace().expect("workspace").active_pane;
5798        let surface_id = model
5799            .active_workspace()
5800            .and_then(|workspace| workspace.panes.get(&pane_id))
5801            .map(|pane| pane.active_surface)
5802            .expect("surface id");
5803
5804        model
5805            .apply_signal(
5806                workspace_id,
5807                pane_id,
5808                SignalEvent::with_metadata(
5809                    "test",
5810                    SignalKind::WaitingInput,
5811                    Some("Need review".into()),
5812                    Some(SignalPaneMetadata {
5813                        title: None,
5814                        agent_title: Some("Codex".into()),
5815                        cwd: None,
5816                        repo_name: None,
5817                        git_branch: None,
5818                        ports: Vec::new(),
5819                        agent_kind: Some("codex".into()),
5820                        agent_active: Some(true),
5821                    }),
5822                ),
5823            )
5824            .expect("waiting signal applied");
5825
5826        let other_workspace_id = model.create_workspace("Docs");
5827        assert_eq!(model.activity_items().len(), 1);
5828
5829        model
5830            .switch_workspace(window_id, workspace_id)
5831            .expect("switch back to waiting workspace");
5832        model
5833            .focus_surface(workspace_id, pane_id, surface_id)
5834            .expect("focus waiting surface");
5835
5836        let activity_items = model.activity_items();
5837        assert_eq!(activity_items.len(), 1);
5838        assert_eq!(activity_items[0].state, AttentionState::WaitingInput);
5839        assert_eq!(
5840            model
5841                .workspaces
5842                .get(&workspace_id)
5843                .expect("workspace")
5844                .notifications
5845                .iter()
5846                .filter(|item| item.cleared_at.is_none())
5847                .count(),
5848            1
5849        );
5850        assert_eq!(model.active_workspace_id(), Some(workspace_id));
5851        assert_ne!(workspace_id, other_workspace_id);
5852    }
5853
5854    #[test]
5855    fn workspace_agent_state_flows_into_summary_and_logs_are_bounded() {
5856        let mut model = AppModel::new("Main");
5857        let workspace_id = model.active_workspace_id().expect("workspace");
5858
5859        model
5860            .set_workspace_status(workspace_id, "Running import".into())
5861            .expect("set status");
5862        model
5863            .set_workspace_progress(
5864                workspace_id,
5865                ProgressState {
5866                    value: 420,
5867                    label: Some("42%".into()),
5868                },
5869            )
5870            .expect("set progress");
5871
5872        for index in 0..205 {
5873            model
5874                .append_workspace_log(
5875                    workspace_id,
5876                    WorkspaceLogEntry {
5877                        source: Some("codex".into()),
5878                        message: format!("log {index}"),
5879                        created_at: OffsetDateTime::now_utc(),
5880                    },
5881                )
5882                .expect("append log");
5883        }
5884
5885        let summary = model
5886            .workspace_summaries(model.active_window)
5887            .expect("workspace summaries")
5888            .into_iter()
5889            .find(|summary| summary.workspace_id == workspace_id)
5890            .expect("workspace summary");
5891        let workspace = model.workspaces.get(&workspace_id).expect("workspace");
5892
5893        assert_eq!(summary.status_text.as_deref(), Some("Running import"));
5894        assert_eq!(
5895            workspace.progress.as_ref().map(|progress| progress.value),
5896            Some(420)
5897        );
5898        assert_eq!(workspace.log_entries.len(), 200);
5899        assert_eq!(
5900            workspace
5901                .log_entries
5902                .first()
5903                .map(|entry| entry.message.as_str()),
5904            Some("log 5")
5905        );
5906        assert_eq!(
5907            workspace
5908                .log_entries
5909                .last()
5910                .map(|entry| entry.message.as_str()),
5911            Some("log 204")
5912        );
5913    }
5914
5915    #[test]
5916    fn focusing_latest_unread_prefers_newest_notification_in_active_window() {
5917        let mut model = AppModel::new("Main");
5918        let workspace_id = model.active_workspace_id().expect("workspace");
5919        let pane_id = model.active_workspace().expect("workspace").active_pane;
5920        let surface_id = model
5921            .active_workspace()
5922            .and_then(|workspace| workspace.panes.get(&pane_id))
5923            .map(|pane| pane.active_surface)
5924            .expect("surface");
5925        let second_workspace_id = model.create_workspace("Secondary");
5926
5927        model
5928            .create_agent_notification(
5929                AgentTarget::Surface {
5930                    workspace_id,
5931                    pane_id,
5932                    surface_id,
5933                },
5934                SignalKind::Notification,
5935                Some("Older".into()),
5936                None,
5937                None,
5938                "First".into(),
5939                AttentionState::WaitingInput,
5940            )
5941            .expect("older notification");
5942        std::thread::sleep(std::time::Duration::from_millis(2));
5943        model
5944            .create_agent_notification(
5945                AgentTarget::Workspace {
5946                    workspace_id: second_workspace_id,
5947                },
5948                SignalKind::Notification,
5949                Some("Newest".into()),
5950                None,
5951                None,
5952                "Second".into(),
5953                AttentionState::WaitingInput,
5954            )
5955            .expect("newer notification");
5956
5957        let focused = model
5958            .focus_latest_unread(model.active_window)
5959            .expect("focus latest unread");
5960
5961        assert!(focused);
5962        assert_eq!(model.active_workspace_id(), Some(second_workspace_id));
5963    }
5964
5965    #[test]
5966    fn opening_notification_marks_it_read_without_clearing() {
5967        let mut model = AppModel::new("Main");
5968        let workspace_id = model.active_workspace_id().expect("workspace");
5969        let pane_id = model.active_workspace().expect("workspace").active_pane;
5970        let surface_id = model
5971            .active_workspace()
5972            .and_then(|workspace| workspace.panes.get(&pane_id))
5973            .and_then(|pane| pane.active_surface())
5974            .map(|surface| surface.id)
5975            .expect("surface");
5976
5977        model
5978            .create_agent_notification(
5979                AgentTarget::Surface {
5980                    workspace_id,
5981                    pane_id,
5982                    surface_id,
5983                },
5984                SignalKind::Notification,
5985                Some("Heads up".into()),
5986                None,
5987                None,
5988                "Review needed".into(),
5989                AttentionState::WaitingInput,
5990            )
5991            .expect("notification");
5992
5993        let notification_id = model
5994            .active_workspace()
5995            .and_then(|workspace| workspace.notifications.last())
5996            .map(|notification| notification.id)
5997            .expect("notification id");
5998
5999        model
6000            .open_notification(model.active_window, notification_id)
6001            .expect("open notification");
6002
6003        let notification = model
6004            .active_workspace()
6005            .and_then(|workspace| {
6006                workspace
6007                    .notifications
6008                    .iter()
6009                    .find(|notification| notification.id == notification_id)
6010            })
6011            .expect("notification");
6012        assert!(notification.read_at.is_some());
6013        assert!(notification.cleared_at.is_none());
6014        assert_eq!(model.activity_items().len(), 1);
6015        assert!(
6016            model
6017                .activity_items()
6018                .iter()
6019                .all(|item| item.read_at.is_some())
6020        );
6021    }
6022
6023    #[test]
6024    fn agent_notifications_do_not_create_live_agent_sessions() {
6025        let mut model = AppModel::new("Main");
6026        let workspace_id = model.active_workspace_id().expect("workspace");
6027        let pane_id = model.active_workspace().expect("workspace").active_pane;
6028        let surface_id = model
6029            .active_workspace()
6030            .and_then(|workspace| workspace.panes.get(&pane_id))
6031            .and_then(|pane| pane.active_surface())
6032            .map(|surface| surface.id)
6033            .expect("surface");
6034
6035        model
6036            .create_agent_notification(
6037                AgentTarget::Surface {
6038                    workspace_id,
6039                    pane_id,
6040                    surface_id,
6041                },
6042                SignalKind::Notification,
6043                Some("Codex".into()),
6044                None,
6045                None,
6046                "Need input".into(),
6047                AttentionState::WaitingInput,
6048            )
6049            .expect("notification");
6050
6051        let surface = model
6052            .active_workspace()
6053            .and_then(|workspace| workspace.panes.get(&pane_id))
6054            .and_then(|pane| pane.surfaces.get(&surface_id))
6055            .expect("surface record");
6056        assert!(surface.agent_session.is_none());
6057        assert_eq!(surface.metadata.agent_kind.as_deref(), Some("codex"));
6058        assert_eq!(surface.metadata.agent_title.as_deref(), Some("Codex"));
6059        assert_eq!(surface.metadata.agent_state, None);
6060        assert!(!surface.metadata.agent_active);
6061        assert_eq!(
6062            surface.metadata.latest_agent_message.as_deref(),
6063            Some("Need input")
6064        );
6065        assert_eq!(surface.attention, AttentionState::WaitingInput);
6066    }
6067
6068    #[test]
6069    fn clearing_notification_moves_it_out_of_active_activity() {
6070        let mut model = AppModel::new("Main");
6071        let workspace_id = model.active_workspace_id().expect("workspace");
6072        let pane_id = model.active_workspace().expect("workspace").active_pane;
6073        let surface_id = model
6074            .active_workspace()
6075            .and_then(|workspace| workspace.panes.get(&pane_id))
6076            .and_then(|pane| pane.active_surface())
6077            .map(|surface| surface.id)
6078            .expect("surface");
6079
6080        model
6081            .create_agent_notification(
6082                AgentTarget::Surface {
6083                    workspace_id,
6084                    pane_id,
6085                    surface_id,
6086                },
6087                SignalKind::Notification,
6088                Some("Heads up".into()),
6089                None,
6090                None,
6091                "Review needed".into(),
6092                AttentionState::WaitingInput,
6093            )
6094            .expect("notification");
6095
6096        let notification_id = model
6097            .active_workspace()
6098            .and_then(|workspace| workspace.notifications.last())
6099            .map(|notification| notification.id)
6100            .expect("notification id");
6101
6102        model
6103            .clear_notification(notification_id)
6104            .expect("clear notification");
6105
6106        assert!(model.activity_items().is_empty());
6107        let notification = model
6108            .active_workspace()
6109            .and_then(|workspace| {
6110                workspace
6111                    .notifications
6112                    .iter()
6113                    .find(|notification| notification.id == notification_id)
6114            })
6115            .expect("notification");
6116        assert!(notification.read_at.is_some());
6117        assert!(notification.cleared_at.is_some());
6118        let surface = model
6119            .active_workspace()
6120            .and_then(|workspace| workspace.panes.get(&pane_id))
6121            .and_then(|pane| pane.surfaces.get(&surface_id))
6122            .expect("surface");
6123        assert_eq!(surface.attention, AttentionState::Normal);
6124    }
6125
6126    #[test]
6127    fn clearing_notification_keeps_surface_attention_when_another_alert_is_still_active() {
6128        let mut model = AppModel::new("Main");
6129        let workspace_id = model.active_workspace_id().expect("workspace");
6130        let pane_id = model.active_workspace().expect("workspace").active_pane;
6131        let surface_id = model
6132            .active_workspace()
6133            .and_then(|workspace| workspace.panes.get(&pane_id))
6134            .and_then(|pane| pane.active_surface())
6135            .map(|surface| surface.id)
6136            .expect("surface");
6137
6138        model
6139            .create_agent_notification(
6140                AgentTarget::Surface {
6141                    workspace_id,
6142                    pane_id,
6143                    surface_id,
6144                },
6145                SignalKind::Notification,
6146                Some("Heads up".into()),
6147                None,
6148                None,
6149                "Review needed".into(),
6150                AttentionState::WaitingInput,
6151            )
6152            .expect("waiting notification");
6153        model
6154            .create_agent_notification(
6155                AgentTarget::Surface {
6156                    workspace_id,
6157                    pane_id,
6158                    surface_id,
6159                },
6160                SignalKind::Error,
6161                Some("Heads up".into()),
6162                None,
6163                None,
6164                "Build failed".into(),
6165                AttentionState::Error,
6166            )
6167            .expect("error notification");
6168
6169        let first_notification_id = model
6170            .active_workspace()
6171            .and_then(|workspace| workspace.notifications.first())
6172            .map(|notification| notification.id)
6173            .expect("notification id");
6174
6175        model
6176            .clear_notification(first_notification_id)
6177            .expect("clear notification");
6178
6179        let surface = model
6180            .active_workspace()
6181            .and_then(|workspace| workspace.panes.get(&pane_id))
6182            .and_then(|pane| pane.surfaces.get(&surface_id))
6183            .expect("surface");
6184        assert_eq!(surface.attention, AttentionState::Error);
6185    }
6186
6187    #[test]
6188    fn dismiss_surface_alert_clears_completed_agent_presentation() {
6189        let mut model = AppModel::new("Main");
6190        let workspace_id = model.active_workspace_id().expect("workspace");
6191        let pane_id = model.active_workspace().expect("workspace").active_pane;
6192        let surface_id = model
6193            .active_workspace()
6194            .and_then(|workspace| workspace.panes.get(&pane_id))
6195            .and_then(|pane| pane.active_surface())
6196            .map(|surface| surface.id)
6197            .expect("surface");
6198
6199        model
6200            .create_agent_notification(
6201                AgentTarget::Surface {
6202                    workspace_id,
6203                    pane_id,
6204                    surface_id,
6205                },
6206                SignalKind::Completed,
6207                Some("Codex".into()),
6208                None,
6209                None,
6210                "Finished".into(),
6211                AttentionState::Completed,
6212            )
6213            .expect("completed notification");
6214
6215        model
6216            .dismiss_surface_alert(workspace_id, pane_id, surface_id)
6217            .expect("dismiss alert");
6218
6219        let surface = model
6220            .active_workspace()
6221            .and_then(|workspace| workspace.panes.get(&pane_id))
6222            .and_then(|pane| pane.surfaces.get(&surface_id))
6223            .expect("surface");
6224        assert_eq!(surface.attention, AttentionState::Normal);
6225        assert_eq!(surface.metadata.agent_active, false);
6226        assert_eq!(surface.metadata.agent_state, None);
6227        assert_eq!(surface.metadata.agent_title, None);
6228        assert_eq!(surface.metadata.agent_kind, None);
6229        assert_eq!(surface.metadata.last_signal_at, None);
6230        assert_eq!(surface.metadata.latest_agent_message, None);
6231    }
6232
6233    #[test]
6234    fn dismiss_surface_alert_clears_working_agent_presentation() {
6235        let mut model = AppModel::new("Main");
6236        let workspace_id = model.active_workspace_id().expect("workspace");
6237        let pane_id = model.active_workspace().expect("workspace").active_pane;
6238        let surface_id = model
6239            .active_workspace()
6240            .and_then(|workspace| workspace.panes.get(&pane_id))
6241            .and_then(|pane| pane.active_surface())
6242            .map(|surface| surface.id)
6243            .expect("surface");
6244
6245        model
6246            .start_surface_agent_session(workspace_id, pane_id, surface_id, "codex".into())
6247            .expect("working session");
6248        model
6249            .apply_surface_signal(
6250                workspace_id,
6251                pane_id,
6252                surface_id,
6253                SignalEvent::with_metadata(
6254                    "agent-hook:codex",
6255                    SignalKind::Started,
6256                    Some("Working".into()),
6257                    Some(SignalPaneMetadata {
6258                        title: None,
6259                        agent_title: Some("Codex".into()),
6260                        cwd: None,
6261                        repo_name: None,
6262                        git_branch: None,
6263                        ports: Vec::new(),
6264                        agent_kind: Some("codex".into()),
6265                        agent_active: Some(true),
6266                    }),
6267                ),
6268            )
6269            .expect("started signal applied");
6270
6271        model
6272            .dismiss_surface_alert(workspace_id, pane_id, surface_id)
6273            .expect("dismiss alert");
6274
6275        let surface = model
6276            .active_workspace()
6277            .and_then(|workspace| workspace.panes.get(&pane_id))
6278            .and_then(|pane| pane.surfaces.get(&surface_id))
6279            .expect("surface");
6280        assert_eq!(surface.attention, AttentionState::Normal);
6281        assert!(surface.agent_process.is_some());
6282        assert!(surface.agent_session.is_none());
6283        assert_eq!(surface.metadata.agent_active, true);
6284        assert_eq!(surface.metadata.agent_state, None);
6285        assert_eq!(surface.metadata.agent_title.as_deref(), Some("Codex"));
6286        assert_eq!(surface.metadata.agent_kind.as_deref(), Some("codex"));
6287        assert_eq!(surface.metadata.last_signal_at, None);
6288        assert_eq!(surface.metadata.latest_agent_message, None);
6289    }
6290
6291    #[test]
6292    fn late_agent_notification_does_not_recreate_live_session_after_stop() {
6293        let mut model = AppModel::new("Main");
6294        let workspace_id = model.active_workspace_id().expect("workspace");
6295        let pane_id = model.active_workspace().expect("workspace").active_pane;
6296        let surface_id = model
6297            .active_workspace()
6298            .and_then(|workspace| workspace.panes.get(&pane_id))
6299            .and_then(|pane| pane.active_surface())
6300            .map(|surface| surface.id)
6301            .expect("surface");
6302
6303        model
6304            .start_surface_agent_session(workspace_id, pane_id, surface_id, "codex".into())
6305            .expect("start agent");
6306        model
6307            .stop_surface_agent_session(workspace_id, pane_id, surface_id, 0)
6308            .expect("stop agent");
6309        model
6310            .apply_surface_signal(
6311                workspace_id,
6312                pane_id,
6313                surface_id,
6314                SignalEvent {
6315                    source: "agent-hook:codex".into(),
6316                    kind: SignalKind::Notification,
6317                    message: Some("Turn complete".into()),
6318                    metadata: Some(SignalPaneMetadata {
6319                        title: None,
6320                        agent_title: Some("Codex".into()),
6321                        cwd: None,
6322                        repo_name: None,
6323                        git_branch: None,
6324                        ports: Vec::new(),
6325                        agent_kind: Some("codex".into()),
6326                        agent_active: Some(true),
6327                    }),
6328                    timestamp: OffsetDateTime::now_utc(),
6329                },
6330            )
6331            .expect("late notification");
6332
6333        let surface = model
6334            .active_workspace()
6335            .and_then(|workspace| workspace.panes.get(&pane_id))
6336            .and_then(|pane| pane.surfaces.get(&surface_id))
6337            .expect("surface record");
6338        assert!(surface.agent_session.is_none());
6339        assert_eq!(surface.attention, AttentionState::WaitingInput);
6340    }
6341
6342    #[test]
6343    fn triggering_surface_flash_advances_workspace_flash_token() {
6344        let mut model = AppModel::new("Main");
6345        let workspace_id = model.active_workspace_id().expect("workspace");
6346        let pane_id = model.active_workspace().expect("workspace").active_pane;
6347        let surface_id = model
6348            .active_workspace()
6349            .and_then(|workspace| workspace.panes.get(&pane_id))
6350            .map(|pane| pane.active_surface)
6351            .expect("surface");
6352
6353        model
6354            .trigger_surface_flash(workspace_id, pane_id, surface_id)
6355            .expect("trigger first flash");
6356        let first_token = model
6357            .workspaces
6358            .get(&workspace_id)
6359            .and_then(|workspace| workspace.surface_flash_tokens.get(&surface_id))
6360            .copied()
6361            .expect("first flash token");
6362        model
6363            .trigger_surface_flash(workspace_id, pane_id, surface_id)
6364            .expect("trigger second flash");
6365        let second_token = model
6366            .workspaces
6367            .get(&workspace_id)
6368            .and_then(|workspace| workspace.surface_flash_tokens.get(&surface_id))
6369            .copied()
6370            .expect("second flash token");
6371
6372        assert!(second_token > first_token);
6373    }
6374
6375    #[test]
6376    fn creating_workspace_window_tab_adds_blank_terminal_tab_and_focuses_it() {
6377        let mut model = AppModel::new("Main");
6378        let workspace_id = model.active_workspace_id().expect("workspace");
6379        let window_id = model.active_workspace().expect("workspace").active_window;
6380
6381        let (tab_id, pane_id) = model
6382            .create_workspace_window_tab(workspace_id, window_id)
6383            .expect("create window tab");
6384
6385        let workspace = model.active_workspace().expect("workspace");
6386        let window = workspace.windows.get(&window_id).expect("window");
6387        let pane = workspace.panes.get(&pane_id).expect("new pane");
6388
6389        assert_eq!(window.tabs.len(), 2);
6390        assert_eq!(window.active_tab, tab_id);
6391        assert_eq!(workspace.active_window, window_id);
6392        assert_eq!(workspace.active_pane, pane_id);
6393        assert_eq!(pane.surfaces.len(), 1);
6394        assert_eq!(
6395            pane.active_surface().map(|surface| surface.kind.clone()),
6396            Some(PaneKind::Terminal)
6397        );
6398    }
6399
6400    #[test]
6401    fn closing_last_window_tab_closes_workspace_window() {
6402        let mut model = AppModel::new("Main");
6403        let workspace_id = model.active_workspace_id().expect("workspace");
6404        let first_window_id = model.active_workspace().expect("workspace").active_window;
6405
6406        model
6407            .create_workspace_window(workspace_id, Direction::Right)
6408            .expect("create second window");
6409
6410        let closing_tab_id = model
6411            .workspaces
6412            .get(&workspace_id)
6413            .and_then(|workspace| workspace.windows.get(&first_window_id))
6414            .map(|window| window.active_tab)
6415            .expect("window tab");
6416
6417        model
6418            .close_workspace_window_tab(workspace_id, first_window_id, closing_tab_id)
6419            .expect("close last tab");
6420
6421        let workspace = model.active_workspace().expect("workspace");
6422        assert!(!workspace.windows.contains_key(&first_window_id));
6423        assert_eq!(workspace.windows.len(), 1);
6424    }
6425
6426    #[test]
6427    fn transferring_window_tab_merges_into_target_window() {
6428        let mut model = AppModel::new("Main");
6429        let workspace_id = model.active_workspace_id().expect("workspace");
6430        let source_window_id = model.active_workspace().expect("workspace").active_window;
6431        let (tab_id, _) = model
6432            .create_workspace_window_tab(workspace_id, source_window_id)
6433            .expect("create second tab");
6434        let target_pane_id = model
6435            .create_workspace_window(workspace_id, Direction::Right)
6436            .expect("create second window");
6437        let target_window_id = model
6438            .active_workspace()
6439            .and_then(|workspace| workspace.window_for_pane(target_pane_id))
6440            .expect("target window");
6441
6442        model
6443            .transfer_workspace_window_tab(
6444                workspace_id,
6445                source_window_id,
6446                tab_id,
6447                target_window_id,
6448                usize::MAX,
6449            )
6450            .expect("transfer window tab");
6451
6452        let workspace = model.active_workspace().expect("workspace");
6453        assert_eq!(
6454            workspace
6455                .windows
6456                .get(&source_window_id)
6457                .map(|window| window.tabs.len()),
6458            Some(1)
6459        );
6460        assert_eq!(
6461            workspace
6462                .windows
6463                .get(&target_window_id)
6464                .map(|window| window.tabs.len()),
6465            Some(2)
6466        );
6467        assert_eq!(workspace.active_window, target_window_id);
6468    }
6469
6470    #[test]
6471    fn extracting_window_tab_creates_new_workspace_window() {
6472        let mut model = AppModel::new("Main");
6473        let workspace_id = model.active_workspace_id().expect("workspace");
6474        let source_window_id = model.active_workspace().expect("workspace").active_window;
6475        let (tab_id, pane_id) = model
6476            .create_workspace_window_tab(workspace_id, source_window_id)
6477            .expect("create second tab");
6478
6479        let extracted_window_id = model
6480            .extract_workspace_window_tab(
6481                workspace_id,
6482                source_window_id,
6483                tab_id,
6484                WorkspaceWindowMoveTarget::StackBelow {
6485                    window_id: source_window_id,
6486                },
6487            )
6488            .expect("extract tab");
6489
6490        let workspace = model.active_workspace().expect("workspace");
6491        assert_eq!(workspace.windows.len(), 2);
6492        assert_eq!(
6493            workspace
6494                .windows
6495                .get(&source_window_id)
6496                .map(|window| window.tabs.len()),
6497            Some(1)
6498        );
6499        assert_eq!(
6500            workspace.window_for_pane(pane_id),
6501            Some(extracted_window_id)
6502        );
6503        assert_eq!(workspace.active_window, extracted_window_id);
6504    }
6505}