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