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