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