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